diff --git a/docs/providers.md b/docs/providers.md index fa70bf8228..c7fedb71b9 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -69,7 +69,7 @@ model; it does not override the model list returned by a resolver. Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch. -Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (`providers/omp/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct. +Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode/runtime-client.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (`providers/omp/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct. Claude first-party model metadata lives in `packages/server/src/server/agent/providers/claude/model-manifest.ts`. When adding or updating a Claude model, update that manifest only; the model picker thinking options and Claude-specific feature gates are derived from the manifest. Do not add model-specific Claude capability lists in feature code. @@ -95,9 +95,13 @@ OMP supports native Paseo host tools. The adapter registers the full caller-scop Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them. -OpenCode 1 keeps MCP and process environment outside the session boundary. Paseo shares one OpenCode server for ordinary agents and installs a daemon-owned plugin through `OPENCODE_CONFIG_CONTENT`. The plugin reads the exact agent environment and caller-scoped Paseo tool catalog from the daemon's private loopback bridge for each OpenCode session. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifact contains no session data or secrets. +OpenCode adapters target v1.14.46 and v2.0.10. V2 rejects binaries older than the tested 2.0.10 SDK at runtime selection. Runtime selection uses the configured command and environment. A recognized version is cached until provider configuration reload; a failed, timed-out, or unrecognized probe retains the v1 path and retries detection on the next operation. Load v2 code and materialize its plugin only after positive v2 selection; v1 sessions remain undecorated. Keep upstream SDK types inside the version-specific adapter. OpenCode owns storage migration; a missing native session must fail resume rather than create a replacement. V2 has no native archive/unarchive operation: archiving affects Paseo only. V1 retains native archiving. -An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. Keep that isolation until OpenCode exposes those values as session-owned configuration. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers. +V2.0.4 also removed the activation endpoint that gated a cold location, and a cold location registers its config-derived commands, skills, and providers asynchronously. Wait until `plugin.list` returns a populated inventory before reading the catalog or commands; an empty inventory means the location is still warming. Fail when the readiness deadline expires, including when an inventory request stalls. + +Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool. + +An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. V2 supports session environment, but its MCP configuration remains location-scoped, so separate servers still prevent one agent from changing another agent's MCP setup. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers. OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prompt APIs; let OpenCode create `msg*` IDs and record the user timeline item from the `message.updated` event. diff --git a/package-lock.json b/package-lock.json index 76664bdce8..f229f53933 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,13 @@ "ws": "^8.20.0" } }, + "node_modules/7zip-bin": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", + "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==", + "dev": true, + "license": "MIT" + }, "node_modules/@0no-co/graphql.web": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.2.0.tgz", @@ -8271,6 +8278,84 @@ "url": "https://opencollective.com/express" } }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", @@ -8644,6 +8729,48 @@ "node": ">=20.0" } }, + "node_modules/@opencode/client": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@opencode/client/-/client-2.0.10.tgz", + "integrity": "sha512-Pz4FD6jVtfZYX97AghVTM+cedi3HfI+U6gg/we5Luf08L85TewKsSXR4jHOZTCvggl0vACTquz1/hOjsJWE0Sg==", + "license": "MIT", + "dependencies": { + "@opencode/protocol": "2.0.10", + "@opencode/schema": "2.0.10" + }, + "peerDependencies": { + "effect": "4.0.0-rc.112", + "solid-js": ">=1.9.0" + }, + "peerDependenciesMeta": { + "effect": { + "optional": true + }, + "solid-js": { + "optional": true + } + } + }, + "node_modules/@opencode/protocol": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@opencode/protocol/-/protocol-2.0.10.tgz", + "integrity": "sha512-cHr0aSK39Q9c3JwPDvG45FFlE6rU/ntTFDSaqm4LRl74tki88G4WpzKk6Q56G06K6hKIONrezcPdXtDKtnZzyw==", + "license": "MIT", + "dependencies": { + "@opencode/schema": "2.0.10", + "effect": "4.0.0-rc.112" + } + }, + "node_modules/@opencode/schema": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@opencode/schema/-/schema-2.0.10.tgz", + "integrity": "sha512-RpRhRZFH1YFnM+OcrxOZc2ObIW3RQ63z/LkcHrZKa47kWF0OiaZVkbAOITeAoAoofcnS3O5ekZBvgT/7kBwRUA==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "1.1.0", + "effect": "4.0.0-rc.112" + } + }, "node_modules/@opentelemetry/api": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", @@ -14263,13 +14390,6 @@ "dev": true, "license": "BSD-2-Clause" }, - "node_modules/7zip-bin": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", - "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==", - "dev": true, - "license": "MIT" - }, "node_modules/abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", @@ -18622,6 +18742,16 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/effect": { + "version": "4.0.0-rc.112", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-rc.112.tgz", + "integrity": "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A==", + "license": "MIT", + "dependencies": { + "fast-check": "^4.9.0", + "msgpackr": "^2.0.5" + } + }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -21866,6 +21996,28 @@ "node": ">=18" } }, + "node_modules/fast-check": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.0.tgz", + "integrity": "sha512-hhqQL+IJllZi3aM4TKvmCj3bywLEcycNTTLZeLhA9ttMxBrCqM07q7Di4kl+j9EWSTXvJH1+EpIgsDbF/+8H5Q==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, "node_modules/fast-copy": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz", @@ -28141,6 +28293,37 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/msgpackr": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.1.0.tgz", + "integrity": "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.4" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, "node_modules/multipasta": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz", @@ -28466,6 +28649,21 @@ "node-gyp-build-test": "build-test.js" } }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, "node_modules/node-gyp/node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -30141,6 +30339,22 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/qrcode": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", @@ -33127,23 +33341,6 @@ "node": ">=4" } }, - "node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/string_decoder/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "license": "MIT" - }, "node_modules/string-length": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/string-length/-/string-length-5.0.1.tgz", @@ -33318,6 +33515,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -38995,6 +39209,7 @@ "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "1.14.46", + "@opencode/client": "2.0.10", "@xterm/headless": "^6.0.0", "ai": "5.0.78", "ajv": "^8.20.0", diff --git a/packages/server/package.json b/packages/server/package.json index 278b7fcd31..726bf2601d 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -89,7 +89,7 @@ "speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts", "test": "npm run test:unit && npm run test:integration", "test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"", - "test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\"", + "test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\"", "test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts", "test:integration:all": "npm run test:e2e", "test:integration:real": "vitest run real.e2e.test.ts", @@ -115,6 +115,7 @@ "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "1.14.46", + "@opencode/client": "2.0.10", "@xterm/headless": "^6.0.0", "ai": "5.0.78", "ajv": "^8.20.0", diff --git a/packages/server/scripts/build-opencode-bridge-plugin.mjs b/packages/server/scripts/build-opencode-bridge-plugin.mjs index 596f429e51..69f1e774f0 100644 --- a/packages/server/scripts/build-opencode-bridge-plugin.mjs +++ b/packages/server/scripts/build-opencode-bridge-plugin.mjs @@ -4,22 +4,27 @@ import { fileURLToPath } from "node:url"; import { build } from "esbuild"; -const sourcePath = fileURLToPath( - new URL("../src/server/agent/providers/opencode/bridge-plugin.mjs", import.meta.url), -); -const outputPath = fileURLToPath( - new URL( - "../dist/server/server/agent/providers/opencode/bridge-plugin.bundle.mjs", - import.meta.url, - ), -); - -await mkdir(path.dirname(outputPath), { recursive: true }); -await build({ - entryPoints: [sourcePath], - bundle: true, - format: "esm", - platform: "neutral", - target: "es2022", - outfile: outputPath, -}); +for (const directory of ["", "v2/"]) { + const sourcePath = fileURLToPath( + new URL( + `../src/server/agent/providers/opencode/${directory}bridge-plugin.mjs`, + import.meta.url, + ), + ); + const outputPath = fileURLToPath( + new URL( + `../dist/server/server/agent/providers/opencode/${directory}bridge-plugin.bundle.mjs`, + import.meta.url, + ), + ); + await mkdir(path.dirname(outputPath), { recursive: true }); + await build({ + entryPoints: [sourcePath], + bundle: true, + format: "esm", + platform: "neutral", + mainFields: ["module", "main"], + target: "es2022", + outfile: outputPath, + }); +} diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index af8c6f9589..0af62655ae 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -11244,3 +11244,129 @@ test("concurrent native restores run once before resuming the same agent", async rmSync(workdir, { recursive: true, force: true }); } }); + +test("commits startup notices once on create and after restored history", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-startup-notice-")); + const notice: AgentTimelineItem = { type: "notification", level: "info", message: "Runtime v2" }; + const history: AgentTimelineItem = { type: "assistant_message", text: "Existing conversation" }; + const toolOutput: AgentTimelineItem = { + type: "tool_call", + callId: "startup-output", + name: "shell", + status: "completed", + error: null, + detail: { + type: "shell", + command: "print output", + output: "x".repeat(1024 * 1024), + exitCode: 0, + }, + }; + const limitedToolOutput: AgentTimelineItem = { + ...toolOutput, + detail: { type: "shell", command: "print output", output: "x".repeat(64 * 1024), exitCode: 0 }, + }; + class NotifyingSession extends TestAgentSession { + readonly initialTimeline = [{ item: notice, timestamp: "2026-09-22T00:00:00.000Z" }]; + override async *streamHistory(): AsyncGenerator { + yield { + type: "timeline", + provider: "codex", + item: history, + timestamp: "2026-09-21T00:00:00.000Z", + }; + yield { + type: "timeline", + provider: "codex", + item: toolOutput, + timestamp: "2026-09-21T01:00:00.000Z", + }; + yield { type: "timeline", provider: "codex", ...this.initialTimeline[0] }; + } + } + const client = new (class extends TestAgentClient { + override async createSession(config: AgentSessionConfig) { + return new NotifyingSession(config); + } + override async resumeSession() { + return new NotifyingSession({ provider: "codex", cwd: workdir }); + } + })(); + const store = new RecordingTimelineStore(); + const manager = new AgentManager({ + clients: { codex: client }, + durableTimelineStore: store, + logger, + }); + const ids: string[] = []; + try { + const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {}); + ids.push(created.id); + expect((await manager.getTimelineRows(created.id)).map((row) => row.item)).toEqual([notice]); + const resumed = await manager.resumeAgentFromPersistence({ + provider: "codex", + sessionId: "existing", + metadata: { cwd: workdir }, + }); + ids.push(resumed.id); + await manager.hydrateTimelineFromProvider(resumed.id); + expect((await manager.getTimelineRows(resumed.id)).map((row) => row.item)).toEqual([ + history, + limitedToolOutput, + notice, + ]); + await manager.hydrateTimelineFromProvider(resumed.id, { force: true }); + expect((await manager.getTimelineRows(resumed.id)).map((row) => row.item)).toEqual([ + history, + limitedToolOutput, + notice, + ]); + await manager.flush(); + expect(store.writes.flat()).toContainEqual(expect.objectContaining({ item: notice })); + } finally { + for (const id of ids) await manager.closeAgent(id); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("failed startup history closes the session without registering an agent", async () => { + let closed = false; + class FailingHistorySession extends TestAgentSession { + readonly initialTimeline = [ + { + item: { type: "notification", level: "info", message: "Runtime v2" } as const, + timestamp: "2026-09-22T00:00:00.000Z", + }, + ]; + override async *streamHistory(): AsyncGenerator { + yield { + type: "timeline", + provider: "codex", + item: { type: "assistant_message", text: "Partial history" }, + }; + throw new Error("History unavailable"); + } + override async close() { + closed = true; + await super.close(); + } + } + const client = new (class extends TestAgentClient { + override async resumeSession() { + return new FailingHistorySession({ provider: "codex", cwd: tmpdir() }); + } + })(); + const manager = new AgentManager({ clients: { codex: client }, logger }); + try { + await expect( + manager.resumeAgentFromPersistence({ + provider: "codex", + sessionId: "failed-history", + metadata: { cwd: tmpdir() }, + }), + ).rejects.toThrow("History unavailable"); + expect({ agents: manager.listAgents(), closed }).toEqual({ agents: [], closed: true }); + } finally { + for (const agent of manager.listAgents()) await manager.closeAgent(agent.id); + } +}); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 2fc062d484..061c00e2ca 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -3488,11 +3488,31 @@ export class AgentManager { options, }); + // Read history before publishing the agent: a provider failure must leave the + // session unregistered so the registration catch closes it. + const startupHistory: AgentStreamEvent[] = []; + if (session.initialTimeline?.length && !managed.historyPrimed) { + for await (const event of session.streamHistory()) { + startupHistory.push(limitAgentStreamEventContent(event)); + } + } + this.assertAcceptingAgentRegistrations(); this.agents.set(resolvedAgentId, managed); registered = true; // Initialize previousStatus to track transitions this.previousStatuses.set(resolvedAgentId, managed.lifecycle); + if (session.initialTimeline?.length) { + if (!managed.historyPrimed) { + // Legacy/imported chats need their existing history before startup rows. + await this.primeTimelineFromLegacyProviderHistory(managed, false, startupHistory); + } else { + for (const entry of session.initialTimeline) { + this.recordTimeline(managed.id, entry.item, { timestamp: entry.timestamp }); + } + } + this.refreshSessionPersistence(managed); + } await this.refreshRuntimeInfo(managed, { emit: false }); this.assertAgentRegistrationActive(managed); await this.persistSnapshot(managed, { @@ -4023,6 +4043,9 @@ export class AgentManager { private async primeTimelineFromLegacyProviderHistory( agent: ActiveManagedAgent, broadcast: boolean | (() => boolean), + history: + | AsyncIterable + | Iterable = agent.session.streamHistory(), ): Promise { const deferredBroadcast = typeof broadcast === "function"; const historyEvents: Extract[] = []; @@ -4032,7 +4055,7 @@ export class AgentManager { // Collect the whole replay before touching either store. A stream that fails // halfway then leaves the committed timeline as it was, instead of a partial // copy the next attempt would append to. - for await (const rawEvent of agent.session.streamHistory()) { + for await (const rawEvent of history) { const event = limitAgentStreamEventContent(rawEvent); if (event.type === "provider_subagent") { historySubagentEvents.push(event); diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 13a8b7de1c..e2fb9779b9 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -665,6 +665,9 @@ export interface AgentSession { readonly id: string | null; readonly capabilities: AgentCapabilityFlags; readonly features?: AgentFeature[]; + /** New provider-owned rows to commit on registration. streamHistory must also + * replay them at their original timestamps; restored sessions omit old rows. */ + readonly initialTimeline?: ImportedTimelineEntry[]; run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise; startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>; steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise; diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 3ac480a872..89302bc68e 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -41,7 +41,7 @@ import { CursorACPAgentClient } from "./providers/cursor-acp-agent.js"; import { GenericACPAgentClient } from "./providers/generic-acp-agent.js"; import { KimiACPAgentClient } from "./providers/kimi-acp-agent.js"; import { KiroACPAgentClient } from "./providers/kiro-acp-agent.js"; -import { OpenCodeAgentClient } from "./providers/opencode-agent.js"; +import { OpenCodeRuntimeClient } from "./providers/opencode/runtime-client.js"; import type { OpenCodeBridge } from "./providers/opencode/bridge.js"; import { OmpAgentClient } from "./providers/omp/agent.js"; import type { OmpRuntime } from "./providers/omp/runtime.js"; @@ -216,7 +216,7 @@ const PROVIDER_CLIENT_FACTORIES: Record = { env: runtimeSettings?.env, }), opencode: (logger, runtimeSettings, options) => - new OpenCodeAgentClient(logger, runtimeSettings, { + new OpenCodeRuntimeClient(logger, runtimeSettings, { managedProcesses: options?.managedProcesses, bridge: options?.openCodeBridge, }), diff --git a/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts b/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts index b074c4a890..c8146bf2d1 100644 --- a/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts +++ b/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts @@ -1,3 +1,4 @@ +import { OpenCodeRuntimeClient } from "./opencode/runtime-client.js"; import { mkdir, mkdtemp, rm } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -5,11 +6,18 @@ import path from "node:path"; import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"; import { expect, test } from "vitest"; +import { execCommand } from "../../../utils/spawn.js"; import { createTestLogger } from "../../../test-utils/test-logger.js"; import type { PaseoToolCatalog } from "../tools/types.js"; import { OpenCodeAgentClient } from "./opencode-agent.js"; +import { OpenCodeV2AgentClient } from "./opencode/v2/agent.js"; import { OpenCodeBridge } from "./opencode/bridge.js"; import { OpenCodeServerManager } from "./opencode/server-manager.js"; +import { + drainPersistedTimeline, + readAssistantText, + requireSessionId, +} from "./opencode/test-utils/v2-local-e2e-helpers.js"; test("real OpenCode server persists provider permissions across creation and resume", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-permissions-")); @@ -86,7 +94,12 @@ test("real OpenCode server shares one process while shell.env stays session-scop mkdir(secondCwd, { recursive: true }), ]); first = await client.createSession( - { provider: "opencode", cwd: firstCwd, model: "opencode/big-pickle", modeId: "build" }, + { + provider: "opencode", + cwd: firstCwd, + model: process.env.OPENCODE_TEST_MODEL ?? "opencode/big-pickle", + modeId: "build", + }, { agentId: "real-agent-one", env: { PASEO_AGENT_ID: "real-agent-one", PASEO_AGENT_CWD: firstCwd }, @@ -127,6 +140,22 @@ test("real OpenCode server shares one process while shell.env stays session-scop expect(JSON.stringify(secondShell.data)).toContain(`real-agent-two|${secondCwd}`); expect(inspection.server.url).toMatch(/^http:\/\/127\.0\.0\.1:/); + // Keep the prompt assertions independent of asynchronous events from the direct shell API. + await first.close(); + first = await client.createSession( + { + provider: "opencode", + cwd: firstCwd, + model: process.env.OPENCODE_TEST_MODEL ?? "opencode/big-pickle", + modeId: "build", + }, + { + agentId: "real-agent-one", + env: { PASEO_AGENT_ID: "real-agent-one", PASEO_AGENT_CWD: firstCwd }, + paseoTools: firstTools, + }, + { persistSession: false }, + ); const agentResult = await first.run( [ "Use the bash tool to run: env | grep -E '^(PASEO_AGENT_ID|PASEO_AGENT_CWD)='", @@ -170,11 +199,6 @@ test("real OpenCode server shares one process while shell.env stays session-scop } }, 240_000); -function requireSessionId(session: { id: string | null }): string { - if (!session.id) throw new Error("OpenCode session has no id"); - return session.id; -} - function createCallerCatalog(callerAgentId: string): PaseoToolCatalog { const tool = { name: "report_caller_agent_id", @@ -197,8 +221,142 @@ function createCallerCatalog(callerAgentId: string): PaseoToolCatalog { }; } -function readAssistantText(timeline: ReadonlyArray<{ type: string; text?: string }>): string { - return timeline - .flatMap((item) => (item.type === "assistant_message" ? [item.text ?? ""] : [])) - .join(""); -} +test("v2 native tool bridge preserves caller identity", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-v2-")); + const logger = createTestLogger(); + const bridge = new OpenCodeBridge({ paseoHome: root, logger }); + const catalog = createCallerCatalog("paseo-v2-caller"); + bridge.setManifestCatalog(catalog); + await bridge.start(); + const client = new OpenCodeV2AgentClient({ logger, bridge }); + let session: Awaited> | undefined; + try { + session = await client.createSession( + { + provider: "opencode", + cwd: root, + model: process.env.OPENCODE_TEST_MODEL ?? "openai/gpt-6-astra", + featureValues: { auto_accept: true }, + }, + { agentId: "paseo-v2-caller", paseoTools: catalog, env: { PASEO_TEST_SCOPE: "V2_ENV_OK" } }, + { persistSession: false }, + ); + const result = await session.run( + "Call the paseo_report_caller_agent_id tool once, then reply with its exact result. Do not use any other tools.", + ); + expect(readAssistantText(result.timeline)).toContain("paseo-v2-caller"); + const environment = await session.run( + "Use the shell tool to run printf '%s' \"$PASEO_TEST_SCOPE\", then reply with its output.", + ); + expect(environment.finalText).toContain("V2_ENV_OK"); + expect(result.timeline).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "tool_call", + name: "paseo_report_caller_agent_id", + status: "completed", + }), + ]), + ); + } finally { + await session?.interrupt(); + await session?.close(); + await client.shutdown(); + await bridge.close(); + await rm(root, { recursive: true, force: true }); + } +}, 120_000); + +test.each([ + { + major: 1, + package: "opencode-ai@1.14.46", + windowsPackage: "opencode-windows", + expectedTimeline: [], + expectedInitialTimeline: undefined, + expectedNotices: undefined, + }, + { + major: 2, + package: "@opencode/cli@2.0.10", + windowsPackage: "@opencode/cli-windows", + expectedTimeline: [ + { type: "notification", level: "info", message: "This chat uses OpenCode v2." }, + ], + expectedInitialTimeline: [ + expect.objectContaining({ + item: { type: "notification", level: "info", message: "This chat uses OpenCode v2." }, + }), + ], + expectedNotices: [expect.objectContaining({ major: 2 })], + }, +])( + "versioned runtime v$major discovers models and preserves a native session handle", + async ({ + package: cliPackage, + windowsPackage, + expectedTimeline, + expectedInitialTimeline, + expectedNotices, + }) => { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-versioned-")); + const client = new OpenCodeRuntimeClient(createTestLogger(), { + command: { + mode: "replace", + argv: [ + process.platform === "win32" + ? path.join( + root, + "node_modules", + `${windowsPackage}-${process.arch}`, + "bin", + "opencode.exe", + ) + : path.join(root, "node_modules", ".bin", "opencode"), + ], + }, + env: { + XDG_CONFIG_HOME: path.join(root, "config"), + XDG_DATA_HOME: path.join(root, "data"), + XDG_CACHE_HOME: path.join(root, "cache"), + XDG_STATE_HOME: path.join(root, "state"), + }, + }); + let original: Awaited> | undefined; + let resumed: Awaited> | undefined; + try { + await execCommand( + "npm", + ["install", "--prefix", root, "--no-audit", "--no-fund", cliPackage], + { + cwd: root, + timeout: 180_000, + }, + ); + const catalog = await client.fetchCatalog({ scope: "workspace", cwd: root, force: true }); + expect(catalog.models.length).toBeGreaterThan(0); + expect(catalog.modes.map((mode) => mode.id)).toContain("build"); + original = await client.createSession( + { provider: "opencode", cwd: root, modeId: "build" }, + undefined, + { persistSession: false }, + ); + const handle = await original.describePersistence(); + expect(original.initialTimeline).toEqual(expectedInitialTimeline); + expect(handle.metadata?.openCodeRuntimeNotices).toEqual(expectedNotices); + resumed = await client.resumeSession(handle, { cwd: root }); + expect((await resumed.describePersistence()).nativeHandle).toBe(handle.nativeHandle); + expect( + (await drainPersistedTimeline(resumed)).map((event) => + event.type === "timeline" ? event.item : event, + ), + ).toEqual(expectedTimeline); + } finally { + await resumed?.close(); + await original?.close(); + await client.shutdown(); + await rm(root, { recursive: true, force: true }); + } + }, + 240_000, +); diff --git a/packages/server/src/server/agent/providers/opencode-server-manager.test.ts b/packages/server/src/server/agent/providers/opencode-server-manager.test.ts index 55875e3523..2167913d0e 100644 --- a/packages/server/src/server/agent/providers/opencode-server-manager.test.ts +++ b/packages/server/src/server/agent/providers/opencode-server-manager.test.ts @@ -393,8 +393,8 @@ describe.runIf(process.platform === "win32")( expect(records).toHaveLength(1); const record = records[0]!; expect(path.extname(record.command).toLowerCase()).toBe(".exe"); - expect(path.normalize(record.command).toLowerCase()).toContain( - path.normalize("node_modules/opencode-ai/bin/opencode.exe").toLowerCase(), + expect(path.normalize(record.command).toLowerCase()).toMatch( + /opencode-(?:ai|windows-(?:x64|arm64))[\\/]bin[\\/]opencode\.exe$/, ); expect(record.command.toLowerCase()).not.toBe(detectedOpenCode!.toLowerCase()); expect(record.args).toEqual(["serve", "--port", String(acquisition.server.port)]); diff --git a/packages/server/src/server/agent/providers/opencode-v2.local.e2e.test.ts b/packages/server/src/server/agent/providers/opencode-v2.local.e2e.test.ts new file mode 100644 index 0000000000..625ea99a39 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode-v2.local.e2e.test.ts @@ -0,0 +1,409 @@ +import { V2Runtime } from "./opencode/v2/runtime.js"; +import type { V2Api } from "./opencode/v2/api.js"; +import { execCommand } from "../../../utils/spawn.js"; +import { OpenCodeV2AgentClient } from "./opencode/v2/agent.js"; +import { mkdir, mkdtemp, rm, writeFile, readFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { expect, test } from "vitest"; + +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { + autoRespondToPermissions, + collectPermissions, + collectProviderSubagents, + collectRunningToolCalls, + collectStreamedAssistantText, + drainPersistedAssistantText, + drainPersistedTimeline, + findSkillCommand, + requireNativeUserMessageId, +} from "./opencode/test-utils/v2-local-e2e-helpers.js"; + +test("v2 shares a helper across agent identities and keeps the remaining session alive", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-v2-shared-")); + const runtime = new V2Runtime({ logger: createTestLogger() }); + const clients = new Set(); + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: { + acquire: async (input) => { + const connection = await runtime.acquire(input); + clients.add(connection.client); + return connection; + }, + shutdown: () => runtime.shutdown(), + }, + }); + const sessions: Awaited>[] = []; + try { + for (const agentId of ["first", "second"]) { + sessions.push( + await client.createSession( + { provider: "opencode", cwd: root }, + { agentId, env: { PASEO_AGENT_ID: agentId, PASEO_AGENT_CWD: root } }, + { persistSession: false }, + ), + ); + } + expect(clients.size).toBe(1); + expect(sessions[0].id).not.toBe(sessions[1].id); + await sessions[0].close(); + expect((await sessions[1].getRuntimeInfo()).sessionId).toBe(sessions[1].id); + } finally { + for (const session of sessions) await session.close(); + await client.shutdown(); + await rm(root, { recursive: true, force: true }); + } +}, 60_000); + +test("v2 structured output survives history and does not affect the next ordinary turn", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-v2-schema-")); + const client = new OpenCodeV2AgentClient({ logger: createTestLogger() }); + let session: Awaited> | undefined; + try { + session = await client.createSession( + { + provider: "opencode", + cwd: root, + model: process.env.OPENCODE_TEST_MODEL ?? "openai/gpt-6-astra", + }, + undefined, + { persistSession: false }, + ); + const result = await session.run("Compute six times seven and return the answer.", { + outputSchema: { + type: "object", + properties: { answer: { type: "integer", const: 42 } }, + required: ["answer"], + additionalProperties: false, + }, + }); + expect(JSON.parse(result.finalText)).toEqual({ answer: 42 }); + expect(await drainPersistedTimeline(session)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "timeline", + item: expect.objectContaining({ type: "assistant_message", text: result.finalText }), + }), + ]), + ); + const ordinary = await session.run("Reply with exactly ORDINARY, without using tools."); + expect(ordinary.finalText.trim()).toBe("ORDINARY"); + } finally { + await session?.interrupt(); + await session?.close(); + await client.shutdown(); + await rm(root, { recursive: true, force: true }); + } +}, 120_000); + +test("v2 preserves final text without duplicating streamed content", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-v2-stream-")); + const client = new OpenCodeV2AgentClient({ logger: createTestLogger() }); + let session: Awaited> | undefined; + try { + session = await client.createSession( + { + provider: "opencode", + cwd: root, + model: process.env.OPENCODE_TEST_MODEL ?? "openai/gpt-6-astra", + }, + undefined, + { persistSession: false }, + ); + const chunks = collectStreamedAssistantText(session); + const result = await session.run("Count from one to five, one word per line, with no tools."); + // Delta granularity is the provider's: some coalesce a whole reply into a + // single `session.text.delta`. Assert delivery happened and, critically, + // that the post-turn snapshot did not replay delivered content. + expect(chunks.length).toBeGreaterThanOrEqual(1); + const persistedText = await drainPersistedAssistantText(session); + expect(chunks.join("")).toBe(persistedText.join("")); + expect(result.finalText).toBe(persistedText.join("")); + } finally { + await session?.interrupt(); + await session?.close(); + await client.shutdown(); + await rm(root, { recursive: true, force: true }); + } +}, 120_000); + +test("v2 handles live tool approvals and questions", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-v2-approvals-")); + const client = new OpenCodeV2AgentClient({ logger: createTestLogger() }); + let session: Awaited> | undefined; + try { + const active = await client.createSession( + { + provider: "opencode", + cwd: root, + model: process.env.OPENCODE_TEST_MODEL ?? "openai/gpt-6-astra", + providerOptions: { permission: { bash: "ask" } }, + }, + undefined, + { persistSession: false }, + ); + session = active; + const permissions = autoRespondToPermissions(active); + const command = await active.run( + "Use the shell tool to run exactly: printf PASEO_APPROVED. Then report its output.", + ); + expect(command.finalText).toContain("PASEO_APPROVED"); + expect(permissions.kinds).toContain("tool"); + const question = await active.run( + "Use the question tool to ask me to choose Red or Blue. Wait for my answer, then reply with exactly the color I selected.", + ); + expect(question.finalText).toContain("Blue"); + expect(permissions.kinds).toContain("question"); + expect(permissions.errors).toEqual([]); + expect(active.getPendingPermissions()).toEqual([]); + } finally { + await session?.interrupt(); + await session?.close(); + await client.shutdown(); + await rm(root, { recursive: true, force: true }); + } +}, 120_000); + +test("v2 invokes an injected MCP tool with exact preapproval", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-v2-mcp-")); + const fixture = path.join(root, "mcp.mjs"); + await writeFile( + fixture, + `import { McpServer } from ${JSON.stringify(import.meta.resolve("@modelcontextprotocol/sdk/server/mcp.js"))}; +import { StdioServerTransport } from ${JSON.stringify(import.meta.resolve("@modelcontextprotocol/sdk/server/stdio.js"))}; +const server = new McpServer({ name: "paseo-validation", version: "1.0.0" }); +server.registerTool("marker", { description: "Return the validation marker", inputSchema: {} }, async () => ({ content: [{ type: "text", text: "PASEO_MCP_OK" }] })); +await server.connect(new StdioServerTransport()); +`, + ); + const client = new OpenCodeV2AgentClient({ logger: createTestLogger() }); + let session: Awaited> | undefined; + try { + const active = await client.createSession( + { + provider: "opencode", + cwd: root, + model: process.env.OPENCODE_TEST_MODEL ?? "openai/gpt-6-astra", + mcpServers: { validation: { type: "stdio", command: process.execPath, args: [fixture] } }, + toolPolicy: { preapproved: [{ server: "validation", tool: "marker" }] }, + }, + undefined, + { persistSession: false }, + ); + session = active; + const permissions = collectPermissions(active); + const result = await active.run( + "Call the validation_marker MCP tool once and report its output. Do not use other tools.", + ); + expect(result.finalText).toContain("PASEO_MCP_OK"); + expect(result.timeline).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "tool_call", + name: "validation_marker", + status: "completed", + }), + ]), + ); + expect(permissions).toEqual([]); + } finally { + await session?.interrupt(); + await session?.close(); + await client.shutdown(); + await rm(root, { recursive: true, force: true }); + } +}, 120_000); + +test("v2 stops a running tool before replacement work and imports the same session", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-v2-stop-")); + const client = new OpenCodeV2AgentClient({ logger: createTestLogger() }); + let session: Awaited> | undefined; + let imported: Awaited> | undefined; + try { + const config = { + provider: "opencode", + cwd: root, + model: process.env.OPENCODE_TEST_MODEL ?? "openai/gpt-6-astra", + featureValues: { auto_accept: true }, + } as const; + session = await client.createSession(config, undefined, { persistSession: false }); + const tools = collectRunningToolCalls(session); + await session.startTurn("Use the shell tool to run sleep 30, then say finished."); + await expect.poll(tools.hasRunningTool, { timeout: 45_000 }).toBe(true); + const stopping = session.interrupt(); + const replacement = session.run("Reply with exactly REPLACED. Do not use tools."); + await stopping; + expect((await replacement).finalText.trim()).toBe("REPLACED"); + expect(tools.canceledTurns()).toBe(1); + const handle = await session.describePersistence(); + const listing = await client.listImportableSessions({ cwd: root }); + expect(listing).toEqual( + expect.arrayContaining([expect.objectContaining({ providerHandleId: handle.nativeHandle })]), + ); + imported = await client.importSession( + { providerHandleId: handle.nativeHandle!, cwd: root }, + { config, storedConfig: config }, + ); + expect(imported.persistence.nativeHandle).toBe(handle.nativeHandle); + expect(imported.timeline).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + item: expect.objectContaining({ type: "assistant_message", text: "REPLACED" }), + }), + ]), + ); + } finally { + await imported?.session.close(); + await session?.interrupt(); + await session?.close(); + await client.shutdown(); + await rm(root, { recursive: true, force: true }); + } +}, 120_000); + +test("v2 rewinds conversation and files and reports autonomous subagents", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-v2-rewind-")); + await execCommand("git", ["init", root]); + await writeFile(path.join(root, "marker.txt"), "before\n"); + await execCommand("git", ["add", "marker.txt"], { cwd: root }); + await execCommand( + "git", + [ + "-c", + "user.name=Paseo Test", + "-c", + "user.email=test@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "baseline", + ], + { cwd: root }, + ); + const client = new OpenCodeV2AgentClient({ logger: createTestLogger() }); + let session: Awaited> | undefined; + try { + session = await client.createSession( + { + provider: "opencode", + cwd: root, + model: process.env.OPENCODE_TEST_MODEL ?? "openai/gpt-6-astra", + featureValues: { auto_accept: true }, + }, + undefined, + { persistSession: false }, + ); + await session.setMode!("plan"); + expect(await session.getCurrentMode!()).toBe("plan"); + await session.setMode!("build"); + const edited = await session.run( + "Edit marker.txt so its entire content is after followed by a newline. Use the file editing tools; do not use shell commands. Then say done.", + ); + expect((await readFile(path.join(root, "marker.txt"), "utf8")).trim()).toBe("after"); + const userMessageId = requireNativeUserMessageId(edited.timeline); + await session.revertBoth!({ messageId: userMessageId }); + expect(await readFile(path.join(root, "marker.txt"), "utf8")).toBe("before\n"); + expect(await drainPersistedAssistantText(session)).toEqual([]); + const subagents = collectProviderSubagents(session); + const delegated = await session.run( + "Use the subagent tool to delegate to a general subagent. Tell it to reply with exactly CHILD_OK without using tools. Wait for it, then report CHILD_OK.", + ); + expect(delegated.finalText).toContain("CHILD_OK"); + await expect.poll(subagents.hasCompletedChild, { timeout: 10_000 }).toBe(true); + expect(subagents.childText()).toContain("CHILD_OK"); + } finally { + await session?.interrupt(); + await session?.close(); + await client.shutdown(); + await rm(root, { recursive: true, force: true }); + } +}, 180_000); + +test("v2 executes commands and retains context after compaction", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-v2-commands-")); + const commandDir = path.join(root, ".opencode", "command"); + await mkdir(commandDir, { recursive: true }); + await writeFile( + path.join(commandDir, "validation.md"), + "---\ndescription: Validation command\n---\nReply with exactly COMMAND_OK. Do not use tools.\n", + ); + const client = new OpenCodeV2AgentClient({ logger: createTestLogger() }); + let session: Awaited> | undefined; + try { + session = await client.createSession( + { + provider: "opencode", + cwd: root, + model: process.env.OPENCODE_TEST_MODEL ?? "openai/gpt-6-astra", + }, + undefined, + { persistSession: false }, + ); + await session.setThinkingOption!("low"); + expect((await session.getRuntimeInfo!()).thinkingOptionId).toBe("low"); + const commands = await session.listCommands!(); + expect(commands).toEqual( + expect.arrayContaining([expect.objectContaining({ name: "validation", kind: "command" })]), + ); + expect((await session.run("/validation")).finalText).toContain("COMMAND_OK"); + await session.run("Remember the secret word ORANGE. Reply OK without tools."); + await session.run("/compact"); + expect( + (await session.run("What secret word did I ask you to remember? Reply with only that word.")) + .finalText, + ).toContain("ORANGE"); + } finally { + await session?.interrupt(); + await session?.close(); + await client.shutdown(); + await rm(root, { recursive: true, force: true }); + } +}, 180_000); + +test("v2 executes discovered skills and accepts image attachments", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-v2-attachments-")); + const skillDir = path.join(root, ".opencode", "skills", "validation"); + await mkdir(skillDir, { recursive: true }); + await writeFile( + path.join(skillDir, "SKILL.md"), + "---\nname: validation\ndescription: Validation skill\n---\nReply with exactly SKILL_OK when asked to use this skill. Do not use tools.\n", + ); + const client = new OpenCodeV2AgentClient({ logger: createTestLogger() }); + let session: Awaited> | undefined; + try { + session = await client.createSession( + { + provider: "opencode", + cwd: root, + model: process.env.OPENCODE_TEST_MODEL ?? "openai/gpt-6-astra", + }, + undefined, + { persistSession: false }, + ); + const skill = findSkillCommand(await session.listCommands!(), "validation"); + expect((await session.run(`/${skill.name}`)).finalText).toContain("SKILL_OK"); + const result = await session.run([ + { + type: "text", + text: "An image is attached. Reply exactly IMAGE_OK if you received it. Do not use tools.", + }, + { + type: "image", + mimeType: "image/png", + data: ( + await readFile(new URL("../../../../../desktop/assets/32x32.png", import.meta.url)) + ).toString("base64"), + }, + ]); + expect(result.finalText).toContain("IMAGE_OK"); + } finally { + await session?.interrupt(); + await session?.close(); + await client.shutdown(); + await rm(root, { recursive: true, force: true }); + } +}, 120_000); diff --git a/packages/server/src/server/agent/providers/opencode/bridge.test.ts b/packages/server/src/server/agent/providers/opencode/bridge.test.ts index 261487c279..2df81b3241 100644 --- a/packages/server/src/server/agent/providers/opencode/bridge.test.ts +++ b/packages/server/src/server/agent/providers/opencode/bridge.test.ts @@ -1,4 +1,5 @@ -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import type { SessionMessageInfo } from "@opencode/client"; +import { mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -8,7 +9,11 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import { createTestLogger } from "../../../../test-utils/test-logger.js"; import type { PaseoToolCatalog } from "../../tools/types.js"; -import { OpenCodeBridge, loadOpenCodeBridgePluginArtifact } from "./bridge.js"; +import { + OpenCodeBridge, + loadOpenCodeBridgePluginArtifact, + materializeOpenCodeV2Plugin, +} from "./bridge.js"; const temporaryDirectories: string[] = []; @@ -196,6 +201,182 @@ describe("OpenCodeBridge", () => { } }); + test("v2 plugin filters caller tools and inherits child session bindings", async () => { + const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-opencode-v2-scope-")); + temporaryDirectories.push(paseoHome); + const catalog = createCatalog(); + const bridge = new OpenCodeBridge({ paseoHome, logger: createTestLogger() }); + bridge.setManifestCatalog(catalog); + await bridge.start(); + const release = bridge.bindSession({ sessionId: "parent", env: {}, tools: catalog }); + const releaseDisabled = bridge.bindSession({ sessionId: "disabled", env: {} }); + try { + const env = await bridge.decorateV2ServerEnv({}); + const config = z + .object({ + plugins: z.array( + z.object({ + package: z.string(), + options: z.object({ baseUrl: z.string(), token: z.string() }), + }), + ), + }) + .parse(JSON.parse(env.OPENCODE_CONFIG_CONTENT)); + expect(await bridge.decorateV2ServerEnv(env)).toEqual(env); + const plugin = config.plugins[0]!; + const tools = new Map(); + let filter!: (input: V2TestContext) => Promise; + const module: { + default: { setup(context: V2TestPluginContext): Promise<() => Promise> }; + } = await import(pathToFileURL(path.join(fileURLToPath(plugin.package), "server.js")).href); + const dispose = await module.default.setup({ + options: plugin.options, + tool: { + transform: async (transform) => { + transform({ + add: (tool) => { + tools.set(tool.name, tool); + }, + }); + return { dispose: async () => undefined }; + }, + }, + session: { + context: async () => [], + get: async () => ({ parentID: "parent" }), + hook: async (_name, callback) => { + filter = callback; + return { dispose: async () => undefined }; + }, + }, + }); + const allowed: V2TestContext = { + sessionID: "child", + tools: { paseo_echo_context: {}, native: {} }, + }; + await filter(allowed); + expect(Object.keys(allowed.tools)).toEqual(["paseo_echo_context", "native"]); + const disabled: V2TestContext = { + sessionID: "disabled", + tools: { paseo_echo_context: {}, native: {} }, + }; + await filter(disabled); + expect(Object.keys(disabled.tools)).toEqual(["native"]); + await expect( + tools.get("paseo_echo_context")!.execute({ value: "child result" }, { sessionID: "child" }), + ).resolves.toMatchObject({ content: [{ type: "text", text: "child result" }] }); + await expect( + tools.get("paseo_echo_context")!.execute({ value: "blocked" }, { sessionID: "disabled" }), + ).rejects.toThrow("HTTP 403"); + await dispose(); + } finally { + release(); + releaseDisabled(); + await bridge.close(); + } + }); + + test("v2 structured output validates values and clears its tool on ordinary turns", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-opencode-v2-structured-")); + temporaryDirectories.push(root); + const pluginUrl = await materializeOpenCodeV2Plugin(root); + const module: { + default: { setup(context: V2TestPluginContext): Promise<() => Promise> }; + } = await import(pathToFileURL(path.join(fileURLToPath(pluginUrl), "server.js")).href); + const schema = { + type: "object", + properties: { answer: { type: "integer" } }, + required: ["answer"], + additionalProperties: false, + }; + const history: SessionMessageInfo[] = [ + { + id: "user", + type: "user", + text: "answer", + time: { created: 1 }, + metadata: { paseoOutputSchema: schema }, + }, + ]; + const tools = new Map(); + let hook!: (input: V2TestContext) => Promise; + const dispose = await module.default.setup({ + options: { baseUrl: "", token: "" }, + tool: { + transform: async (transform) => { + transform({ + add: (tool) => { + tools.set(tool.name, tool); + }, + }); + return { dispose: async () => undefined }; + }, + }, + session: { + context: async () => history, + get: async () => ({ parentID: "parent" }), + hook: async (_name, callback) => { + hook = callback; + return { dispose: async () => undefined }; + }, + }, + }); + try { + const context: V2TestContext = { + sessionID: "session", + tools: { paseo_structured_output: {} }, + system: [], + }; + await hook(context); + expect(context.tools.paseo_structured_output.input).toMatchObject({ + properties: { value: schema }, + }); + const tool = tools.get("paseo_structured_output")!; + await expect( + tool.execute({ value: { answer: "wrong" } }, { sessionID: "session" }), + ).rejects.toThrow("Invalid structured output"); + await expect( + tool.execute({ value: { answer: 42 } }, { sessionID: "session" }), + ).resolves.toMatchObject({ metadata: { paseoStructuredOutput: { answer: 42 } } }); + history.push({ id: "next", type: "user", text: "ordinary", time: { created: 2 } }); + await hook(context); + expect(context.tools).toEqual({}); + await expect( + tool.execute({ value: { answer: 42 } }, { sessionID: "session" }), + ).rejects.toThrow("no structured-output request"); + } finally { + await dispose(); + } + }); + + test("packages the v2 bridge as a directory with a server entry point", async () => { + const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-opencode-v2-package-")); + temporaryDirectories.push(paseoHome); + const bridge = new OpenCodeBridge({ paseoHome, logger: createTestLogger() }); + await bridge.start(); + try { + expect( + (await readdir(path.join(paseoHome, "runtime", "opencode"))).filter((name) => + name.startsWith("paseo-v2-"), + ), + ).toEqual([]); + const env = await bridge.decorateV2ServerEnv({ + OPENCODE_CONFIG_CONTENT: JSON.stringify({ plugins: ["user-plugin"] }), + }); + const config = z + .object({ plugins: z.tuple([z.literal("user-plugin"), z.object({ package: z.string() })]) }) + .parse(JSON.parse(env.OPENCODE_CONFIG_CONTENT)); + const directory = fileURLToPath(config.plugins[1].package); + expect((await stat(directory)).isDirectory()).toBe(true); + const entry = await readFile(path.join(directory, "server.js"), "utf8"); + expect(entry).toContain('id: "paseo"'); + const manifest = JSON.parse(await readFile(path.join(directory, "package.json"), "utf8")); + expect(manifest).toMatchObject({ type: "module", exports: { "./server": "./server.js" } }); + } finally { + await bridge.close(); + } + }); + test("preserves user OpenCode config while installing one content-addressed plugin", async () => { const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-opencode-bridge-config-")); temporaryDirectories.push(paseoHome); @@ -224,3 +405,29 @@ describe("OpenCodeBridge", () => { } }); }); + +interface V2TestTool { + name: string; + execute(input: unknown, call: { sessionID: string }): Promise; +} +interface V2TestContext { + sessionID: string; + tools: Record; + system?: Array<{ type: "text"; text: string }>; +} +interface V2TestPluginContext { + options: { baseUrl: string; token: string }; + tool: { + transform( + callback: (editor: { add(tool: V2TestTool): void }) => void, + ): Promise<{ dispose(): Promise }>; + }; + session: { + context(input: { sessionID: string }): Promise; + get(input: { sessionID: string }): Promise<{ parentID: string }>; + hook( + name: string, + callback: (input: V2TestContext) => Promise, + ): Promise<{ dispose(): Promise }>; + }; +} diff --git a/packages/server/src/server/agent/providers/opencode/bridge.ts b/packages/server/src/server/agent/providers/opencode/bridge.ts index 98e12e7eb1..52a2cd3612 100644 --- a/packages/server/src/server/agent/providers/opencode/bridge.ts +++ b/packages/server/src/server/agent/providers/opencode/bridge.ts @@ -48,6 +48,7 @@ export class OpenCodeBridge { private server: Server | null = null; private baseUrl: string | null = null; private pluginUrl: string | null = null; + private v2PluginUrl: string | null = null; private manifestCatalog: PaseoToolCatalog | null = null; constructor(options: OpenCodeBridgeOptions) { @@ -115,6 +116,14 @@ export class OpenCodeBridge { }; } + async decorateV2ServerEnv(env: Record): Promise> { + this.v2PluginUrl ??= await this.materializePlugin(2); + return decorateOpenCodeV2Env(env, this.v2PluginUrl, { + baseUrl: this.requireBaseUrl(), + token: this.token, + }); + } + async close(): Promise { const server = this.server; this.server = null; @@ -123,7 +132,8 @@ export class OpenCodeBridge { if (server) await closeServer(server); } - private async materializePlugin(): Promise { + private async materializePlugin(version: 1 | 2 = 1): Promise { + if (version === 2) return materializeOpenCodeV2Plugin(this.paseoHome); const artifact = await loadOpenCodeBridgePluginArtifact(import.meta.url); const digest = createHash("sha256").update(artifact).digest("hex"); const destination = path.join(this.paseoHome, "runtime", "opencode", `paseo-${digest}.mjs`); @@ -152,7 +162,12 @@ export class OpenCodeBridge { sendJson(response, 404, { error: "OpenCode session is not bound to a Paseo agent" }); return; } - sendJson(response, 200, { env: binding.env }); + sendJson(response, 200, { + env: binding.env, + ...(url.searchParams.has("tools") + ? { tools: [...(binding.tools?.tools.keys() ?? [])] } + : {}), + }); return; } @@ -231,13 +246,63 @@ export class OpenCodeBridge { } } +export async function materializeOpenCodeV2Plugin(paseoHome: string): Promise { + const artifact = await loadOpenCodeBridgePluginArtifact(import.meta.url, undefined, 2); + const digest = createHash("sha256").update(artifact).digest("hex"); + // V2 ignores configured file paths; it loads a package directory's server entry point. + const directory = path.join(paseoHome, "runtime", "opencode", `paseo-v2-${digest}`); + await writeFileAtomic(path.join(directory, "server.js"), artifact); + await writeFileAtomic( + path.join(directory, "package.json"), + JSON.stringify({ + name: "paseo-opencode-bridge", + private: true, + type: "module", + exports: { "./server": "./server.js" }, + }), + ); + return pathToFileURL(directory).href; +} + +export function decorateOpenCodeV2Env( + env: Record, + pluginUrl: string, + options: Record = {}, +): Record { + const config = parseOpenCodeConfig(env.OPENCODE_CONFIG_CONTENT); + const plugins = config.plugins; + if (plugins !== undefined && !Array.isArray(plugins)) + throw new Error("OpenCode v2 plugins must be an array"); + return { + ...env, + OPENCODE_CONFIG_CONTENT: JSON.stringify({ + ...config, + plugins: [ + ...(plugins ?? []).filter( + (plugin: unknown) => + plugin !== pluginUrl && + !( + typeof plugin === "object" && + plugin !== null && + "package" in plugin && + plugin.package === pluginUrl + ), + ), + { package: pluginUrl, options }, + ], + }), + }; +} + type CompileOpenCodeBridgePlugin = (sourcePath: string) => Promise; export async function loadOpenCodeBridgePluginArtifact( moduleUrl: string, compileSource: CompileOpenCodeBridgePlugin = compileOpenCodeBridgePlugin, + version: 1 | 2 = 1, ): Promise { - const bundleUrl = new URL("./bridge-plugin.bundle.mjs", moduleUrl); + const directory = version === 2 ? "./v2/" : "./"; + const bundleUrl = new URL(`${directory}bridge-plugin.bundle.mjs`, moduleUrl); try { return await readFile(bundleUrl); } catch (error) { @@ -245,7 +310,7 @@ export async function loadOpenCodeBridgePluginArtifact( throw new Error("Bundled OpenCode bridge plugin artifact is missing", { cause: error }); } } - return compileSource(fileURLToPath(new URL("./bridge-plugin.mjs", moduleUrl))); + return compileSource(fileURLToPath(new URL(`${directory}bridge-plugin.mjs`, moduleUrl))); } async function compileOpenCodeBridgePlugin(sourcePath: string): Promise { @@ -260,6 +325,7 @@ async function compileOpenCodeBridgePlugin(sourcePath: string): Promise { ]); }); }); + +describe("OpenCode v2 history reconciliation", () => { + it("keeps client message identity on live echoes but omits it from history", () => { + const user: SessionMessageUser = { + id: "native-message", + type: "user", + text: "hello", + time: { created: 1 }, + metadata: { paseoClientMessageId: "client-message" }, + }; + expect(new V2Timeline().messages([user])).toMatchObject([ + { item: { clientMessageId: "client-message", messageId: "native-message" } }, + ]); + expect(new V2Timeline(false).messages([user])).toMatchObject([ + { item: { type: "user_message", messageId: "native-message" } }, + ]); + const [event] = new V2Timeline(false).messages([user]); + expect(event && "item" in event && event.item).not.toHaveProperty("clientMessageId"); + }); + function assistant(text: string): SessionMessageAssistant { + return { + id: "message", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + time: { created: 1 }, + content: [{ type: "text", text }], + }; + } + it("emits only the missing suffix after reconnect, even if an intermediate snapshot lags", () => { + const timeline = new V2Timeline(); + const text = (messages: ReturnType) => + messages.flatMap((event) => + event.type === "timeline" && event.item.type === "assistant_message" + ? [event.item.text] + : [], + ); + expect(text(timeline.messages([assistant("hello")]))).toEqual(["hello"]); + expect(timeline.messages([assistant("hel")])).toEqual([]); + expect(text(timeline.messages([assistant("hello world")]))).toEqual([" world"]); + expect(timeline.messages([assistant("hello world")])).toEqual([]); + }); + it("keeps reasoning separate from assistant output", () => { + const timeline = new V2Timeline(); + const message = assistant("answer"); + message.content.unshift({ type: "reasoning", text: "thinking" }); + expect( + timeline.messages([message]).map((event) => (event.type === "timeline" ? event.item : null)), + ).toEqual([ + { type: "reasoning", text: "thinking" }, + { type: "assistant_message", text: "answer", messageId: "message" }, + ]); + }); + it("emits tool state transitions once and keeps their call identity", () => { + const timeline = new V2Timeline(); + const message = assistant(""); + message.content = [ + { + type: "tool", + id: "call", + name: "bash", + time: { created: 1 }, + state: { status: "running", input: { command: "pwd" }, metadata: {} }, + }, + ]; + expect(timeline.messages([message])).toMatchObject([ + { item: { type: "tool_call", callId: "call", status: "running" } }, + ]); + message.content = [ + { + type: "tool", + id: "call", + name: "bash", + time: { created: 1, completed: 2 }, + state: { + status: "completed", + input: { command: "pwd" }, + content: [{ type: "text", text: "/workspace" }], + }, + }, + ]; + expect(timeline.messages([message])).toMatchObject([ + { item: { type: "tool_call", callId: "call", status: "completed" } }, + ]); + expect(timeline.messages([message])).toEqual([]); + }); +}); diff --git a/packages/server/src/server/agent/providers/opencode/http-error.ts b/packages/server/src/server/agent/providers/opencode/http-error.ts new file mode 100644 index 0000000000..883f82deae --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/http-error.ts @@ -0,0 +1,16 @@ +export class OpenCodeHttpError extends Error { + readonly name = "OpenCodeHttpError"; + + constructor( + readonly operation: string, + readonly status: number | undefined, + readonly html: boolean, + ) { + const statusLabel = status === undefined ? "" : ` (HTTP ${status})`; + super( + html + ? `OpenCode ${operation} returned HTML instead of JSON; incompatible OpenCode API${statusLabel}` + : `OpenCode ${operation} failed${statusLabel}`, + ); + } +} diff --git a/packages/server/src/server/agent/providers/opencode/rewind.test.ts b/packages/server/src/server/agent/providers/opencode/rewind.test.ts index 18baba3a3f..b65da2de10 100644 --- a/packages/server/src/server/agent/providers/opencode/rewind.test.ts +++ b/packages/server/src/server/agent/providers/opencode/rewind.test.ts @@ -1,3 +1,5 @@ +import { V2Harness } from "./test-utils/v2-harness.js"; +import { OpenCodeV2AgentClient } from "./v2/agent.js"; import { describe, expect, test } from "vitest"; import { createTestLogger } from "../../../../test-utils/test-logger.js"; @@ -67,3 +69,37 @@ describe("OpenCode rewind", () => { }); }); }); + +test("OpenCode v2 commits conversation and file rewind only after staging succeeds", async () => { + const harness = new V2Harness(); + const operations: string[] = []; + harness.interrupt = async () => { + operations.push("interrupt"); + return { interrupted: true }; + }; + harness.api.session.revert.stage = async (input) => { + expect(input).toEqual({ sessionID: "session", messageID: "user-message", files: true }); + operations.push("stage"); + return harness.info; + }; + harness.api.session.revert.commit = async () => { + operations.push("commit"); + return harness.info; + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + try { + await session.revertBoth!({ messageId: "user-message" }); + expect(operations).toEqual(["interrupt", "stage", "commit"]); + harness.api.session.revert.stage = async () => { + throw new Error("snapshot missing"); + }; + await expect(session.revertBoth!({ messageId: "missing" })).rejects.toThrow("snapshot missing"); + expect(operations).toEqual(["interrupt", "stage", "commit", "interrupt"]); + } finally { + await session.close(); + } +}); diff --git a/packages/server/src/server/agent/providers/opencode/runtime-client.test.ts b/packages/server/src/server/agent/providers/opencode/runtime-client.test.ts new file mode 100644 index 0000000000..b1ee5be697 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/runtime-client.test.ts @@ -0,0 +1,211 @@ +import { expect, test } from "vitest"; +import { openCodeMajorVersion } from "./runtime-client.js"; + +test.each([ + ["1.14.46", 1], + ["unexpected wrapper output", 1], + ["", 1], + ["opencode v2.0.10\n", 2], + ["v2.0.10-beta.1", 2], +])("identifies the OpenCode runtime version %s", (output, major) => { + expect(openCodeMajorVersion(String(output))).toBe(major); +}); + +test.each(["3.0.0", "2.0.9", "2.0.7", "2.0.4", "2.0.3", "v2.0.1"])( + "rejects unsupported OpenCode version output %s", + (output) => { + expect(() => openCodeMajorVersion(output)).toThrow(); + }, +); + +test("records the runtime once and retains its position across resume", async () => { + const { withOpenCodeRuntimeNotice } = await import("./runtime-notice.js"); + const { V2Harness } = await import("./test-utils/v2-harness.js"); + const { OpenCodeV2AgentClient } = await import("./v2/agent.js"); + const { createTestLogger } = await import("../../../../test-utils/test-logger.js"); + const harness = new V2Harness(); + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const original = withOpenCodeRuntimeNotice( + await client.createSession({ provider: "opencode", cwd: "/tmp/project" }), + 2, + ); + const notice = { type: "notification", level: "info", message: "This chat uses OpenCode v2." }; + try { + expect(original.initialTimeline?.map((entry) => entry.item)).toEqual([notice]); + const handle = original.describePersistence()!; + await original.close(); + const resumed = withOpenCodeRuntimeNotice(await client.resumeSession(handle), 2, handle); + try { + expect(resumed.initialTimeline).toEqual([]); + const history = []; + for await (const event of resumed.streamHistory()) history.push(event); + expect(history).toEqual([ + { type: "timeline", provider: "opencode", ...original.initialTimeline![0] }, + ]); + expect(resumed.describePersistence()?.metadata?.openCodeRuntimeNotices).toEqual( + handle.metadata?.openCodeRuntimeNotices, + ); + } finally { + await resumed.close(); + } + } finally { + await client.shutdown(); + } +}); + +test("retries a rejected version probe after the configured binary changes", async () => { + const { mkdtemp, writeFile, rm } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const { OpenCodeRuntimeClient } = await import("./runtime-client.js"); + const { createTestLogger } = await import("../../../../test-utils/test-logger.js"); + const root = await mkdtemp(join(tmpdir(), "opencode-version-probe-")); + const script = join(root, "version.cjs"); + const client = new OpenCodeRuntimeClient(createTestLogger(), { + command: { mode: "replace", argv: [process.execPath, script] }, + }); + try { + await writeFile(script, 'console.log("opencode v2.0.3")'); + await expect(client.fetchCatalog({ scope: "global", force: true })).rejects.toThrow( + "Update OpenCode to 2.0.10 or newer, then refresh the provider in Paseo", + ); + await writeFile(script, 'console.log("opencode v3.0.0")'); + await expect(client.fetchCatalog({ scope: "global", force: true })).rejects.toThrow( + "Unsupported OpenCode major version 3", + ); + } finally { + await client.shutdown(); + await rm(root, { recursive: true, force: true }); + } +}); + +test.each([ + ["console.log('wrapper output')", "unrecognized output"], + ["process.exit(1)", "failed command"], +])("retries a %s version probe after updating to v2 (%s)", async (initialSource) => { + const { mkdtemp, writeFile, rm } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const { OpenCodeRuntimeClient } = await import("./runtime-client.js"); + const { createTestLogger } = await import("../../../../test-utils/test-logger.js"); + const root = await mkdtemp(join(tmpdir(), "opencode-version-retry-")); + const script = join(root, "version.cjs"); + const client = new OpenCodeRuntimeClient(createTestLogger(), { + command: { mode: "replace", argv: [process.execPath, script] }, + }); + try { + await writeFile(script, initialSource); + expect((await client.listFeatures({ provider: "opencode", cwd: root }))[0]?.label).toBe( + "Auto Accept", + ); + await writeFile(script, 'console.log("opencode v2.0.10")'); + expect((await client.listFeatures({ provider: "opencode", cwd: root }))[0]?.label).toBe( + "Auto-accept", + ); + } finally { + await client.shutdown(); + await rm(root, { recursive: true, force: true }); + } +}); + +test("appends a first notice after old history and records a changed major version", async () => { + const { withOpenCodeRuntimeNotice } = await import("./runtime-notice.js"); + const { V2Harness } = await import("./test-utils/v2-harness.js"); + const { OpenCodeV2AgentClient } = await import("./v2/agent.js"); + const { createTestLogger } = await import("../../../../test-utils/test-logger.js"); + const harness = new V2Harness(); + harness.history.push({ id: "old", type: "user", text: "Earlier prompt", time: { created: 1 } }); + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const previous = { + provider: "opencode", + sessionId: "session", + metadata: { cwd: "/tmp/project" }, + }; + const session = withOpenCodeRuntimeNotice(await client.resumeSession(previous), 2, previous); + try { + const history = []; + for await (const event of session.streamHistory()) history.push(event); + expect(history).toEqual([ + expect.objectContaining({ + type: "timeline", + item: { type: "user_message", text: "Earlier prompt", messageId: "old" }, + }), + expect.objectContaining({ + type: "timeline", + item: { type: "notification", level: "info", message: "This chat uses OpenCode v2." }, + }), + ]); + await session.close(); + const v1Handle = { + ...previous, + metadata: { + ...previous.metadata, + openCodeRuntimeNotices: [{ major: 1, timestamp: "2026-01-01T00:00:00.000Z" }], + }, + }; + const changed = withOpenCodeRuntimeNotice(await client.resumeSession(v1Handle), 2, v1Handle); + try { + expect(changed.initialTimeline?.map((entry) => entry.item)).toEqual([ + { type: "notification", level: "info", message: "This chat uses OpenCode v2." }, + ]); + expect(changed.describePersistence()?.metadata?.openCodeRuntimeNotices).toHaveLength(2); + } finally { + await changed.close(); + } + } finally { + await session.close(); + await client.shutdown(); + } +}); + +test.each([ + "process.exit(1)", + 'console.log("custom wrapper output")', + "setTimeout(() => {}, 30000)", +])( + "preserves legacy operations when the version probe is inconclusive: %s", + async (source) => { + const { mkdtemp, writeFile, rm } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const { OpenCodeRuntimeClient } = await import("./runtime-client.js"); + const { OpenCodeAgentClient } = await import("../opencode-agent.js"); + const { createTestLogger } = await import("../../../../test-utils/test-logger.js"); + const { OpenCodeServerManager } = await import("./server-manager.js"); + const root = await mkdtemp(join(tmpdir(), "opencode-legacy-probe-")); + const script = join(root, "version.cjs"); + await writeFile(script, source); + const logger = createTestLogger(); + let legacyShutdown = false; + class TrackedServerManager extends OpenCodeServerManager { + override async shutdown() { + legacyShutdown = true; + await super.shutdown(); + } + } + const client = new OpenCodeRuntimeClient( + logger, + { + command: { mode: "replace", argv: [process.execPath, script] }, + }, + { serverManager: new TrackedServerManager({ logger }) }, + ); + const legacy = new OpenCodeAgentClient(logger); + const config = { provider: "opencode", cwd: root }; + try { + expect(await client.listFeatures(config)).toEqual(await legacy.listFeatures(config)); + } finally { + await client.shutdown(); + await legacy.shutdown(); + await rm(root, { recursive: true, force: true }); + } + expect(legacyShutdown).toBe(true); + }, + 10000, +); diff --git a/packages/server/src/server/agent/providers/opencode/runtime-client.ts b/packages/server/src/server/agent/providers/opencode/runtime-client.ts new file mode 100644 index 0000000000..2a34abe35d --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/runtime-client.ts @@ -0,0 +1,164 @@ +import type { Logger } from "pino"; +import type { + AgentClient, + AgentSessionConfig, + AgentLaunchContext, + AgentCreateSessionOptions, + AgentPersistenceHandle, + FetchCatalogOptions, + ProviderRefreshContext, + ListImportableSessionsOptions, + ImportProviderSessionInput, + ImportProviderSessionContext, +} from "../../agent-sdk-types.js"; +import { + createProviderEnvSpec, + resolveProviderLaunch, + type ProviderRuntimeSettings, +} from "../../provider-launch-config.js"; +import { execCommand } from "../../../../utils/spawn.js"; +import { OpenCodeAgentClient } from "../opencode-agent.js"; +import type { OpenCodeV2AgentClient } from "./v2/agent.js"; +import { withOpenCodeRuntimeNotice } from "./runtime-notice.js"; + +// Keep the minimum aligned with the SDK and binary exercised by CI. +const MINIMUM_V2: readonly [number, number] = [0, 10]; +const VERSION_PATTERN = /^(?:opencode\s+)?v?(\d+)\.(\d+)\.(\d+)(?:[-+][\w.-]+)?$/i; + +export function openCodeMajorVersion(output: string): 1 | 2 { + const version = output.trim().match(VERSION_PATTERN); + if (!version) return 1; + if (version[1] === "1") return 1; + if (version[1] === "2") { + const [minimumMinor, minimumPatch] = MINIMUM_V2; + const minor = Number(version[2]); + const patch = Number(version[3]); + if (minor < minimumMinor || (minor === minimumMinor && patch < minimumPatch)) + throw new Error( + `OpenCode ${version[1]}.${version[2]}.${version[3]} is too old for this Paseo integration. Update OpenCode to 2.${minimumMinor}.${minimumPatch} or newer, then refresh the provider in Paseo.`, + ); + return 2; + } + throw new Error( + `Unsupported OpenCode major version ${version[1]}; supported versions are 1 and 2`, + ); +} + +export type OpenCodeRuntimeClientOptions = NonNullable< + ConstructorParameters[2] +>; + +// Selection belongs to the configured client, which provider reload replaces. +export class OpenCodeRuntimeClient implements AgentClient { + readonly provider = "opencode"; + readonly capabilities; + readonly resolveCreateConfig; + readonly isCreateConfigUnattended; + private selected: Promise | null = null; + private legacySelected = false; + private readonly legacy: OpenCodeAgentClient; + constructor( + private readonly logger: Logger, + private readonly settings?: ProviderRuntimeSettings, + private readonly options: OpenCodeRuntimeClientOptions = {}, + ) { + this.legacy = new OpenCodeAgentClient(logger, settings, options); + this.capabilities = this.legacy.capabilities; + this.resolveCreateConfig = this.legacy.resolveCreateConfig; + this.isCreateConfigUnattended = this.legacy.isCreateConfigUnattended; + } + private client(): Promise { + this.selected ??= (async () => { + let output: string; + try { + const launch = await resolveProviderLaunch({ + commandConfig: this.settings?.command, + defaultBinary: "opencode", + }); + const result = await execCommand(launch.command, [...launch.args, "--version"], { + ...createProviderEnvSpec({ runtimeSettings: this.settings }), + timeout: 5_000, + }); + output = result.stdout; + } catch { + // Version discovery is additive: legacy wrappers need not support --version. + this.selected = null; + this.legacySelected = true; + return this.legacy; + } + if (!VERSION_PATTERN.test(output.trim())) this.selected = null; + if (openCodeMajorVersion(output) === 1) { + this.legacySelected = true; + return this.legacy; + } + const { OpenCodeV2AgentClient } = await import("./v2/agent.js"); + return new OpenCodeV2AgentClient({ + logger: this.logger, + settings: this.settings, + managedProcesses: this.options.managedProcesses, + bridge: this.options.bridge, + }); + })().catch((error: unknown) => { + // A failed probe must not poison refresh after the user updates the binary. + this.selected = null; + throw error; + }); + return this.selected; + } + async isAvailable() { + return this.legacy.isAvailable(); + } + async getDiagnostic() { + return this.legacy.getDiagnostic(); + } + async createSession( + config: AgentSessionConfig, + launch?: AgentLaunchContext, + options?: AgentCreateSessionOptions, + ) { + const client = await this.client(); + const session = await client.createSession(config, launch, options); + return client === this.legacy ? session : withOpenCodeRuntimeNotice(session, 2); + } + async resumeSession( + handle: AgentPersistenceHandle, + config?: Partial, + launch?: AgentLaunchContext, + ) { + const client = await this.client(); + const session = await client.resumeSession(handle, config, launch); + return client === this.legacy ? session : withOpenCodeRuntimeNotice(session, 2, handle); + } + async fetchCatalog(options: FetchCatalogOptions, context?: ProviderRefreshContext) { + return (await this.client()).fetchCatalog(options, context); + } + async listCommands(config: AgentSessionConfig) { + return (await this.client()).listCommands(config); + } + async listFeatures(config: AgentSessionConfig) { + return (await this.client()).listFeatures(config); + } + async listImportableSessions(options?: ListImportableSessionsOptions) { + return (await this.client()).listImportableSessions(options); + } + async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) { + const client = await this.client(); + const imported = await client.importSession(input, context); + return client === this.legacy + ? imported + : { ...imported, session: withOpenCodeRuntimeNotice(imported.session, 2) }; + } + async archiveNativeSession(handle: AgentPersistenceHandle) { + const client = await this.client(); + if (client instanceof OpenCodeAgentClient) await client.archiveNativeSession(handle); + } + async unarchiveNativeSession(handle: AgentPersistenceHandle) { + const client = await this.client(); + if (client instanceof OpenCodeAgentClient) await client.unarchiveNativeSession(handle); + } + async shutdown() { + const selected = await this.selected?.catch(() => null); + if (this.legacySelected) await this.legacy.shutdown(); + if (selected && selected !== this.legacy) await selected.shutdown(); + } +} diff --git a/packages/server/src/server/agent/providers/opencode/runtime-notice.ts b/packages/server/src/server/agent/providers/opencode/runtime-notice.ts new file mode 100644 index 0000000000..4b480e7c18 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/runtime-notice.ts @@ -0,0 +1,62 @@ +import { z } from "zod"; +import type { + AgentPersistenceHandle, + AgentSession, + AgentStreamEvent, + ImportedTimelineEntry, +} from "../../agent-sdk-types.js"; + +const Notices = z.array( + z.object({ major: z.union([z.literal(1), z.literal(2)]), timestamp: z.string().datetime() }), +); + +// These rows belong to Paseo, not OpenCode's conversation. Keep their original +// timestamps in the persistence handle so history rebuilds preserve placement. +export function withOpenCodeRuntimeNotice( + session: AgentSession, + major: 1 | 2, + previous?: AgentPersistenceHandle, +): AgentSession { + const notices = Notices.parse(previous?.metadata?.openCodeRuntimeNotices ?? []); + const added = notices.at(-1)?.major !== major; + if (added) notices.push({ major, timestamp: new Date().toISOString() }); + const entries: ImportedTimelineEntry[] = notices.map(({ major: recordedMajor, timestamp }) => ({ + timestamp, + item: { + type: "notification", + level: "info", + message: `This chat uses OpenCode v${recordedMajor}.`, + }, + })); + const describePersistence = session.describePersistence.bind(session); + const streamHistory = session.streamHistory.bind(session); + return Object.assign(session, { + initialTimeline: added ? entries.slice(-1) : [], + describePersistence() { + const handle = describePersistence(); + return ( + handle && { ...handle, metadata: { ...handle.metadata, openCodeRuntimeNotices: notices } } + ); + }, + async *streamHistory(): AsyncGenerator { + const pending = [...entries]; + const event = (entry: ImportedTimelineEntry): AgentStreamEvent => ({ + type: "timeline", + provider: "opencode", + ...entry, + }); + for await (const row of streamHistory()) { + while ( + row.type === "timeline" && + row.timestamp && + pending[0]?.timestamp && + pending[0].timestamp <= row.timestamp + ) { + yield event(pending.shift()!); + } + yield row; + } + for (const entry of pending) yield event(entry); + }, + }); +} diff --git a/packages/server/src/server/agent/providers/opencode/server-manager.ts b/packages/server/src/server/agent/providers/opencode/server-manager.ts index 4161cd9876..f1cd43030e 100644 --- a/packages/server/src/server/agent/providers/opencode/server-manager.ts +++ b/packages/server/src/server/agent/providers/opencode/server-manager.ts @@ -2,6 +2,7 @@ import type { ChildProcess } from "node:child_process"; import { mkdirSync } from "node:fs"; import { stat } from "node:fs/promises"; import net from "node:net"; +import { createRequire } from "node:module"; import path from "node:path"; import type { Logger } from "pino"; @@ -622,25 +623,23 @@ async function resolveOpenCodeBinary(): Promise { } if (process.platform === "win32" && path.extname(found).toLowerCase() === ".cmd") { - // Global npm: /opencode.cmd → /node_modules/opencode-ai/bin/opencode.exe - const globalCandidate = path.join( - path.dirname(found), - "node_modules", - "opencode-ai", - "bin", - "opencode.exe", - ); - if (await pathExists(globalCandidate)) return globalCandidate; + const packageDirectories = [ + path.join(path.dirname(found), "node_modules", "opencode-ai"), + path.join(path.dirname(found), "..", "opencode-ai"), + ]; + for (const packageDirectory of packageDirectories) { + const bundledBinary = path.join(packageDirectory, "bin", "opencode.exe"); + if (await pathExists(bundledBinary)) return bundledBinary; - // Local/pnpm: /node_modules/.bin/opencode.cmd → /node_modules/opencode-ai/bin/opencode.exe - const localCandidate = path.join( - path.dirname(found), - "..", - "opencode-ai", - "bin", - "opencode.exe", - ); - if (await pathExists(localCandidate)) return localCandidate; + // Newer npm releases keep the executable in a platform dependency. + // Resolve from the CLI package so nested installs and pnpm both work. + try { + const require = createRequire(path.join(packageDirectory, "package.json")); + return require.resolve(`opencode-windows-${process.arch}/bin/opencode.exe`); + } catch { + // Try the other npm layout before retaining the original command. + } + } console.warn( "[opencode-server] Found opencode.cmd but could not resolve the real opencode.exe. " + diff --git a/packages/server/src/server/agent/providers/opencode/test-utils/v2-harness.ts b/packages/server/src/server/agent/providers/opencode/test-utils/v2-harness.ts new file mode 100644 index 0000000000..7a0776b52c --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/test-utils/v2-harness.ts @@ -0,0 +1,142 @@ +import type { + OpenCodeEvent, + SessionInfo, + SessionMessageInfo, + SessionCreateInput, +} from "@opencode/client"; +import type { V2Api } from "../v2/api.js"; +import type { V2Connection } from "../v2/runtime.js"; + +function unexpected(): never { + throw new Error("Unexpected OpenCode v2 test operation"); +} + +export class V2Harness { + readonly info: SessionInfo = { + id: "session", + projectID: "project", + location: { directory: "/tmp/project" }, + agent: "build", + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + cost: 0, + time: { created: 1, updated: 1 }, + outcome: "succeeded", + }; + readonly history: SessionMessageInfo[] = []; + readonly creates: SessionCreateInput[] = []; + readonly prompts: string[] = []; + readonly environments: Array<{ sessionID: string; variables: Record }> = []; + readonly mcpAdds: string[] = []; + releases = 0; + prompt: V2Api["session"]["prompt"] = async (input) => { + this.prompts.push(input.text); + }; + wait: V2Api["session"]["wait"] = async () => undefined; + interrupt: V2Api["session"]["interrupt"] = async () => { + this.info.outcome = "interrupted"; + return { interrupted: true }; + }; + private pendingEvents: OpenCodeEvent[] = []; + private notify: (() => void) | null = null; + push(event: OpenCodeEvent) { + this.pendingEvents.push(event); + this.notify?.(); + } + + readonly api: V2Api = { + server: { info: unexpected }, + plugin: { + list: async () => ({ + location: { directory: this.info.location.directory }, + data: [ + { id: "paseo", source: { type: "builtin" }, features: {}, state: { status: "active" } }, + ], + }), + }, + agent: { + list: async () => ({ + location: { + ...this.info.location, + project: { id: "project", directory: "/tmp/project", canonical: "/tmp/project" }, + }, + data: [], + }), + }, + model: { list: unexpected, default: unexpected }, + provider: { list: unexpected }, + command: { list: unexpected }, + skill: { list: unexpected }, + mcp: { + add: async (input) => { + this.mcpAdds.push(input.server); + }, + list: async (input) => ({ + location: { directory: input?.location?.directory ?? this.info.location.directory }, + data: this.mcpAdds.map((name) => ({ + name, + status: { status: "connected" as const }, + })), + }), + }, + message: { + list: async (input) => { + if (input.cursor && input.order) throw new Error("cursor cannot be combined with order"); + return { data: [...this.history], cursor: {} }; + }, + }, + permission: { list: async () => [], reply: unexpected }, + session: { + form: { list: async () => [], reply: unexpected, cancel: unexpected }, + create: async (input = {}) => { + this.creates.push(input); + return this.info; + }, + get: async () => this.info, + list: async () => ({ data: [], cursor: {} }), + active: async () => ({}), + remove: async () => undefined, + switchAgent: unexpected, + switchModel: unexpected, + environment: async (input) => { + this.environments.push(input); + }, + instructions: { entry: { list: unexpected, put: unexpected, remove: unexpected } }, + prompt: (input, options) => this.prompt(input, options), + wait: (input, options) => this.wait(input, options), + interrupt: (input, options) => this.interrupt(input, options), + command: unexpected, + compact: unexpected, + revert: { stage: unexpected, clear: unexpected, commit: unexpected }, + log: unexpected, + }, + event: { subscribe: (options) => this.events(options?.signal) }, + }; + readonly connection: V2Connection = { + client: this.api, + release: async () => { + this.releases += 1; + }, + retain: () => this.connection, + exited: new Promise(() => undefined), + }; + readonly runtime = { acquire: async () => this.connection, shutdown: async () => undefined }; + + private async *events(signal?: AbortSignal): AsyncGenerator { + yield { id: "connected", created: 1, type: "server.connected", data: {} }; + const wake = () => this.notify?.(); + signal?.addEventListener("abort", wake); + try { + while (true) { + if (signal?.aborted) return; + const event = this.pendingEvents.shift(); + if (event) yield event; + else + await new Promise((resolve) => { + this.notify = resolve; + }); + } + } finally { + signal?.removeEventListener("abort", wake); + } + } +} diff --git a/packages/server/src/server/agent/providers/opencode/test-utils/v2-local-e2e-helpers.ts b/packages/server/src/server/agent/providers/opencode/test-utils/v2-local-e2e-helpers.ts new file mode 100644 index 0000000000..3365932c6e --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/test-utils/v2-local-e2e-helpers.ts @@ -0,0 +1,152 @@ +import { z } from "zod"; +import type { + AgentPermissionRequest, + AgentSession, + AgentSlashCommand, + AgentStreamEvent, + AgentTimelineItem, +} from "../../../agent-sdk-types.js"; + +export function requireSessionId(session: { id: string | null }): string { + if (!session.id) throw new Error("OpenCode session has no id"); + return session.id; +} + +export function readAssistantText( + timeline: ReadonlyArray & { text?: string }>, +): string { + return timeline + .flatMap((item) => (item.type === "assistant_message" ? [item.text ?? ""] : [])) + .join(""); +} + +export function requireNativeUserMessageId(timeline: readonly AgentTimelineItem[]): string { + const user = timeline.find((item) => item.type === "user_message"); + if (!user || user.type !== "user_message" || !user.messageId) + throw new Error("Missing native user message ID"); + return user.messageId; +} + +export function findSkillCommand( + commands: readonly AgentSlashCommand[], + nameFragment: string, +): AgentSlashCommand { + const skill = commands.find((item) => item.kind === "skill" && item.name.includes(nameFragment)); + if (!skill) throw new Error(`Missing skill command matching ${nameFragment}`); + return skill; +} + +/** Live assistant text as it streams, so tests never spell out the event filter. */ +export function collectStreamedAssistantText(session: AgentSession): string[] { + const text: string[] = []; + session.subscribe((event) => { + if (event.type === "timeline" && event.item.type === "assistant_message") + text.push(event.item.text); + }); + return text; +} + +export function drainPersistedTimeline(session: AgentSession): Promise { + return drain(session); +} + +async function drain(session: AgentSession): Promise { + const events: AgentStreamEvent[] = []; + for await (const event of session.streamHistory()) events.push(event); + return events; +} + +/** Assistant text replayed from persisted history. */ +export async function drainPersistedAssistantText(session: AgentSession): Promise { + return (await drainPersistedTimeline(session)).flatMap((event) => + event.type === "timeline" && event.item.type === "assistant_message" ? [event.item.text] : [], + ); +} + +export function collectRunningToolCalls(session: AgentSession) { + let running = false; + let canceledTurns = 0; + session.subscribe((event) => { + if (event.type === "turn_canceled") canceledTurns += 1; + if ( + event.type === "timeline" && + event.item.type === "tool_call" && + event.item.status === "running" + ) + running = true; + }); + return { + hasRunningTool: () => running, + canceledTurns: () => canceledTurns, + }; +} + +export function collectPermissions(session: AgentSession): string[] { + const names: string[] = []; + session.subscribe((event) => { + if (event.type === "permission_requested") names.push(event.request.name); + }); + return names; +} + +interface AutoRespondOptions { + /** Answer value applied to every question header. */ + questionAnswer?: string; +} + +/** + * Answers provider permissions as they arrive: questions with the configured + * answer, tools with a one-time approval. Tests assert the collected kinds and + * errors instead of branching on permission type. + */ +export function autoRespondToPermissions(session: AgentSession, options: AutoRespondOptions = {}) { + const kinds: string[] = []; + const errors: unknown[] = []; + session.subscribe((event) => { + if (event.type !== "permission_requested") return; + kinds.push(event.request.kind); + void respondToRequest(session, event.request, options).catch((error: unknown) => { + errors.push(error); + }); + }); + return { kinds, errors }; +} + +function respondToRequest( + session: AgentSession, + request: AgentPermissionRequest, + options: AutoRespondOptions, +): Promise { + if (request.kind !== "question") { + return session.respondToPermission(request.id, { + behavior: "allow", + selectedActionId: "once", + }); + } + const input = z + .object({ questions: z.array(z.object({ header: z.string() })) }) + .parse(request.input); + return session.respondToPermission(request.id, { + behavior: "allow", + updatedInput: { + answers: Object.fromEntries( + input.questions.map((question) => [question.header, options.questionAnswer ?? "Blue"]), + ), + }, + }); +} + +export function collectProviderSubagents(session: AgentSession) { + let completed = false; + let text = ""; + session.subscribe((event) => { + if (event.type !== "provider_subagent") return; + if (event.event.type === "upsert" && event.event.status === "completed") completed = true; + if (event.event.type === "timeline" && event.event.item.type === "assistant_message") + text += event.event.item.text; + }); + return { + hasCompletedChild: () => completed, + childText: () => text, + }; +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts b/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts new file mode 100644 index 0000000000..5ce99a5d8b --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts @@ -0,0 +1,404 @@ +import type { SessionMessageInfo } from "@opencode/client"; +import { describe, expect, test } from "vitest"; + +import { createTestLogger } from "../../../../../test-utils/test-logger.js"; +import type { AgentStreamEvent } from "../../../agent-sdk-types.js"; +import { OpenCodeV2AgentClient } from "./agent.js"; +import { V2Harness } from "../test-utils/v2-harness.js"; + +describe("OpenCode v2 session lifecycle", () => { + test("fails initialization when the event stream ends before connecting", async () => { + const harness = new V2Harness(); + harness.api.event.subscribe = async function* () {}; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + await expect( + client.createSession({ provider: "opencode", cwd: "/tmp/project" }), + ).rejects.toThrow("event stream ended before connecting"); + expect(harness.releases).toBeGreaterThan(0); + }); + + test("returns only validated structured output and preserves it in history", async () => { + const harness = new V2Harness(); + harness.prompt = async (input) => { + harness.history.push({ + id: "user", + type: "user", + text: input.text, + metadata: input.metadata, + time: { created: 1 }, + }); + harness.history.push({ + id: "answer", + type: "assistant", + agent: "build", + model: { providerID: "test", id: "model" }, + time: { created: 2 }, + content: [ + { type: "text", text: "Here is the answer" }, + { + type: "tool", + id: "output", + name: "paseo_structured_output", + time: { created: 2 }, + state: { + status: "completed", + input: { value: { answer: 42 } }, + content: [{ type: "text", text: "accepted" }], + metadata: { paseoStructuredOutput: { answer: 42 } }, + }, + }, + ], + }); + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + try { + const result = await session.run("answer", { + outputSchema: { + type: "object", + properties: { answer: { const: 42 } }, + required: ["answer"], + }, + }); + expect(result.finalText).toBe('{"answer":42}'); + const history = []; + for await (const event of session.streamHistory!()) history.push(event); + expect(history).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "timeline", + item: expect.objectContaining({ type: "assistant_message", text: result.finalText }), + }), + ]), + ); + } finally { + await session.close(); + } + }); + + test("fails a structured turn when the model omits the validated output tool", async () => { + const harness = new V2Harness(); + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + try { + await expect(session.run("answer", { outputSchema: { type: "object" } })).rejects.toThrow( + "without submitting the required structured output", + ); + } finally { + await session.close(); + } + }); + + test("resumes the native session ID and never replaces a missing session", async () => { + const harness = new V2Harness(); + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const handle = { + provider: "opencode", + sessionId: "session", + nativeHandle: "session", + metadata: { cwd: "/tmp/project" }, + } as const; + harness.api.session.switchAgent = async ({ agent }) => { + harness.info.agent = agent; + }; + harness.api.session.switchModel = async ({ model }) => { + harness.info.model = model; + }; + const session = await client.resumeSession(handle, { + modeId: "plan", + model: "synthetic/test", + thinkingOptionId: "low", + }); + try { + expect(await session.getRuntimeInfo!()).toMatchObject({ + modeId: "plan", + model: "synthetic/test", + thinkingOptionId: "low", + }); + expect(await session.describePersistence()).toMatchObject({ + sessionId: "session", + nativeHandle: "session", + }); + expect(harness.creates).toEqual([]); + } finally { + await session.close(); + } + harness.api.session.get = async () => { + throw new Error("Session not found"); + }; + await expect(client.resumeSession(handle)).rejects.toThrow("Session not found"); + expect(harness.creates).toEqual([]); + }); + + test("reconciles reconnect snapshots without replaying text or losing repeated chunks", async () => { + const harness = new V2Harness(); + let finish!: () => void; + harness.wait = () => + new Promise((resolve) => { + finish = resolve; + }); + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + const chunks: string[] = []; + session.subscribe((event) => { + if (event.type === "timeline" && event.item.type === "assistant_message") + chunks.push(event.item.text); + }); + try { + const running = session.run("laugh"); + await expect.poll(() => typeof finish).toBe("function"); + const answer = { + id: "answer", + type: "assistant", + agent: "build", + model: { providerID: "test", id: "model" }, + time: { created: 2 }, + content: [{ type: "text", text: "ha" }], + } satisfies SessionMessageInfo; + harness.history.push(answer); + harness.push({ id: "reconnect-1", created: 2, type: "server.connected", data: {} }); + await expect.poll(() => chunks).toEqual(["ha"]); + answer.content[0].text = "haha"; + harness.push({ id: "reconnect-2", created: 3, type: "server.connected", data: {} }); + await expect.poll(() => chunks).toEqual(["ha", "ha"]); + finish(); + expect((await running).finalText).toBe("haha"); + expect(chunks).toEqual(["ha", "ha"]); + } finally { + await session.close(); + } + }); + + test("reports a terminal failure when reading the execution error fails", async () => { + const harness = new V2Harness(); + harness.info.outcome = "failed"; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + try { + await session.startTurn("hello"); + const failures = () => events.filter((event) => event.type === "turn_failed"); + await expect.poll(failures).toHaveLength(1); + expect(events.filter((event) => event.type === "turn_completed")).toHaveLength(0); + } finally { + await session.close(); + } + }); + + test("waits for prompt acceptance before interrupting", async () => { + const harness = new V2Harness(); + let accept!: () => void; + let settle!: () => void; + let interruptions = 0; + harness.prompt = () => + new Promise((resolve) => { + accept = resolve; + }); + harness.wait = () => + new Promise((resolve) => { + settle = resolve; + }); + harness.interrupt = async () => { + interruptions += 1; + harness.info.outcome = "interrupted"; + settle(); + return { interrupted: true }; + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + try { + await session.startTurn("first"); + const stopping = session.interrupt(); + await Promise.resolve(); + expect(interruptions).toBe(0); + accept(); + await stopping; + expect(interruptions).toBe(1); + } finally { + await session.close(); + } + }); + + test("completes a submitted turn once and reconciles its final text", async () => { + const harness = new V2Harness(); + harness.prompt = async (input) => { + harness.prompts.push(input.text); + harness.history.push({ + id: "answer", + type: "assistant", + agent: "build", + model: { providerID: "test", id: "model" }, + time: { created: 2 }, + content: [{ type: "text", text: "done" }], + }); + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + try { + const result = await session.run("hello"); + expect(result.finalText).toBe("done"); + expect(events.filter((event) => event.type === "turn_completed")).toHaveLength(1); + expect(harness.creates[0]).toMatchObject({ + agent: "build", + location: { directory: "/tmp/project" }, + }); + } finally { + await session.close(); + } + expect(harness.releases).toBe(1); + }); + + test("waits for interruption settlement before submitting replacement work", async () => { + const harness = new V2Harness(); + let finishFirst!: () => void; + let finishStop!: () => void; + harness.wait = () => + new Promise((resolve) => { + finishFirst = resolve; + }); + harness.interrupt = () => + new Promise((resolve) => { + finishStop = () => { + harness.info.outcome = "interrupted"; + finishFirst(); + resolve({ interrupted: true }); + }; + }); + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + try { + await session.startTurn("first"); + await expect.poll(() => typeof finishFirst).toBe("function"); + const stopping = session.interrupt(); + const replacement = session.startTurn("second"); + await Promise.resolve(); + expect(harness.prompts).toEqual(["first"]); + harness.wait = async () => undefined; + finishStop(); + await stopping; + await replacement; + await expect.poll(() => harness.prompts).toEqual(["first", "second"]); + } finally { + await session.close(); + } + }); + + test("reuses the shared helper for sessions whose env is only agent identity", async () => { + const harness = new V2Harness(); + const acquires: Array<{ env?: Record; dedicated?: boolean }> = []; + const runtime = { + acquire: async (input: { env?: Record; dedicated?: boolean } = {}) => { + acquires.push(input); + return harness.connection; + }, + shutdown: async () => undefined, + }; + const client = new OpenCodeV2AgentClient({ logger: createTestLogger(), runtime }); + const session = await client.createSession( + { provider: "opencode", cwd: "/tmp/project" }, + { agentId: "agent", env: { PASEO_AGENT_ID: "agent", PASEO_AGENT_CWD: "/tmp/project" } }, + ); + try { + expect(acquires).toEqual([{}]); + expect(harness.environments).toEqual([ + { + sessionID: "session", + variables: { PASEO_AGENT_ID: "agent", PASEO_AGENT_CWD: "/tmp/project" }, + }, + ]); + } finally { + await session.close(); + } + }); + + test("starts a dedicated helper when the session carries custom MCP", async () => { + const harness = new V2Harness(); + const acquires: Array<{ env?: Record; dedicated?: boolean }> = []; + const runtime = { + acquire: async (input: { env?: Record; dedicated?: boolean } = {}) => { + acquires.push(input); + return harness.connection; + }, + shutdown: async () => undefined, + }; + const client = new OpenCodeV2AgentClient({ logger: createTestLogger(), runtime }); + const session = await client.createSession( + { + provider: "opencode", + cwd: "/tmp/project", + mcpServers: { custom: { type: "stdio", command: "custom", args: [] } }, + }, + { agentId: "agent", env: { PASEO_AGENT_ID: "agent", PASEO_AGENT_CWD: "/tmp/project" } }, + ); + try { + expect(acquires).toHaveLength(1); + expect(acquires[0]?.dedicated).toBe(true); + } finally { + await session.close(); + } + }); + + test("refuses replacement work after a failed stop until Stop succeeds", async () => { + const harness = new V2Harness(); + let finishFirst!: () => void; + harness.wait = () => + new Promise((resolve) => { + finishFirst = resolve; + }); + harness.interrupt = async () => { + throw new Error("stop failed"); + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + try { + await session.startTurn("first"); + await expect.poll(() => typeof finishFirst).toBe("function"); + await expect(session.interrupt()).rejects.toThrow("stop failed"); + await expect(session.startTurn("unsafe replacement")).rejects.toThrow("stop failed"); + expect(harness.prompts).toEqual(["first"]); + harness.interrupt = async () => { + finishFirst(); + return { interrupted: true }; + }; + await session.interrupt(); + harness.wait = async () => undefined; + await session.startTurn("retry"); + await expect.poll(() => harness.prompts).toEqual(["first", "retry"]); + } finally { + await session.close(); + } + }); +}); diff --git a/packages/server/src/server/agent/providers/opencode/v2/agent.ts b/packages/server/src/server/agent/providers/opencode/v2/agent.ts new file mode 100644 index 0000000000..ef268b3578 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/agent.ts @@ -0,0 +1,264 @@ +import { OpenCodeV2Session } from "./session.js"; +import { V2_CAPABILITIES } from "./capabilities.js"; +import { + features, + permissionRules, + applyResumeOverrides, + requiresDedicatedV2Server, +} from "./configuration.js"; +import { commands } from "./commands.js"; + +import { waitForLocationReady, awaitPaseoPlugin } from "./readiness.js"; + +import type { SessionInfo } from "@opencode/client"; + +import type { Logger } from "pino"; +import type { + AgentCapabilityFlags, + AgentClient, + AgentCreateSessionOptions, + AgentFeature, + AgentLaunchContext, + AgentPersistenceHandle, + AgentSession, + AgentSessionConfig, + FetchCatalogOptions, + ImportableProviderSession, + ImportProviderSessionInput, + ImportProviderSessionContext, + ListImportableSessionsOptions, + ProviderRefreshContext, +} from "../../../agent-sdk-types.js"; +import type { ProviderRuntimeSettings } from "../../../provider-launch-config.js"; +import type { ManagedProcessRegistry } from "../../../../managed-processes/managed-processes.js"; + +import { importSessionFromPersistence } from "../../../provider-session-import.js"; + +import type { OpenCodeBridge } from "../bridge.js"; +import { resolveOpenCodeHomeDir } from "../paths.js"; +import { V2Runtime, type V2Connection } from "./runtime.js"; +import { modelRef, modesFromV2, modelsFromV2 } from "./mapping.js"; + +interface V2AgentOptions { + logger: Logger; + settings?: ProviderRuntimeSettings; + managedProcesses?: ManagedProcessRegistry; + bridge?: OpenCodeBridge; + runtime?: Pick; +} + +export class OpenCodeV2AgentClient implements AgentClient { + readonly provider = "opencode"; + readonly capabilities: AgentCapabilityFlags; + private readonly runtime: Pick; + private readonly connections = new Map(); + constructor(private readonly options: V2AgentOptions) { + this.capabilities = { ...V2_CAPABILITIES, supportsNativePaseoTools: Boolean(options.bridge) }; + this.runtime = + options.runtime ?? + new V2Runtime({ + ...options, + decorateEnv: options.bridge ? (env) => options.bridge!.decorateV2ServerEnv(env) : undefined, + }); + } + async isAvailable() { + return true; + } + async shutdown() { + await this.runtime.shutdown(); + } + async fetchCatalog(options: FetchCatalogOptions, context?: ProviderRefreshContext) { + const connection = await this.runtime.acquire({ + fresh: options.force, + signal: context?.signal, + }); + const location = { + directory: options.scope === "workspace" ? options.cwd : resolveOpenCodeHomeDir(), + }; + const request = { signal: context?.signal }; + const activity = (name: string, operation: () => Promise) => + context ? context.runActivity(name, operation) : operation(); + try { + await activity("plugin.list", () => + waitForLocationReady({ client: connection.client, location, signal: request.signal }), + ); + const [models, agents, providers] = await Promise.all([ + activity("model.list", () => connection.client.model.list({ location }, request)), + activity("agent.list", () => connection.client.agent.list({ location }, request)), + activity("provider.list", () => connection.client.provider.list({ location }, request)), + ]); + if (!providers.data.length) + throw new Error( + "OpenCode has no connected providers. Authenticate using opencode auth login.", + ); + return { models: modelsFromV2(models.data), modes: modesFromV2(agents.data) }; + } finally { + await connection.release(); + } + } + async createSession( + config: AgentSessionConfig, + launch?: AgentLaunchContext, + options?: AgentCreateSessionOptions, + ): Promise { + const connection = await this.runtime.acquire( + requiresDedicatedV2Server(config, launch) ? { env: launch?.env, dedicated: true } : {}, + ); + try { + const info = await connection.client.session.create({ + location: { directory: config.cwd }, + title: config.title, + agent: config.modeId ?? "build", + model: config.model ? modelRef(config.model, config.thinkingOptionId) : undefined, + permissions: permissionRules(config), + }); + return await this.attach(connection, info, config, launch, options?.persistSession !== false); + } catch (error) { + await connection.release(); + throw error; + } + } + async resumeSession( + handle: AgentPersistenceHandle, + overrides?: Partial, + launch?: AgentLaunchContext, + ): Promise { + const cwd = overrides?.cwd ?? handle.metadata?.cwd; + if (typeof cwd !== "string") + throw new Error("OpenCode resume requires the original working directory"); + const config: AgentSessionConfig = { + ...handle.metadata, + ...overrides, + provider: "opencode", + cwd, + }; + const connection = + this.connections.get(handle.nativeHandle ?? handle.sessionId)?.retain() ?? + (await this.runtime.acquire( + requiresDedicatedV2Server(config, launch) ? { env: launch?.env, dedicated: true } : {}, + )); + try { + const info = await connection.client.session.get({ + sessionID: handle.nativeHandle ?? handle.sessionId, + }); + await applyResumeOverrides(connection.client, info, overrides); + return await this.attach(connection, info, config, launch, true); + } catch (error) { + await connection.release(); + throw error; + } + } + private async attach( + connection: V2Connection, + info: SessionInfo, + config: AgentSessionConfig, + launch: AgentLaunchContext | undefined, + persist: boolean, + ) { + const unbind = this.options.bridge?.bindSession({ + sessionId: info.id, + env: launch?.env ?? {}, + tools: launch?.paseoTools, + }); + const bound = new Map void>(); + const bindChild = (childId: string) => { + this.connections.set(childId, connection); + if (bound.has(childId)) return; + const childUnbind = this.options.bridge?.bindSession({ + sessionId: childId, + env: launch?.env ?? {}, + tools: launch?.paseoTools, + }); + if (childUnbind) bound.set(childId, childUnbind); + }; + this.connections.set(info.id, connection); + const releaseBindings = () => { + unbind?.(); + for (const cleanup of bound.values()) cleanup(); + for (const [id, owner] of this.connections) + if (owner === connection) this.connections.delete(id); + }; + const session = new OpenCodeV2Session( + connection, + info, + config, + this.options.logger, + persist, + Boolean(this.options.bridge), + releaseBindings, + bindChild, + ); + try { + if (this.options.bridge) { + const location = { directory: config.cwd }; + await awaitPaseoPlugin({ client: connection.client, location }); + } + await session.initialize(launch); + return session; + } catch (error) { + await session.close(); + throw error; + } + } + async listFeatures(config: AgentSessionConfig): Promise { + return features(config); + } + async listCommands(config: AgentSessionConfig) { + const connection = await this.runtime.acquire(); + try { + return await commands(connection.client, config.cwd); + } finally { + await connection.release(); + } + } + async listImportableSessions( + options: ListImportableSessionsOptions = {}, + ): Promise { + const connection = await this.runtime.acquire(); + try { + const sessions: SessionInfo[] = []; + let cursor: string | undefined; + const scanLimit = Math.min(options.scanLimit ?? 100, 500); + do { + const page = await connection.client.session.list({ + ...(cursor ? { cursor } : { directory: options.cwd, search: options.query }), + limit: Math.min(50, scanLimit - sessions.length), + }); + sessions.push(...page.data); + cursor = page.cursor.next ?? undefined; + } while (cursor && sessions.length < scanLimit); + return sessions + .filter((info) => !info.parentID && !info.time.archived) + .slice(0, options.limit ?? 50) + .map((info) => ({ + providerHandleId: info.id, + cwd: info.location.directory, + title: info.title ?? null, + firstPromptPreview: null, + lastPromptPreview: null, + lastActivityAt: new Date(info.time.updated), + })); + } finally { + await connection.release(); + } + } + async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) { + const connection = await this.runtime.acquire(); + try { + const info = await connection.client.session.get({ sessionID: input.providerHandleId }); + return await importSessionFromPersistence({ + provider: "opencode", + request: input, + context, + resumeSession: this.resumeSession.bind(this), + config: { + title: info.title, + modeId: info.agent, + model: info.model ? `${info.model.providerID}/${info.model.id}` : undefined, + }, + }); + } finally { + await connection.release(); + } + } +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/api.ts b/packages/server/src/server/agent/providers/opencode/v2/api.ts new file mode 100644 index 0000000000..1e656e6603 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/api.ts @@ -0,0 +1,36 @@ +import type { OpenCodeClient } from "@opencode/client"; + +export interface V2Api { + server: OpenCodeClient["server"]; + session: Pick< + OpenCodeClient["session"], + | "create" + | "get" + | "list" + | "active" + | "remove" + | "switchAgent" + | "switchModel" + | "environment" + | "instructions" + | "prompt" + | "command" + | "compact" + | "wait" + | "interrupt" + | "revert" + | "log" + > & { + form: Pick; + }; + plugin: Pick; + model: OpenCodeClient["model"]; + provider: Pick; + agent: Pick; + command: OpenCodeClient["command"]; + skill: OpenCodeClient["skill"]; + message: OpenCodeClient["message"]; + mcp: Pick; + permission: Pick; + event: OpenCodeClient["event"]; +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/bridge-plugin.mjs b/packages/server/src/server/agent/providers/opencode/v2/bridge-plugin.mjs new file mode 100644 index 0000000000..b14540a1c3 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/bridge-plugin.mjs @@ -0,0 +1,67 @@ +import { registerStructuredOutput, STRUCTURED_OUTPUT_TOOL } from "./structured-output-plugin.mjs"; + +export default { + id: "paseo", + async setup(context) { + const disposeStructuredOutput = await registerStructuredOutput(context); + const { baseUrl, token } = context.options; + if (!baseUrl || !token) return disposeStructuredOutput; + async function request(pathname, body, allowMissing = false) { + const response = await fetch(new URL(`/_internal/opencode${pathname}`, baseUrl), { + method: body === undefined ? "GET" : "POST", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + if (allowMissing && response.status === 404) return null; + if (!response.ok) throw new Error(`Paseo tool bridge returned HTTP ${response.status}`); + return response.json(); + } + async function scope(sessionID) { + const visited = new Set(); + while (sessionID && !visited.has(sessionID)) { + visited.add(sessionID); + const binding = await request( + `/sessions/${encodeURIComponent(sessionID)}/context?tools`, + undefined, + true, + ); + if (binding) return { ...binding, sessionID }; + const session = await context.session.get({ sessionID }); + sessionID = session.parentID; + } + throw new Error("OpenCode session is not bound to a Paseo agent"); + } + const manifest = await request("/tools"); + const registration = await context.tool.transform((editor) => { + for (const definition of manifest.tools) { + editor.add({ + name: `paseo_${definition.name}`, + description: definition.description, + input: definition.inputSchema, + options: { codemode: false }, + async execute(input, call) { + const binding = await scope(call.sessionID); + const result = await request( + `/sessions/${encodeURIComponent(binding.sessionID)}/tools/${encodeURIComponent(definition.name)}`, + input, + ); + return { content: result.content, metadata: { paseoTool: definition.name } }; + }, + }); + } + }); + const filtering = await context.session.hook("context", async (input) => { + const binding = await scope(input.sessionID); + const allowed = new Set(binding.tools.map((name) => `paseo_${name}`)); + for (const name of Object.keys(input.tools)) { + if (name !== STRUCTURED_OUTPUT_TOOL && name.startsWith("paseo_") && !allowed.has(name)) + delete input.tools[name]; + } + }); + return async () => { + await disposeStructuredOutput(); + await filtering.dispose(); + await registration.dispose(); + }; + }, +}; diff --git a/packages/server/src/server/agent/providers/opencode/v2/capabilities.ts b/packages/server/src/server/agent/providers/opencode/v2/capabilities.ts new file mode 100644 index 0000000000..4369af279b --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/capabilities.ts @@ -0,0 +1,13 @@ +import type { AgentCapabilityFlags } from "../../../agent-sdk-types.js"; +export const V2_CAPABILITIES: AgentCapabilityFlags = { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsSessionListing: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + supportsRewindBoth: true, + supportsRewindConversation: false, + supportsRewindFiles: false, +}; diff --git a/packages/server/src/server/agent/providers/opencode/v2/children.ts b/packages/server/src/server/agent/providers/opencode/v2/children.ts new file mode 100644 index 0000000000..bbb5fe7ed8 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/children.ts @@ -0,0 +1,91 @@ +import { V2Timeline } from "./timeline.js"; + +import type { SessionInfo } from "@opencode/client"; + +import type { AgentStreamEvent } from "../../../agent-sdk-types.js"; + +import type { ProviderSubagentInputEvent } from "../../../provider-subagents/store.js"; + +import { messages } from "./history.js"; + +import type { V2Api } from "./api.js"; +interface ChildrenOptions { + client: V2Api; + id: string; + emit(event: AgentStreamEvent): void; + reconcilePermissions(id: string): Promise; + bindChild?: (id: string) => void; +} +export class SessionChildren { + private readonly children = new Map(); + private readonly childStates = new Map(); + constructor(private readonly options: ChildrenOptions) {} + async observe(event: import("@opencode/client").OpenCodeEvent) { + if ( + event.type === "session.created" && + event.data.parentID && + (event.data.parentID === this.options.id || this.children.has(event.data.parentID)) + ) { + await this.reconcile(event.data.parentID); + } + if ( + "sessionID" in event.data && + typeof event.data.sessionID === "string" && + this.children.has(event.data.sessionID) + ) { + await this.reconcileChild( + await this.options.client.session.get({ sessionID: event.data.sessionID }), + ); + } + } + async reconcile(parentID: string) { + let cursor: string | undefined; + do { + const page = await this.options.client.session.list({ + ...(cursor ? { cursor } : { parentID }), + limit: 100, + }); + for (const child of page.data) { + await this.reconcileChild(child); + await this.reconcile(child.id); + } + cursor = page.cursor.next ?? undefined; + } while (cursor); + } + private async reconcileChild(info: SessionInfo) { + await this.options.reconcilePermissions(info.id); + let timeline = this.children.get(info.id); + if (!timeline) { + timeline = new V2Timeline(); + this.children.set(info.id, timeline); + this.options.bindChild?.(info.id); + } + const active = await this.options.client.session.active(); + let status: "running" | "failed" | "canceled" | "completed" = "completed"; + if (active[info.id]) status = "running"; + else if (info.outcome === "failed") status = "failed"; + else if (info.outcome === "interrupted") status = "canceled"; + const presentation: ProviderSubagentInputEvent = { + type: "upsert", + id: info.id, + parentSubagentId: info.parentID === this.options.id ? null : info.parentID, + title: info.title ?? null, + status, + cwd: info.location.directory, + timestamp: new Date(info.time.updated).toISOString(), + }; + const signature = JSON.stringify(presentation); + if (this.childStates.get(info.id) !== signature) { + this.childStates.set(info.id, signature); + this.options.emit({ type: "provider_subagent", provider: "opencode", event: presentation }); + } + for (const event of timeline.messages(await messages(this.options.client, info.id))) { + if (event.type === "timeline") + this.options.emit({ + type: "provider_subagent", + provider: "opencode", + event: { type: "timeline", id: info.id, item: event.item, timestamp: event.timestamp }, + }); + } + } +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/commands.ts b/packages/server/src/server/agent/providers/opencode/v2/commands.ts new file mode 100644 index 0000000000..1bd52db1ee --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/commands.ts @@ -0,0 +1,39 @@ +import { waitForLocationReady } from "./readiness.js"; + +import type { V2Api } from "./api.js"; + +import type { AgentSlashCommand } from "../../../agent-sdk-types.js"; + +export async function commands(client: V2Api, directory: string): Promise { + const location = { directory }; + await waitForLocationReady({ client, location }); + const [configured, skills] = await Promise.all([ + client.command.list({ location }), + client.skill.list({ location }), + ]); + const result = new Map(); + for (const name of ["compact", "summarize"]) + result.set(name, { + name, + description: "Compact the current session", + argumentHint: "", + kind: "command", + }); + for (const command of configured.data) + result.set(command.name, { + name: command.name, + description: command.description ?? "", + argumentHint: "", + kind: "command", + }); + for (const skill of skills.data) { + if (result.has(skill.id)) continue; + result.set(skill.id, { + name: skill.id, + description: skill.description ?? "", + argumentHint: "", + kind: "skill", + }); + } + return [...result.values()]; +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/configuration.test.ts b/packages/server/src/server/agent/providers/opencode/v2/configuration.test.ts new file mode 100644 index 0000000000..6479ca3851 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/configuration.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "vitest"; + +import { features, permissionRules, requiresDedicatedV2Server } from "./configuration.js"; + +describe("OpenCode v2 permission rules", () => { + test("maps authored native permissions and exact injected MCP grants", () => { + expect( + permissionRules({ + provider: "opencode", + cwd: "/tmp/project", + providerOptions: { permission: { bash: { pwd: "allow" }, task: "ask" } }, + toolPolicy: { preapproved: [{ server: "paseo.host", tool: "read/info" }] }, + }), + ).toEqual([ + { action: "paseo_host_read_info", resource: "*", effect: "allow" }, + { action: "shell", resource: "pwd", effect: "allow" }, + { action: "subagent", resource: "*", effect: "ask" }, + ]); + }); +}); + +describe("OpenCode v2 helper isolation", () => { + test("shares the helper for agent-identity environment only", () => { + expect( + requiresDedicatedV2Server( + { provider: "opencode", cwd: "/tmp/project" }, + { agentId: "agent", env: { PASEO_AGENT_ID: "agent", PASEO_AGENT_CWD: "/tmp/project" } }, + ), + ).toBe(false); + }); + + test("dedicates a helper for any other process environment", () => { + expect( + requiresDedicatedV2Server( + { provider: "opencode", cwd: "/tmp/project" }, + { agentId: "agent", env: { CUSTOM_FLAG: "1" } }, + ), + ).toBe(true); + }); + + test("dedicates a helper when the session carries custom MCP", () => { + expect( + requiresDedicatedV2Server({ + provider: "opencode", + cwd: "/tmp/project", + mcpServers: { custom: { type: "stdio", command: "custom", args: [] } }, + }), + ).toBe(true); + }); +}); + +describe("OpenCode v2 features", () => { + test("reports the auto-accept toggle from the session value", () => { + expect(features({ provider: "opencode", cwd: "/tmp/project" })).toEqual([ + { type: "toggle", id: "auto_accept", label: "Auto-accept", value: false }, + ]); + expect( + features({ + provider: "opencode", + cwd: "/tmp/project", + featureValues: { auto_accept: true }, + }), + ).toEqual([{ type: "toggle", id: "auto_accept", label: "Auto-accept", value: true }]); + }); +}); diff --git a/packages/server/src/server/agent/providers/opencode/v2/configuration.ts b/packages/server/src/server/agent/providers/opencode/v2/configuration.ts new file mode 100644 index 0000000000..9a565e6fca --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/configuration.ts @@ -0,0 +1,83 @@ +import type { SessionInfo, PermissionRuleset } from "@opencode/client"; +import type { V2Api } from "./api.js"; + +import type { + AgentFeature, + AgentLaunchContext, + AgentSessionConfig, +} from "../../../agent-sdk-types.js"; + +import { OpenCodeProviderOptionsSchema, buildOpenCodePermissionRules } from "../options.js"; + +import { modelRef } from "./mapping.js"; + +export function features(config: AgentSessionConfig): AgentFeature[] { + return [ + { + type: "toggle", + id: "auto_accept", + label: "Auto-accept", + value: config.featureValues?.["auto_accept"] === true, + }, + ]; +} +export function permissionRules(config: AgentSessionConfig): PermissionRuleset { + const rules = + buildOpenCodePermissionRules( + OpenCodeProviderOptionsSchema.parse(config.providerOptions ?? {}), + undefined, + ) ?? []; + const grants: PermissionRuleset = (config.toolPolicy?.preapproved ?? []).map((grant) => ({ + action: `${grant.server.replace(/[^a-zA-Z0-9_-]/g, "_")}_${grant.tool.replace(/[^a-zA-Z0-9_-]/g, "_")}`, + resource: "*", + effect: "allow", + })); + return [ + ...grants, + ...rules.map((rule) => ({ + action: nativePermissionAction(rule.permission), + resource: rule.pattern, + effect: rule.action, + })), + ]; +} +function nativePermissionAction(action: string): string { + if (action === "bash") return "shell"; + if (action === "task") return "subagent"; + return action; +} +export async function applyResumeOverrides( + client: V2Api, + info: SessionInfo, + overrides?: Partial, +) { + if (overrides?.modeId) { + await client.session.switchAgent({ + sessionID: info.id, + agent: overrides.modeId, + }); + info.agent = overrides.modeId; + } + if (overrides?.model || overrides?.thinkingOptionId !== undefined) { + const model = overrides.model + ? modelRef(overrides.model, overrides.thinkingOptionId ?? info.model?.variant) + : info.model && { ...info.model, variant: overrides.thinkingOptionId }; + if (!model) throw new Error("Select an OpenCode model before changing its variant"); + await client.session.switchModel({ sessionID: info.id, model }); + info.model = model; + } +} + +// Agent identity env is applied per session through `session.environment`, so +// it must not force a dedicated server. Custom MCP is location-scoped, and any +// other env key belongs to the process, so both need isolation. Mirrors the v1 +// adapter's `requiresDedicatedOpenCodeServer`. +const V2_SESSION_ENV_KEYS = new Set(["PASEO_AGENT_ID", "PASEO_AGENT_CWD"]); + +export function requiresDedicatedV2Server( + config: AgentSessionConfig, + launch?: AgentLaunchContext, +): boolean { + if (Object.keys(config.mcpServers ?? {}).length > 0) return true; + return Object.keys(launch?.env ?? {}).some((key) => !V2_SESSION_ENV_KEYS.has(key)); +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/history.ts b/packages/server/src/server/agent/providers/opencode/v2/history.ts new file mode 100644 index 0000000000..8687ced82d --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/history.ts @@ -0,0 +1,17 @@ +import type { SessionMessageInfo } from "@opencode/client"; +import type { V2Api } from "./api.js"; + +export async function messages(client: V2Api, sessionID: string): Promise { + const result: SessionMessageInfo[] = []; + let cursor: string | undefined; + do { + const page = await client.message.list({ + sessionID, + ...(cursor ? { cursor } : { order: "asc" }), + limit: 100, + }); + result.push(...page.data); + cursor = page.cursor.next ?? undefined; + } while (cursor); + return result; +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/mapping.test.ts b/packages/server/src/server/agent/providers/opencode/v2/mapping.test.ts new file mode 100644 index 0000000000..b0286f0160 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/mapping.test.ts @@ -0,0 +1,50 @@ +import type { AgentInfo, ModelInfo } from "@opencode/client"; +import { expect, test } from "vitest"; +import { modelsFromV2, modesFromV2 } from "./mapping.js"; + +test("normalizes v2 model capabilities, variants, and visible primary modes", () => { + const model: ModelInfo = { + id: "model", + modelID: "native-model", + providerID: "provider", + name: "Model", + capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, + variants: [{ id: "high" }], + time: { released: 1 }, + cost: [], + status: "active", + enabled: true, + limit: { context: 200000, output: 10000 }, + }; + expect(modelsFromV2([model, { ...model, id: "disabled", enabled: false }])).toEqual([ + { + provider: "opencode", + id: "provider/model", + label: "Model", + contextWindowMaxTokens: 200000, + metadata: { + providerId: "provider", + modelId: "model", + supportsAttachments: true, + supportsToolCall: true, + contextWindowMaxTokens: 200000, + }, + thinkingOptions: [{ id: "high", label: "high" }], + }, + ]); + const agent: AgentInfo = { + id: "build", + name: "Build", + mode: "primary", + hidden: false, + request: { settings: {}, headers: {}, body: {} }, + permissions: [], + }; + expect( + modesFromV2([ + agent, + { ...agent, id: "hidden", hidden: true }, + { ...agent, id: "child", mode: "subagent" }, + ]), + ).toEqual([{ id: "build", label: "Build", description: undefined }]); +}); diff --git a/packages/server/src/server/agent/providers/opencode/v2/mapping.ts b/packages/server/src/server/agent/providers/opencode/v2/mapping.ts new file mode 100644 index 0000000000..fc37897476 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/mapping.ts @@ -0,0 +1,47 @@ +import type { AgentInfo, ModelInfo, ModelRef, SessionInfo } from "@opencode/client"; +import type { AgentMode, AgentModelDefinition, AgentUsage } from "../../../agent-sdk-types.js"; + +export function modelRef(id: string, variant?: string | null): ModelRef { + const slash = id.indexOf("/"); + if (slash < 1 || slash === id.length - 1) + throw new Error("OpenCode model must be provider/model"); + return { + providerID: id.slice(0, slash), + id: id.slice(slash + 1), + ...(variant ? { variant } : {}), + }; +} + +export function modelsFromV2(models: ModelInfo[]): AgentModelDefinition[] { + return models + .filter((model) => model.enabled) + .map((model) => ({ + provider: "opencode", + id: `${model.providerID}/${model.id}`, + label: model.name, + contextWindowMaxTokens: model.limit.context, + metadata: { + providerId: model.providerID, + modelId: model.id, + supportsAttachments: model.capabilities.input.includes("image"), + supportsToolCall: model.capabilities.tools, + contextWindowMaxTokens: model.limit.context, + }, + thinkingOptions: model.variants.map((variant) => ({ id: variant.id, label: variant.id })), + })); +} + +export function modesFromV2(agents: AgentInfo[]): AgentMode[] { + return agents + .filter((agent) => !agent.hidden && agent.mode !== "subagent") + .map((agent) => ({ id: agent.id, label: agent.name, description: agent.description })); +} + +export function usageFromV2(session: SessionInfo): AgentUsage { + return { + inputTokens: session.tokens.input, + outputTokens: session.tokens.output, + cachedInputTokens: session.tokens.cache.read, + totalCostUsd: session.cost, + }; +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/permissions.test.ts b/packages/server/src/server/agent/providers/opencode/v2/permissions.test.ts new file mode 100644 index 0000000000..451bdf81c6 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/permissions.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, test } from "vitest"; + +import { createTestLogger } from "../../../../../test-utils/test-logger.js"; +import type { V2Api } from "./api.js"; +import { OpenCodeV2AgentClient } from "./agent.js"; +import { V2Harness } from "../test-utils/v2-harness.js"; + +describe("OpenCode v2 questions", () => { + test("maps selected labels to native values and parses numeric and boolean answers", async () => { + const harness = new V2Harness(); + harness.api.session.form.list = async () => [ + { + id: "question", + sessionID: "session", + title: "Preferences", + fields: [ + { key: "color", type: "string", options: [{ label: "Blue", value: "blue-id" }] }, + { + key: "features", + type: "multiselect", + options: [{ label: "Search", value: "search-id" }], + }, + { key: "count", type: "integer" }, + { key: "enabled", type: "boolean" }, + ], + }, + ]; + const answers: Parameters[0][] = []; + harness.api.session.form.reply = async (input) => { + answers.push(input); + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + try { + expect(session.getPendingPermissions()).toHaveLength(1); + await session.respondToPermission("question", { + behavior: "allow", + updatedInput: { + answers: { + color: "Blue", + features: ["Search"], + count: "3", + enabled: "false", + }, + }, + }); + expect(answers).toEqual([ + { + sessionID: "session", + formID: "question", + answer: { + color: "blue-id", + features: ["search-id"], + count: 3, + enabled: false, + }, + }, + ]); + expect(session.getPendingPermissions()).toHaveLength(0); + } finally { + await session.close(); + } + }); +}); + +describe("OpenCode v2 permission routing", () => { + test("routes a child approval back to its owning session", async () => { + const harness = new V2Harness(); + const child = { ...harness.info, id: "child", parentID: "session" }; + harness.api.session.list = async (input) => ({ + data: input?.parentID === "session" ? [child] : [], + cursor: {}, + }); + harness.api.permission.list = async (input) => + input.sessionID === "child" + ? [{ id: "child-permission", sessionID: "child", action: "shell", resources: ["pwd"] }] + : []; + const replies: Parameters[0][] = []; + harness.api.permission.reply = async (input) => { + replies.push(input); + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + try { + expect(session.getPendingPermissions()).toHaveLength(1); + await session.respondToPermission("child-permission", { + behavior: "allow", + selectedActionId: "once", + }); + expect(replies).toEqual([ + { sessionID: "child", requestID: "child-permission", decision: "once" }, + ]); + } finally { + await session.close(); + } + }); + + test("clears an approval resolved by another native client", async () => { + const harness = new V2Harness(); + harness.api.permission.list = async () => [ + { id: "approval", sessionID: "session", action: "shell", resources: ["pwd"] }, + ]; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + try { + expect(session.getPendingPermissions()).toHaveLength(1); + harness.api.permission.list = async () => []; + harness.push({ + id: "resolved", + created: 2, + type: "permission.replied", + data: { sessionID: "session", requestID: "approval", reply: "once" }, + }); + await expect.poll(() => session.getPendingPermissions()).toEqual([]); + } finally { + await session.close(); + } + }); +}); diff --git a/packages/server/src/server/agent/providers/opencode/v2/permissions.ts b/packages/server/src/server/agent/providers/opencode/v2/permissions.ts new file mode 100644 index 0000000000..12965a6d7e --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/permissions.ts @@ -0,0 +1,157 @@ +import type { FormInfo, FormValue } from "@opencode/client"; +import type { V2Api } from "./api.js"; + +import type { + AgentPermissionRequest, + AgentPermissionResponse, + AgentSessionConfig, + AgentStreamEvent, +} from "../../../agent-sdk-types.js"; + +export class SessionPermissions { + private readonly pending = new Map(); + private readonly permissionOwners = new Map(); + private readonly forms = new Map(); + constructor( + private readonly client: V2Api, + private readonly id: string, + private readonly config: AgentSessionConfig, + private readonly emit: (event: AgentStreamEvent) => void, + ) {} + observe(event: import("@opencode/client").OpenCodeEvent) { + if (event.type === "permission.replied") { + this.resolvePending(event.data.requestID, { + behavior: event.data.reply === "reject" ? "deny" : "allow", + }); + } + if (event.type === "form.replied" || event.type === "form.cancelled") { + this.resolvePending(event.data.id, { + behavior: event.type === "form.cancelled" ? "deny" : "allow", + }); + } + } + list() { + return [...this.pending.values()]; + } + async respondToPermission(requestId: string, response: AgentPermissionResponse) { + const form = this.forms.get(requestId); + if (form) { + if (response.behavior === "deny") + await this.client.session.form.cancel({ sessionID: form.sessionID, formID: form.id }); + else { + const raw = response.updatedInput?.answers; + const answer: Record = {}; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) + throw new Error("OpenCode question response requires answers"); + for (const field of form.fields) { + const value: unknown = + Reflect.get(raw, field.key) ?? Reflect.get(raw, field.title ?? field.key); + const normalized = formAnswer(field, value); + if (normalized !== undefined) answer[field.key] = normalized; + } + await this.client.session.form.reply({ + sessionID: form.sessionID, + formID: form.id, + answer, + }); + } + this.forms.delete(requestId); + } else { + await this.client.permission.reply({ + sessionID: this.permissionOwners.get(requestId) ?? this.id, + requestID: requestId, + decision: permissionReply(response), + }); + } + this.resolvePending(requestId, response); + } + async reconcile(sessionID: string) { + const [permissions, forms] = await Promise.all([ + this.client.permission.list({ sessionID }), + this.client.session.form.list({ sessionID }), + ]); + for (const request of permissions) { + if (this.pending.has(request.id)) continue; + const pending: AgentPermissionRequest = { + id: request.id, + provider: "opencode", + kind: "tool", + name: request.action, + description: request.message ?? request.resources.join("\n"), + actions: [ + { id: "once", label: "Allow once", behavior: "allow" }, + { id: "always", label: "Always allow", behavior: "allow" }, + { id: "reject", label: "Deny", behavior: "deny" }, + ], + }; + this.pending.set(request.id, pending); + this.permissionOwners.set(request.id, sessionID); + if (!this.config.toolPolicy && this.config.featureValues?.["auto_accept"] === true) + await this.respondToPermission(request.id, { behavior: "allow" }); + else this.emit({ type: "permission_requested", provider: "opencode", request: pending }); + } + for (const form of forms) { + if (this.pending.has(form.id)) continue; + const pending: AgentPermissionRequest = { + id: form.id, + provider: "opencode", + name: "question", + kind: "question", + title: form.title, + input: { + questions: form.fields.map((field) => ({ + header: field.key, + question: field.title ?? field.key, + options: "options" in field ? field.options : undefined, + multiple: field.type === "multiselect", + })), + }, + }; + this.forms.set(form.id, form); + this.pending.set(form.id, pending); + this.emit({ type: "permission_requested", provider: "opencode", request: pending }); + } + } + resolvePending(requestId: string, resolution: AgentPermissionResponse) { + if (!this.pending.delete(requestId)) return; + this.forms.delete(requestId); + this.permissionOwners.delete(requestId); + this.emit({ type: "permission_resolved", provider: "opencode", requestId, resolution }); + } +} +function permissionReply(response: AgentPermissionResponse): "reject" | "once" | "always" { + if (response.behavior === "deny") return "reject"; + return response.selectedActionId === "always" ? "always" : "once"; +} + +function formAnswer(field: FormInfo["fields"][number], value: unknown): FormValue | undefined { + if (value === undefined) return undefined; + if (field.type === "external") return undefined; + const labelValue = (label: string) => { + const options = "options" in field ? field.options : undefined; + return ( + options?.find((option) => option.value === label)?.value ?? + options?.find((option) => option.label === label)?.value ?? + label + ); + }; + if (field.type === "multiselect") { + if (Array.isArray(value) && value.every((item: unknown) => typeof item === "string")) + return value.map(labelValue); + } else if (field.type === "string" && typeof value === "string") { + return labelValue(value); + } else if (field.type === "boolean") { + if (typeof value === "boolean") return value; + if (value === "true") return true; + if (value === "false") return false; + } else if (field.type === "number" || field.type === "integer") { + const numeric = typeof value === "string" && value.trim() ? Number(value) : value; + if ( + typeof numeric === "number" && + Number.isFinite(numeric) && + (field.type !== "integer" || Number.isInteger(numeric)) + ) + return numeric; + } + throw new Error(`Invalid answer for OpenCode question ${field.key}`); +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/readiness.test.ts b/packages/server/src/server/agent/providers/opencode/v2/readiness.test.ts new file mode 100644 index 0000000000..0305a40c27 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/readiness.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "vitest"; +import { OpenCodeLocationReadyTimeoutError, waitForLocationReady } from "./readiness.js"; + +describe("OpenCode v2 location readiness", () => { + test("fails location readiness when the plugin inventory never populates", async () => { + await expect( + waitForLocationReady({ + client: { plugin: { list: async () => ({ location: { directory: "/tmp" }, data: [] }) } }, + location: { directory: "/tmp" }, + timeoutMs: 25, + }), + ).rejects.toBeInstanceOf(OpenCodeLocationReadyTimeoutError); + }); + + test("bounds location readiness even when the inventory request never settles", async () => { + await expect( + waitForLocationReady({ + client: { plugin: { list: () => new Promise(() => undefined) } }, + location: { directory: "/tmp" }, + timeoutMs: 25, + }), + ).rejects.toBeInstanceOf(OpenCodeLocationReadyTimeoutError); + }); + + test("preserves inventory errors instead of reporting a readiness timeout", async () => { + const error = new Error("inventory unavailable"); + await expect( + waitForLocationReady({ + client: { + plugin: { + list: async () => { + throw error; + }, + }, + }, + location: { directory: "/tmp" }, + }), + ).rejects.toBe(error); + }); +}); diff --git a/packages/server/src/server/agent/providers/opencode/v2/readiness.ts b/packages/server/src/server/agent/providers/opencode/v2/readiness.ts new file mode 100644 index 0000000000..186bbeee1d --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/readiness.ts @@ -0,0 +1,66 @@ +import { setTimeout as delay } from "node:timers/promises"; +import { raceProviderRefreshAbort } from "../../../provider-refresh-deadline.js"; +import type { V2Api } from "./api.js"; + +interface ReadinessInput { + client: Pick; + location: { directory: string }; + signal?: AbortSignal; + timeoutMs?: number; +} + +export class OpenCodeLocationReadyTimeoutError extends Error { + constructor( + readonly directory: string, + readonly waitedMs: number, + ) { + super(`OpenCode location ${directory} did not activate within ${waitedMs}ms`); + this.name = "OpenCodeLocationReadyTimeoutError"; + } +} + +export function waitForLocationReady(input: ReadinessInput): Promise { + return waitForPlugins({ ...input, requireBridge: false }); +} + +export function awaitPaseoPlugin(input: ReadinessInput): Promise { + return waitForPlugins({ ...input, requireBridge: true }); +} + +// A cold location exposes an empty inventory before its config plugins register +// commands and skills. Bound both the inventory request and the polling loop. +interface PluginReadinessInput extends ReadinessInput { + requireBridge: boolean; +} +async function waitForPlugins(input: PluginReadinessInput): Promise { + const { client, location, signal, requireBridge, timeoutMs = 30_000 } = input; + signal?.throwIfAborted(); + const deadline = AbortSignal.timeout(timeoutMs); + const lifetime = signal ? AbortSignal.any([signal, deadline]) : deadline; + try { + while (true) { + const requestTimeout = AbortSignal.timeout(5_000); + const requestSignal = AbortSignal.any([lifetime, requestTimeout]); + try { + const plugins = await raceProviderRefreshAbort( + requestSignal, + client.plugin.list({ location }, { signal: requestSignal }), + ); + if (!requireBridge && plugins.data.length > 0) return; + const bridge = plugins.data.find((plugin) => plugin.id === "paseo"); + if (requireBridge && bridge?.state.status === "active") return; + if (requireBridge && bridge?.state.status === "failed") + throw new Error(`OpenCode Paseo tool bridge plugin failed: ${bridge.state.error}`); + } catch (error) { + lifetime.throwIfAborted(); + if (!requestTimeout.aborted) throw error; + } + await delay(50, undefined, { signal: lifetime }); + } + } catch (error) { + signal?.throwIfAborted(); + if (deadline.aborted) + throw new OpenCodeLocationReadyTimeoutError(location.directory, timeoutMs); + throw error; + } +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/runtime.ts b/packages/server/src/server/agent/providers/opencode/v2/runtime.ts new file mode 100644 index 0000000000..6f659cb44a --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/runtime.ts @@ -0,0 +1,262 @@ +import { decorateOpenCodeV2Env, materializeOpenCodeV2Plugin } from "../bridge.js"; +import { resolvePaseoHome } from "../../../../paseo-home.js"; +import { OpenCode } from "@opencode/client"; +import type { V2Api } from "./api.js"; +import { randomBytes } from "node:crypto"; +import { mkdir } from "node:fs/promises"; +import type { Logger } from "pino"; +import { spawnProcess } from "../../../../../utils/spawn.js"; +import { terminateWithTreeKill } from "../../../../../utils/tree-kill.js"; +import { + createProviderEnvSpec, + resolveProviderLaunch, + type ProviderRuntimeSettings, +} from "../../../provider-launch-config.js"; +import type { ManagedProcessRegistry } from "../../../../managed-processes/managed-processes.js"; +import { resolveOpenCodeHomeDir } from "../paths.js"; +import { OpenCodeHttpError } from "../http-error.js"; +import { raceProviderRefreshAbort } from "../../../provider-refresh-deadline.js"; + +export interface V2Connection { + client: V2Api; + release(): Promise; + retain(): V2Connection; + readonly exited: Promise; +} +interface Generation { + client: V2Api; + users: number; + stop(): Promise; + exited: Promise; +} +export interface V2RuntimeOptions { + logger: Logger; + settings?: ProviderRuntimeSettings; + managedProcesses?: ManagedProcessRegistry; + decorateEnv?: (env: Record) => Promise>; +} + +export interface V2RuntimeAcquireOptions { + fresh?: boolean; + dedicated?: boolean; + env?: Record; + signal?: AbortSignal; +} + +// The runtime owns a credential for each subprocess; sessions only receive its authenticated client. +export class V2Runtime { + private current: Promise | null = null; + private generations = new Set(); + private starts = new Set>(); + private closed = false; + + constructor(private readonly options: V2RuntimeOptions) {} + + async acquire(input: V2RuntimeAcquireOptions = {}): Promise { + if (this.closed) throw new Error("OpenCode runtime is closed"); + input.signal?.throwIfAborted(); + let pending: Promise; + if (input.env || input.dedicated) { + pending = this.startTracked(input.env); + } else { + if (input.fresh || !this.current) this.current = this.startTracked(); + pending = this.current; + } + let generation: Generation; + try { + generation = await raceProviderRefreshAbort(input.signal, pending); + } catch (error) { + if (this.current === pending) this.current = null; + if (input.signal?.aborted) { + void pending + .then(async (started) => { + if (started.users !== 0) return undefined; + this.generations.delete(started); + await started.stop(); + return undefined; + }) + .catch((cleanupError: unknown) => { + this.options.logger.warn( + { error: String(cleanupError) }, + "OpenCode canceled startup cleanup failed", + ); + }); + } + throw error; + } + if (!this.generations.has(generation)) { + if (this.current === pending) this.current = null; + throw new Error("OpenCode helper server exited"); + } + const connection = this.lease(generation, pending); + if (input.signal?.aborted || this.closed) { + await connection.release(); + input.signal?.throwIfAborted(); + throw new Error("OpenCode runtime is closed"); + } + return connection; + } + + private lease(generation: Generation, pending: Promise): V2Connection { + if (this.closed || !this.generations.has(generation)) + throw new Error("OpenCode helper server is no longer running"); + generation.users += 1; + let released = false; + const release = async () => { + if (released) return; + released = true; + generation.users -= 1; + if (generation.users !== 0) return; + if (this.current === pending) this.current = null; + this.generations.delete(generation); + await generation.stop(); + }; + return { + client: generation.client, + release, + retain: () => this.lease(generation, pending), + exited: generation.exited, + }; + } + + async shutdown(): Promise { + this.closed = true; + await Promise.allSettled(this.starts); + await Promise.all([...this.generations].map((generation) => generation.stop())); + this.generations.clear(); + this.current = null; + } + + private startTracked(env?: Record): Promise { + const pending = this.start(env); + this.starts.add(pending); + void pending.then( + () => this.starts.delete(pending), + () => this.starts.delete(pending), + ); + return pending; + } + + private async start(env: Record = {}): Promise { + const { settings, managedProcesses, logger } = this.options; + const launch = await resolveProviderLaunch({ + commandConfig: settings?.command, + defaultBinary: "opencode", + }); + const cwd = resolveOpenCodeHomeDir(); + await mkdir(cwd, { recursive: true }); + const password = randomBytes(32).toString("base64url"); + const inheritedConfig = globalThis.process.env.OPENCODE_CONFIG_CONTENT; + const configured = { + ...(inheritedConfig ? { OPENCODE_CONFIG_CONTENT: inheritedConfig } : {}), + ...settings?.env, + ...env, + }; + const decorated = this.options.decorateEnv + ? await this.options.decorateEnv(configured) + : decorateOpenCodeV2Env(configured, await materializeOpenCodeV2Plugin(resolvePaseoHome())); + const args = [...launch.args, "serve", "--hostname", "127.0.0.1", "--port", "0"]; + const process = spawnProcess(launch.command, args, { + cwd, + detached: globalThis.process.platform !== "win32", + stdio: ["ignore", "pipe", "pipe"], + ...createProviderEnvSpec({ + runtimeSettings: settings, + overlays: [{ ...decorated, OPENCODE_PASSWORD: password }], + }), + }); + const processAbort = new AbortController(); + const exited = new Promise((resolve) => + process.once("exit", (code) => { + const error = new Error(`OpenCode helper server exited (${code})`); + processAbort.abort(error); + resolve(error); + }), + ); + let stopped: Promise | undefined; + const record = + process.pid && managedProcesses + ? managedProcesses + .record({ + owner: { provider: "opencode", kind: "helper-server" }, + pid: process.pid, + command: launch.command, + args, + }) + .catch((error: unknown) => { + logger.warn({ error }, "Could not record OpenCode helper process"); + return null; + }) + : Promise.resolve(null); + const stop = () => { + stopped ??= (async () => { + await terminateWithTreeKill(process, { gracefulTimeoutMs: 5_000, forceTimeoutMs: 1_000 }); + const entry = await record; + if (entry) await managedProcesses?.remove(entry.id); + })(); + return stopped; + }; + const deadline = Date.now() + 30_000; + try { + const url = await new Promise((resolve, reject) => { + const timer = setTimeout( + () => finish(new Error("OpenCode v2 server startup timed out after 30s")), + 30_000, + ); + let buffer = ""; + const finish = (result: string | Error) => { + clearTimeout(timer); + process.stdout?.off("data", onData); + process.off("error", onError); + process.off("exit", onExit); + if (result instanceof Error) reject(result); + else resolve(result); + }; + const onData = (chunk: Buffer) => { + buffer = (buffer + chunk.toString()).slice(-8192); + const match = buffer.match(/server listening on (http:\/\/127\.0\.0\.1:\d+)(?:\r?\n)/); + if (match) finish(match[1]); + }; + const onError = (error: Error) => finish(error); + const onExit = (code: number | null) => + finish(new Error(`OpenCode v2 server exited during startup (${code})`)); + process.stdout?.on("data", onData); + process.once("error", onError); + process.once("exit", onExit); + // Drain stderr without retaining provider output that may contain credentials. + process.stderr?.on("data", () => undefined); + }); + const client = OpenCode.make({ + baseUrl: url, + headers: { + Authorization: `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`, + }, + fetch: async (request, init) => { + const signal = init?.signal + ? AbortSignal.any([init.signal, processAbort.signal]) + : processAbort.signal; + const response = await fetch(request, { ...init, signal }); + const html = response.headers.get("content-type")?.includes("text/html") ?? false; + if (!response.ok || html) { + const requestUrl = request instanceof Request ? request.url : String(request); + throw new OpenCodeHttpError(new URL(requestUrl).pathname, response.status, html); + } + return response; + }, + }); + await client.server.info({ signal: AbortSignal.timeout(Math.max(1, deadline - Date.now())) }); + const generation: Generation = { client, users: 0, stop, exited }; + this.generations.add(generation); + process.once("exit", () => { + this.generations.delete(generation); + void stop().catch((error: unknown) => + logger.warn({ error }, "OpenCode process cleanup failed"), + ); + }); + return generation; + } catch (error) { + await stop(); + throw error; + } + } +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/session.ts b/packages/server/src/server/agent/providers/opencode/v2/session.ts new file mode 100644 index 0000000000..4df28f3875 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/session.ts @@ -0,0 +1,424 @@ +import { SessionChildren } from "./children.js"; +import { SessionTurns } from "./turns.js"; +import { V2Timeline } from "./timeline.js"; +import { waitForLocationReady, awaitPaseoPlugin } from "./readiness.js"; + +import type { SessionInfo, SessionMessageInfo } from "@opencode/client"; + +import { setTimeout as delay } from "node:timers/promises"; +import type { Logger } from "pino"; +import type { + AgentLaunchContext, + AgentMode, + AgentPermissionResponse, + AgentPersistenceHandle, + AgentPromptInput, + AgentRunOptions, + AgentSession, + AgentSessionConfig, + AgentStreamEvent, + SteerActiveTurnOptions, + SteerResult, +} from "../../../agent-sdk-types.js"; + +import { toDiagnosticErrorMessage } from "../../diagnostic-utils.js"; + +import { runProviderTurn } from "../../provider-runner.js"; + +import { composeSystemPromptParts } from "../../../system-prompt.js"; +import { raceProviderRefreshAbort } from "../../../provider-refresh-deadline.js"; + +import { type V2Connection } from "./runtime.js"; +import { modelRef, modesFromV2 } from "./mapping.js"; + +import { V2_CAPABILITIES } from "./capabilities.js"; +import { features } from "./configuration.js"; +import { commands } from "./commands.js"; +import { messages } from "./history.js"; +import { SessionPermissions } from "./permissions.js"; +export class OpenCodeV2Session implements AgentSession { + readonly provider = "opencode"; + readonly capabilities = V2_CAPABILITIES; + private readonly listeners = new Set<(event: AgentStreamEvent) => void>(); + private readonly timeline = new V2Timeline(); + private readonly abort = new AbortController(); + private readonly permissions: SessionPermissions; + private readonly turns: SessionTurns; + private readonly children: SessionChildren; + private stream: Promise | null = null; + private sync: Promise = Promise.resolve(); + private dirty = false; + private refreshTimer: ReturnType | null = null; + private closed = false; + private history: SessionMessageInfo[] = []; + private modes: AgentMode[] = []; + constructor( + private readonly connection: V2Connection, + private info: SessionInfo, + private readonly config: AgentSessionConfig, + private readonly logger: Logger, + private readonly persist: boolean, + private readonly requiresPaseoPlugin: boolean, + private readonly unbind?: () => void, + bindChild?: (id: string) => void, + ) { + this.permissions = new SessionPermissions(connection.client, info.id, config, (event) => + this.emit(event), + ); + this.children = new SessionChildren({ + client: connection.client, + id: info.id, + emit: (event) => this.emit(event), + reconcilePermissions: (id) => this.permissions.reconcile(id), + bindChild, + }); + this.turns = new SessionTurns({ + client: connection.client, + id: info.id, + cwd: config.cwd, + signal: this.abort.signal, + emit: (event) => this.emit(event), + reconcile: async () => { + await this.reconcile(); + return { info: this.info, history: this.history }; + }, + clearPermissions: async () => { + for (const request of this.permissions.list()) + await this.permissions.respondToPermission(request.id, { behavior: "deny" }); + }, + }); + } + get id() { + return this.info.id; + } + get features() { + return features(this.config); + } + private get client() { + return this.connection.client; + } + async initialize(launch?: AgentLaunchContext) { + void this.connection.exited.then((error) => { + if (this.closed) return; + this.abort.abort(error); + this.turns.fail(error); + return undefined; + }); + const location = { directory: this.config.cwd }; + await waitForLocationReady({ client: this.client, location, signal: this.abort.signal }); + if (this.requiresPaseoPlugin) + await awaitPaseoPlugin({ client: this.client, location, signal: this.abort.signal }); + if (launch?.env) + await this.client.session.environment({ sessionID: this.id, variables: launch.env }); + for (const [server, config] of Object.entries(this.config.mcpServers ?? {})) { + await this.client.mcp.add({ + server, + location, + config: + config.type === "stdio" + ? { + type: "local", + command: [config.command, ...(config.args ?? [])], + environment: config.env, + codemode: false, + } + : { + type: "remote", + url: config.url, + headers: config.headers, + oauth: false, + codemode: false, + }, + }); + await this.awaitMcp(server); + } + const system = composeSystemPromptParts( + this.config.systemPrompt, + this.config.daemonAppendSystemPrompt, + ); + if (system) + await this.client.session.instructions.entry.put({ + sessionID: this.id, + key: "paseo", + value: system, + }); + this.modes = modesFromV2((await this.client.agent.list({ location })).data); + this.timeline.messages(await messages(this.client, this.id)); + let ready!: () => void; + let fail!: (error: unknown) => void; + const first = new Promise((resolve, reject) => { + ready = resolve; + fail = reject; + }); + this.stream = this.consume(ready, fail); + await raceProviderRefreshAbort( + AbortSignal.any([this.abort.signal, AbortSignal.timeout(30_000)]), + first, + ); + await this.children.reconcile(this.id); + } + subscribe(callback: (event: AgentStreamEvent) => void) { + this.listeners.add(callback); + return () => { + this.listeners.delete(callback); + }; + } + private async awaitMcp(server: string) { + const signal = AbortSignal.any([this.abort.signal, AbortSignal.timeout(30_000)]); + while (true) { + const catalog = await this.client.mcp.list( + { location: { directory: this.config.cwd } }, + { signal }, + ); + const entry = catalog.data.find((item) => item.name === server); + if (entry?.status.status === "connected") return; + if (entry && entry.status.status !== "pending") { + const reason = entry.status.status === "failed" ? entry.status.error : entry.status.status; + throw new Error(`OpenCode MCP server ${server} failed to connect: ${reason}`); + } + await delay(50, undefined, { signal }); + } + } + private emit(event: AgentStreamEvent) { + for (const listener of this.listeners) listener(event); + } + private emitTimeline(event: AgentStreamEvent) { + this.emit({ ...event, ...(this.turns.id ? { turnId: this.turns.id } : {}) }); + } + async *streamHistory() { + const history = new V2Timeline(false); + yield* history.messages(await messages(this.client, this.id)); + } + async getRuntimeInfo() { + this.info = await this.client.session.get({ sessionID: this.id }); + return { + provider: "opencode", + sessionId: this.id, + model: this.info.model ? `${this.info.model.providerID}/${this.info.model.id}` : null, + modeId: this.info.agent ?? null, + thinkingOptionId: this.info.model?.variant ?? null, + }; + } + async getAvailableModes() { + return this.modes; + } + async getCurrentMode() { + return this.info.agent ?? null; + } + async setMode(modeId: string) { + await this.client.session.switchAgent({ sessionID: this.id, agent: modeId }); + this.info.agent = modeId; + this.config.modeId = modeId; + this.emit({ + type: "mode_changed", + provider: "opencode", + currentModeId: modeId, + availableModes: this.modes, + }); + } + async setModel(model: string | null) { + const selected = model + ? modelRef(model, this.config.thinkingOptionId) + : (await this.client.model.default({ location: { directory: this.config.cwd } })).data; + if (!selected) throw new Error("OpenCode has no default model"); + await this.client.session.switchModel({ + sessionID: this.id, + model: { + id: selected.id, + providerID: selected.providerID, + variant: this.config.thinkingOptionId, + }, + }); + this.config.model = model ?? undefined; + this.emit({ + type: "model_changed", + provider: "opencode", + runtimeInfo: await this.getRuntimeInfo(), + }); + } + async setThinkingOption(variant: string | null) { + const model = this.info.model; + if (!model) throw new Error("Select an OpenCode model before changing its variant"); + await this.client.session.switchModel({ + sessionID: this.id, + model: { id: model.id, providerID: model.providerID, ...(variant ? { variant } : {}) }, + }); + this.config.thinkingOptionId = variant ?? undefined; + this.info = await this.client.session.get({ sessionID: this.id }); + this.emit({ type: "thinking_option_changed", provider: "opencode", thinkingOptionId: variant }); + } + async setFeature(id: string, value: unknown) { + if (id !== "auto_accept" || typeof value !== "boolean") + throw new Error("Unknown OpenCode feature"); + this.config.featureValues = { ...this.config.featureValues, [id]: value }; + if (value && !this.config.toolPolicy) + for (const request of this.permissions.list()) + if (request.kind !== "question") + await this.respondToPermission(request.id, { behavior: "allow" }); + } + async listCommands() { + return commands(this.client, this.config.cwd); + } + describePersistence(): AgentPersistenceHandle { + return { + provider: "opencode", + sessionId: this.id, + nativeHandle: this.id, + metadata: { ...this.config }, + }; + } + run(prompt: AgentPromptInput, options?: AgentRunOptions) { + return runProviderTurn({ + prompt, + runOptions: options, + startTurn: this.startTurn.bind(this), + subscribe: this.subscribe.bind(this), + getSessionId: () => this.id, + reduceFinalText: ({ current, item }) => + item.type === "assistant_message" ? current + item.text : current, + }); + } + startTurn(prompt: AgentPromptInput, options?: AgentRunOptions) { + return this.turns.startTurn(prompt, options); + } + steerActiveTurn(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise { + return this.turns.steerActiveTurn(prompt, options); + } + interrupt() { + return this.turns.interrupt(); + } + async revertBoth(input: { messageId: string }) { + await this.interrupt(); + await this.client.session.revert.stage({ + sessionID: this.id, + messageID: input.messageId, + files: true, + }); + await this.client.session.revert.commit({ sessionID: this.id }); + } + getPendingPermissions() { + return [...this.permissions.list()]; + } + async respondToPermission(requestId: string, response: AgentPermissionResponse) { + await this.permissions.respondToPermission(requestId, response); + } + private async reconcileSnapshot() { + const [info, history] = await Promise.all([ + this.client.session.get({ sessionID: this.id }), + messages(this.client, this.id), + ]); + this.info = info; + this.history = history; + for (const event of this.timeline.messages(history)) this.emitTimeline(event); + await this.permissions.reconcile(this.id); + } + private async reconcile() { + this.dirty = true; + this.sync = this.sync.catch(() => undefined).then(() => this.drainReconciliation()); + return this.sync; + } + private async drainReconciliation() { + while (this.dirty && !this.closed) { + this.dirty = false; + await this.reconcileSnapshot(); + } + } + private async reconcileConnection() { + await this.reconcile(); + await this.children.reconcile(this.id); + const active = await this.client.session.active(); + if (active[this.id]) this.turns.observeActiveTurn(); + } + private scheduleReconcile() { + if (this.refreshTimer || this.closed) return; + this.refreshTimer = setTimeout(() => { + this.refreshTimer = null; + void this.reconcile().catch((error: unknown) => + this.logger.warn( + { error: toDiagnosticErrorMessage(error) }, + "OpenCode reconciliation failed", + ), + ); + }, 25); + } + private observeOwnEvent(event: import("@opencode/client").OpenCodeEvent) { + if (event.type === "form.created" && event.data.form.sessionID === this.id) { + this.scheduleReconcile(); + return; + } + if (!("sessionID" in event.data) || event.data.sessionID !== this.id) return; + if (event.type === "session.text.started" || event.type === "session.reasoning.started") { + if (event.type === "session.text.started" && this.turns.hasStructuredOutput) return; + this.timeline.startPart({ + ...event.data, + type: event.type === "session.text.started" ? "text" : "reasoning", + }); + return; + } + if (event.type === "session.text.delta" || event.type === "session.reasoning.delta") { + const isText = event.type === "session.text.delta"; + // A structured-output turn supplies its answer through a tool, so streamed + // prose is not part of the result and must not reach the timeline. + if (isText && this.turns.hasStructuredOutput) return; + const streamed = this.timeline.delta({ + assistantMessageID: event.data.assistantMessageID, + ordinal: event.data.ordinal, + delta: event.data.delta, + type: isText ? "text" : "reasoning", + }); + if (streamed) this.emitTimeline(streamed); + return; + } + this.permissions.observe(event); + if (event.type === "session.execution.failed") + this.turns.executionError = event.data.error.message; + this.scheduleReconcile(); + if (event.type !== "session.execution.started" || this.turns.id) return; + this.turns.observeActiveTurn(); + } + private async consume(ready: () => void, fail: (error: unknown) => void) { + let connected = false; + while (!this.closed && !this.abort.signal.aborted) { + try { + for await (const event of this.client.event.subscribe({ signal: this.abort.signal })) { + if (this.closed || this.abort.signal.aborted) return; + if (event.type === "server.connected") { + this.timeline.resetStreams(); + await this.reconcileConnection(); + connected = true; + ready(); + } + await this.children.observe(event); + this.observeOwnEvent(event); + } + if (!connected) throw new Error("OpenCode event stream ended before connecting"); + } catch (error) { + if (this.closed || this.abort.signal.aborted) return; + if (!connected) { + fail(error); + return; + } + this.logger.warn( + { error: toDiagnosticErrorMessage(error) }, + "OpenCode event stream interrupted; reconciling on reconnect", + ); + } + await delay(100, undefined, { signal: this.abort.signal }).catch(() => undefined); + } + } + async close() { + if (this.closed) return; + this.closed = true; + this.abort.abort(); + if (this.refreshTimer) clearTimeout(this.refreshTimer); + await this.stream; + await this.sync.catch(() => undefined); + this.unbind?.(); + try { + if (!this.persist) await this.client.session.remove({ sessionID: this.id }); + } finally { + await this.connection.release(); + } + this.listeners.clear(); + } +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/streaming.test.ts b/packages/server/src/server/agent/providers/opencode/v2/streaming.test.ts new file mode 100644 index 0000000000..7f4524b136 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/streaming.test.ts @@ -0,0 +1,208 @@ +import { V2Timeline } from "./timeline.js"; +import type { SessionMessageAssistant } from "@opencode/client"; +import { describe, expect, test } from "vitest"; +import { OpenCodeV2AgentClient } from "./agent.js"; +import { V2Harness } from "../test-utils/v2-harness.js"; +import { createTestLogger } from "../../../../../test-utils/test-logger.js"; +import type { AgentStreamEvent } from "../../../agent-sdk-types.js"; + +function collectAssistantText(session: { + subscribe: (cb: (e: AgentStreamEvent) => void) => () => void; +}) { + const chunks: string[] = []; + session.subscribe((event) => { + if (event.type === "timeline" && event.item.type === "assistant_message") + chunks.push(event.item.text); + }); + return chunks; +} + +function assistant(content: SessionMessageAssistant["content"]): SessionMessageAssistant { + return { + id: "answer", + type: "assistant", + agent: "build", + model: { providerID: "test", id: "model" }, + time: { created: 2 }, + content, + }; +} + +describe("OpenCode v2 token streaming", () => { + test("emits text and reasoning deltas as they arrive", async () => { + const harness = new V2Harness(); + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + const text = collectAssistantText(session); + const reasoning: string[] = []; + session.subscribe((event) => { + if (event.type === "timeline" && event.item.type === "reasoning") + reasoning.push(event.item.text); + }); + try { + harness.push({ + id: "rs", + created: 2, + type: "session.reasoning.started", + data: { sessionID: "session", assistantMessageID: "answer", ordinal: 0 }, + }); + harness.push({ + id: "r1", + created: 3, + type: "session.reasoning.delta", + data: { sessionID: "session", assistantMessageID: "answer", ordinal: 0, delta: "think" }, + }); + harness.push({ + id: "ts", + created: 2, + type: "session.text.started", + data: { sessionID: "session", assistantMessageID: "answer", ordinal: 0 }, + }); + harness.push({ + id: "t1", + created: 4, + type: "session.text.delta", + data: { sessionID: "session", assistantMessageID: "answer", ordinal: 0, delta: "Hel" }, + }); + harness.push({ + id: "t2", + created: 5, + type: "session.text.delta", + data: { sessionID: "session", assistantMessageID: "answer", ordinal: 0, delta: "lo" }, + }); + await expect.poll(() => text).toEqual(["Hel", "lo"]); + expect(reasoning).toEqual(["think"]); + } finally { + await session.close(); + } + }); + + test("deduplicates snapshots before and after deltas using per-type ordinals", () => { + const timeline = new V2Timeline(); + const text = { assistantMessageID: "answer", type: "text", ordinal: 0 } as const; + const reasoning = { ...text, type: "reasoning" } as const; + timeline.startPart(text); + timeline.startPart(reasoning); + expect(timeline.delta({ ...text, delta: "Hel" })).toMatchObject({ item: { text: "Hel" } }); + expect(timeline.delta({ ...reasoning, delta: "think" })).toMatchObject({ + item: { text: "think" }, + }); + const snapshot = assistant([ + { + type: "reasoning", + text: "think", + state: { reasoningField: "reasoning_content" }, + time: { created: 2, completed: 2 }, + }, + { type: "text", text: "Hello" }, + ]); + expect(timeline.messages([snapshot])).toMatchObject([{ item: { text: "lo" } }]); + expect(timeline.delta({ ...text, delta: "lo" })).toBeNull(); + expect(timeline.messages([snapshot])).toEqual([]); + expect(timeline.delta({ ...text, delta: "!" })).toMatchObject({ item: { text: "!" } }); + expect(timeline.messages([snapshot])).toEqual([]); + }); + + test("recovers missed fragments from snapshots after reconnect", () => { + const timeline = new V2Timeline(); + const part = { assistantMessageID: "answer", type: "text", ordinal: 0 } as const; + timeline.startPart(part); + expect(timeline.delta({ ...part, delta: "Hel" })).toMatchObject({ item: { text: "Hel" } }); + timeline.resetStreams(); + expect(timeline.delta({ ...part, delta: "!" })).toBeNull(); + expect(timeline.messages([assistant([{ type: "text", text: "Hello!" }])])).toMatchObject([ + { item: { text: "lo!" } }, + ]); + expect(timeline.messages([assistant([{ type: "text", text: "Hello!" }])])).toEqual([]); + }); + + test("withholds streamed prose during a structured-output turn", async () => { + const harness = new V2Harness(); + let settle!: () => void; + harness.wait = () => + new Promise((resolve) => { + settle = resolve; + }); + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + const text = collectAssistantText(session); + const reasoning: string[] = []; + session.subscribe((event) => { + if (event.type === "timeline" && event.item.type === "reasoning") + reasoning.push(event.item.text); + }); + const failures: AgentStreamEvent[] = []; + session.subscribe((event) => { + if (event.type === "turn_failed") failures.push(event); + }); + try { + await session.startTurn("answer", { + outputSchema: { type: "object", properties: { answer: { type: "integer" } } }, + }); + await expect.poll(() => harness.prompts).toEqual(["answer"]); + harness.push({ + id: "ts", + created: 2, + type: "session.text.started", + data: { sessionID: "session", assistantMessageID: "answer", ordinal: 0 }, + }); + harness.push({ + id: "t1", + created: 3, + type: "session.text.delta", + data: { + sessionID: "session", + assistantMessageID: "answer", + ordinal: 0, + delta: "ignore me", + }, + }); + // A following reasoning delta is still delivered, so its arrival proves + // the text delta was seen and suppressed rather than merely not yet read. + harness.push({ + id: "rs", + created: 2, + type: "session.reasoning.started", + data: { sessionID: "session", assistantMessageID: "answer", ordinal: 0 }, + }); + harness.push({ + id: "r1", + created: 4, + type: "session.reasoning.delta", + data: { sessionID: "session", assistantMessageID: "answer", ordinal: 0, delta: "thought" }, + }); + await expect.poll(() => reasoning).toEqual(["thought"]); + expect(text).toEqual([]); + // A late delta after the structured turn failed must remain suppressed. + settle(); + await expect.poll(() => failures.length).toBe(1); + harness.push({ + id: "late", + created: 5, + type: "session.text.delta", + data: { + sessionID: "session", + assistantMessageID: "answer", + ordinal: 0, + delta: "still hidden", + }, + }); + harness.push({ + id: "r2", + created: 6, + type: "session.reasoning.delta", + data: { sessionID: "session", assistantMessageID: "answer", ordinal: 0, delta: "done" }, + }); + await expect.poll(() => reasoning).toEqual(["thought", "done"]); + expect(text).toEqual([]); + } finally { + await session.close(); + } + }); +}); diff --git a/packages/server/src/server/agent/providers/opencode/v2/structured-output-plugin.mjs b/packages/server/src/server/agent/providers/opencode/v2/structured-output-plugin.mjs new file mode 100644 index 0000000000..adb306e558 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/structured-output-plugin.mjs @@ -0,0 +1,60 @@ +import Ajv from "ajv"; + +export const STRUCTURED_OUTPUT_TOOL = "paseo_structured_output"; + +export async function registerStructuredOutput(context) { + const ajv = new Ajv({ strict: false, allErrors: true }); + async function schemaFor(sessionID) { + const history = await context.session.context({ sessionID }); + const prompt = history.findLast((message) => message.type === "user"); + return prompt?.metadata?.paseoOutputSchema; + } + const tools = await context.tool.transform((editor) => { + editor.add({ + name: STRUCTURED_OUTPUT_TOOL, + description: "Submit the final answer in the required structured format.", + input: { + type: "object", + properties: { value: {} }, + required: ["value"], + additionalProperties: false, + }, + options: { codemode: false }, + async execute(input, call) { + const schema = await schemaFor(call.sessionID); + if (schema === undefined) throw new Error("This turn has no structured-output request"); + const validate = ajv.compile(schema); + if (!validate(input.value)) + throw new Error(`Invalid structured output: ${ajv.errorsText(validate.errors)}`); + return { + content: + "Final structured answer accepted. End this turn without additional text or tool calls.", + metadata: { paseoStructuredOutput: input.value }, + }; + }, + }); + }); + const hook = await context.session.hook("context", async (input) => { + const schema = await schemaFor(input.sessionID); + if (schema === undefined) { + delete input.tools[STRUCTURED_OUTPUT_TOOL]; + return; + } + const tool = input.tools[STRUCTURED_OUTPUT_TOOL]; + if (!tool) throw new Error("Structured-output tool is unavailable"); + tool.input = { + type: "object", + properties: { value: schema }, + required: ["value"], + additionalProperties: false, + }; + input.system.push({ + type: "text", + text: `Complete the user's task, then submit the final answer using ${STRUCTURED_OUTPUT_TOOL}. Its value must satisfy the tool schema. Do not finish with an ordinary text answer. After the tool accepts the value, end the turn without additional text or tool calls.`, + }); + }); + return async () => { + await hook.dispose(); + await tools.dispose(); + }; +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/structured-output.ts b/packages/server/src/server/agent/providers/opencode/v2/structured-output.ts new file mode 100644 index 0000000000..dd9472acf2 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/structured-output.ts @@ -0,0 +1,36 @@ +import { Ajv } from "ajv"; +import { z } from "zod"; +import type { SessionMessageInfo } from "@opencode/client"; + +export const STRUCTURED_OUTPUT_TOOL = "paseo_structured_output"; +const schemas = new Ajv({ strict: false, allErrors: true }); + +export function structuredOutput(input: unknown) { + const schema = z.union([z.boolean(), z.record(z.string(), z.json())]).parse(input); + const validate = schemas.compile(schema); + return { + schema, + assert(history: SessionMessageInfo[]) { + const start = history.findLastIndex((message) => message.type === "user"); + for (const message of history.slice(start + 1)) { + if (message.type !== "assistant") continue; + for (const part of message.content) { + if ( + part.type !== "tool" || + part.name !== STRUCTURED_OUTPUT_TOOL || + part.state.status !== "completed" + ) + continue; + const result = part.state.metadata?.paseoStructuredOutput; + if (result === undefined) continue; + if (!validate(result)) + throw new Error( + `Invalid OpenCode structured output: ${schemas.errorsText(validate.errors)}`, + ); + return; + } + } + throw new Error("OpenCode finished without submitting the required structured output"); + }, + }; +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/timeline.ts b/packages/server/src/server/agent/providers/opencode/v2/timeline.ts new file mode 100644 index 0000000000..3bc0a5bf76 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/timeline.ts @@ -0,0 +1,205 @@ +import { mapOpencodeToolCall } from "../tool-call-mapper.js"; +import type { SessionMessageAssistantTool } from "@opencode/client"; +import { STRUCTURED_OUTPUT_TOOL } from "./structured-output.js"; +import type { SessionMessageInfo } from "@opencode/client"; +import type { AgentStreamEvent, AgentTimelineItem } from "../../../agent-sdk-types.js"; + +function textPartKey(messageID: string, part: "text" | "reasoning", ordinal: number): string { + return `${messageID}:${part}:${ordinal}`; +} + +// Text and reasoning each number parts from zero, independent of snapshot position. +export class V2Timeline { + private readonly content = new Map(); + private readonly streams = new Map(); + + resetStreams() { + this.streams.clear(); + } + + startPart(part: TextPartIdentity) { + this.streams.set(textPartKey(part.assistantMessageID, part.type, part.ordinal), ""); + } + + constructor(private readonly includeClientMessageId = true) {} + + delta(event: TextPartDelta): AgentStreamEvent | null { + const key = textPartKey(event.assistantMessageID, event.type, event.ordinal); + const streamed = this.streams.get(key); + // Deltas are ephemeral and have no offset. After reconnect, only stream + // parts whose start we observed; snapshots recover any interrupted part. + if (streamed === undefined || event.delta.length === 0) return null; + const text = streamed + event.delta; + this.streams.set(key, text); + const emitted = this.content.get(key) ?? ""; + if (text.length <= emitted.length) return null; + if (!text.startsWith(emitted)) + throw new Error("OpenCode changed previously emitted message content"); + this.content.set(key, text); + const suffix = text.slice(emitted.length); + const item: AgentTimelineItem = + event.type === "text" + ? { type: "assistant_message", text: suffix, messageId: event.assistantMessageID } + : { type: "reasoning", text: suffix }; + return { type: "timeline", provider: "opencode", item, timestamp: new Date().toISOString() }; + } + + messages(messages: SessionMessageInfo[]): AgentStreamEvent[] { + const events: AgentStreamEvent[] = []; + const state: TimelineState = { structured: false, accepted: false }; + for (const message of messages) { + const timestamp = new Date(message.time.created).toISOString(); + const push = (item: AgentTimelineItem) => + events.push({ type: "timeline", provider: "opencode", item, timestamp }); + if (message.type === "user") this.userMessage(message, state, push); + else if (message.type === "assistant") this.assistantMessage(message, state, push); + else if (message.type === "compaction") this.compactionMessage(message, push); + } + return events; + } + + private userMessage( + message: Extract, + state: TimelineState, + push: (item: AgentTimelineItem) => void, + ) { + state.structured = message.metadata?.paseoOutputSchema !== undefined; + state.accepted = false; + if (this.content.has(message.id)) return; + this.content.set(message.id, message.text); + const clientMessageId = message.metadata?.paseoClientMessageId; + push({ + type: "user_message", + text: message.text, + messageId: message.id, + ...(this.includeClientMessageId && typeof clientMessageId === "string" + ? { clientMessageId } + : {}), + }); + } + + private assistantMessage( + message: Extract, + state: TimelineState, + push: (item: AgentTimelineItem) => void, + ) { + // Snapshot parts are indexed by position in `message.content`, but deltas + // number text and reasoning separately. Track a per-type ordinal so both + // paths address the same cursor entry. + let textOrdinal = 0; + let reasoningOrdinal = 0; + message.content.forEach((part, partIndex) => { + if (part.type === "tool" && part.name === STRUCTURED_OUTPUT_TOOL) { + this.structuredOutputPart(message.id, partIndex, part, state, push); + } else if (part.type === "text" || part.type === "reasoning") { + const ordinal = part.type === "text" ? textOrdinal++ : reasoningOrdinal++; + this.textPart(message.id, part, ordinal, state, push); + } else { + this.toolPart(message.id, partIndex, part, push); + } + }); + } + + private structuredOutputPart( + messageID: string, + partIndex: number, + part: Extract< + Extract["content"][number], + { type: "tool" } + >, + state: TimelineState, + push: (item: AgentTimelineItem) => void, + ) { + if (!state.structured || state.accepted || part.state.status !== "completed") return; + const value = part.state.metadata?.paseoStructuredOutput; + if (value === undefined) return; + state.accepted = true; + const key = `${messageID}:${partIndex}`; + const text = JSON.stringify(value); + if (this.content.get(key) === text) return; + this.content.set(key, text); + push({ type: "assistant_message", text, messageId: messageID }); + } + + private textPart( + messageID: string, + part: Extract< + Extract["content"][number], + { type: "text" } | { type: "reasoning" } + >, + ordinal: number, + state: TimelineState, + push: (item: AgentTimelineItem) => void, + ) { + if (state.structured && part.type === "text") return; + const key = textPartKey(messageID, part.type, ordinal); + const previous = this.content.get(key) ?? ""; + // Upstream snapshots may briefly lag the volatile delta stream. + if (part.text.length <= previous.length) return; + if (!part.text.startsWith(previous)) + throw new Error("OpenCode changed previously emitted message content"); + this.content.set(key, part.text); + const suffix = part.text.slice(previous.length); + if (part.type === "text") + push({ type: "assistant_message", text: suffix, messageId: messageID }); + else push({ type: "reasoning", text: suffix }); + } + + private toolPart( + messageID: string, + partIndex: number, + part: Extract< + Extract["content"][number], + { type: "tool" } + >, + push: (item: AgentTimelineItem) => void, + ) { + const key = `${messageID}:${partIndex}`; + const serialized = JSON.stringify(part); + if (serialized === this.content.get(key)) return; + this.content.set(key, serialized); + const item = toolFromV2(part); + if (item) push(item); + } + + private compactionMessage( + message: Extract, + push: (item: AgentTimelineItem) => void, + ) { + const status = message.status === "running" ? "loading" : "completed"; + if (this.content.get(message.id) === status) return; + this.content.set(message.id, status); + push({ type: "compaction", status }); + } +} + +interface TimelineState { + structured: boolean; + accepted: boolean; +} + +interface TextPartIdentity { + assistantMessageID: string; + type: "text" | "reasoning"; + ordinal: number; +} + +interface TextPartDelta extends TextPartIdentity { + delta: string; +} +function toolFromV2(tool: SessionMessageAssistantTool): AgentTimelineItem | null { + const state = tool.state; + const output = + "content" in state + ? state.content?.map((part) => (part.type === "text" ? part.text : part.uri)).join("\n") + : undefined; + return mapOpencodeToolCall({ + toolName: tool.name, + callId: tool.id, + input: state.input, + status: state.status === "error" ? "failed" : state.status, + output, + error: state.status === "error" ? state.error : undefined, + metadata: "metadata" in state ? state.metadata : undefined, + }); +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/turns.ts b/packages/server/src/server/agent/providers/opencode/v2/turns.ts new file mode 100644 index 0000000000..cc5872e10d --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/turns.ts @@ -0,0 +1,261 @@ +import { structuredOutput } from "./structured-output.js"; +import type { SessionInfo, SessionMessageInfo } from "@opencode/client"; + +import { randomUUID } from "node:crypto"; + +import type { + AgentPromptInput, + AgentRunOptions, + AgentStreamEvent, + SteerActiveTurnOptions, + SteerResult, +} from "../../../agent-sdk-types.js"; + +import { toDiagnosticErrorMessage } from "../../diagnostic-utils.js"; + +import { renderPromptAttachmentAsText } from "../../../prompt-attachments.js"; + +import { usageFromV2 } from "./mapping.js"; + +import { commands } from "./commands.js"; + +import type { V2Api } from "./api.js"; +interface TurnSnapshot { + info: SessionInfo; + history: SessionMessageInfo[]; +} +interface TurnOptions { + client: V2Api; + id: string; + cwd: string; + signal: AbortSignal; + emit(event: AgentStreamEvent): void; + reconcile(): Promise; + clearPermissions(): Promise; +} +interface Turn { + output?: ReturnType; + id: string; + submitted: Promise; + completion: Promise; +} + +export class SessionTurns { + private turn: Turn | null = null; + private stopping: Promise | null = null; + private stopFailed = false; + executionError: string | null = null; + constructor(private readonly options: TurnOptions) {} + get id() { + return this.turn?.id; + } + get hasStructuredOutput() { + return Boolean(this.turn?.output); + } + fail(error: Error) { + const turn = this.turn; + this.turn = null; + if (turn) + this.options.emit({ + type: "turn_failed", + provider: "opencode", + turnId: turn.id, + error: error.message, + }); + } + async startTurn(prompt: AgentPromptInput, options?: AgentRunOptions) { + await this.stopping; + if (this.options.signal.aborted) throw new Error("OpenCode session is closed"); + if (this.turn) throw new Error("OpenCode session already has an active turn"); + this.executionError = null; + const id = randomUUID(); + const input = this.promptInput(prompt); + let accept!: () => void; + const submitted = new Promise((resolve) => { + accept = resolve; + }); + const output = + options?.outputSchema === undefined ? undefined : structuredOutput(options.outputSchema); + const completion = this.submit(id, input, accept, options, output); + this.turn = { id, submitted, completion, output }; + return { turnId: id }; + } + private async submit( + id: string, + input: ReturnType, + accept: () => void, + options?: AgentRunOptions, + output?: ReturnType, + ) { + // Defer until startTurn publishes ownership, including for immediately resolved test transports. + await Promise.resolve(); + this.options.emit({ type: "turn_started", provider: "opencode", turnId: id }); + try { + await this.dispatch(input, options, output); + accept(); + await this.finish(id); + } catch (error) { + if (this.turn?.id === id) { + this.turn = null; + this.options.emit({ + type: "turn_failed", + provider: "opencode", + turnId: id, + error: toDiagnosticErrorMessage(error), + }); + } + } finally { + accept(); + } + } + private async dispatch( + input: ReturnType, + options?: AgentRunOptions, + output?: ReturnType, + ) { + const command = input.text.match(/^\/([^\s]+)(?:\s+([\s\S]*))?$/); + if (command?.[1] === "compact" || command?.[1] === "summarize") { + await this.options.client.session.compact({ sessionID: this.options.id }); + return; + } + const selected = command + ? (await commands(this.options.client, this.options.cwd)).find( + (item) => item.name === command[1], + ) + : undefined; + if (selected && selected.kind !== "skill") { + await this.options.client.session.command({ + sessionID: this.options.id, + name: selected.name, + text: command?.[2] ?? "", + files: input.files, + }); + return; + } + await this.options.client.session.prompt({ + sessionID: this.options.id, + ...input, + ...(selected?.kind === "skill" + ? { + skills: [{ id: selected.name }], + text: command?.[2] ?? `Use the ${selected.name} skill.`, + } + : {}), + metadata: { + ...(options?.clientMessageId ? { paseoClientMessageId: options.clientMessageId } : {}), + ...(output ? { paseoOutputSchema: output.schema } : {}), + }, + }); + } + private async finish(id: string) { + await this.options.client.session.wait( + { sessionID: this.options.id }, + { signal: this.options.signal }, + ); + const { info, history } = await this.options.reconcile(); + if (this.turn?.id !== id) return; + if (info.outcome !== "failed" && info.outcome !== "interrupted") + this.turn.output?.assert(history); + const failure = + info.outcome === "failed" ? (this.executionError ?? (await this.readExecutionError())) : null; + if (this.turn?.id !== id) return; + this.turn = null; + if (info.outcome === "interrupted") + this.options.emit({ + type: "turn_canceled", + provider: "opencode", + turnId: id, + reason: "OpenCode interrupted execution", + }); + else if (info.outcome === "failed") + this.options.emit({ + type: "turn_failed", + provider: "opencode", + turnId: id, + error: failure ?? "OpenCode execution failed", + }); + else + this.options.emit({ + type: "turn_completed", + provider: "opencode", + turnId: id, + usage: usageFromV2(info), + }); + } + private async readExecutionError(): Promise { + let message = "OpenCode execution failed"; + for await (const event of this.options.client.session.log( + { sessionID: this.options.id, follow: false }, + { signal: this.options.signal }, + )) { + if (event.type === "session.execution.failed") message = event.data.error.message; + } + return message; + } + private promptInput(prompt: AgentPromptInput) { + if (typeof prompt === "string") return { text: prompt, files: [] }; + const text: string[] = []; + const files: Array<{ uri: string }> = []; + for (const part of prompt) { + if (part.type === "text") text.push(part.text); + else if (part.type === "image") + files.push({ uri: `data:${part.mimeType};base64,${part.data}` }); + else text.push(renderPromptAttachmentAsText(part)); + } + return { text: text.join("\n"), files }; + } + async steerActiveTurn( + prompt: AgentPromptInput, + options: SteerActiveTurnOptions, + ): Promise { + if (this.turn?.id !== options.expectedTurnId || this.stopping) return { status: "unavailable" }; + await this.options.client.session.prompt({ + sessionID: this.options.id, + ...this.promptInput(prompt), + delivery: "steer", + metadata: options.clientMessageId + ? { paseoClientMessageId: options.clientMessageId } + : undefined, + }); + if (options.clearPendingPermissions) await this.options.clearPermissions(); + return { status: "accepted" }; + } + async interrupt() { + if (!this.stopping || this.stopFailed) { + this.stopFailed = false; + const turn = this.turn; + const stop = (async () => { + // A stop sent before the queued prompt is accepted would interrupt an idle session. + await turn?.submitted; + await this.options.client.session.interrupt({ sessionID: this.options.id }); + await turn?.completion; + })(); + this.stopping = stop; + try { + await stop; + this.stopping = null; + } catch (error) { + this.stopFailed = true; + throw error; + } + } else await this.stopping; + } + observeActiveTurn() { + if (this.turn) return; + const id = randomUUID(); + const completion = Promise.resolve() + .then(() => this.finish(id)) + .catch((error: unknown) => { + if (this.turn?.id !== id) return; + this.turn = null; + this.options.emit({ + type: "turn_failed", + provider: "opencode", + turnId: id, + error: toDiagnosticErrorMessage(error), + }); + }); + this.turn = { id, submitted: Promise.resolve(), completion }; + this.options.emit({ type: "turn_started", provider: "opencode", turnId: id }); + } +}