mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74b56cbfe8 |
+3
-7
@@ -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/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.
|
||||
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.
|
||||
|
||||
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,13 +95,9 @@ 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 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-UXnB6q5tubKpTs+A5+u/NLSzc8ZK6rAsQs+kEphEKd8=
|
||||
sha256-9UWtpZrCdyYyGq3HGNgSpU1+2Imu3oYqtSumq2DtANc=
|
||||
|
||||
Generated
+24
-239
@@ -42,13 +42,6 @@
|
||||
"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",
|
||||
@@ -8278,84 +8271,6 @@
|
||||
"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",
|
||||
@@ -8729,48 +8644,6 @@
|
||||
"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",
|
||||
@@ -14390,6 +14263,13 @@
|
||||
"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",
|
||||
@@ -18742,16 +18622,6 @@
|
||||
"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",
|
||||
@@ -21996,28 +21866,6 @@
|
||||
"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",
|
||||
@@ -28293,37 +28141,6 @@
|
||||
"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",
|
||||
@@ -28649,21 +28466,6 @@
|
||||
"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",
|
||||
@@ -30339,22 +30141,6 @@
|
||||
"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",
|
||||
@@ -33341,6 +33127,23 @@
|
||||
"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",
|
||||
@@ -33515,23 +33318,6 @@
|
||||
"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",
|
||||
@@ -39209,7 +38995,6 @@
|
||||
"@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",
|
||||
|
||||
@@ -6,16 +6,13 @@ import {
|
||||
expectAgentProfilesEmptyPrompt,
|
||||
expectProfileEditTooltip,
|
||||
expectComposerMode,
|
||||
expectCreateProfileFromModelRow,
|
||||
expectComposerModel,
|
||||
expectModelRowProfileActionBesideRow,
|
||||
expectModelRowSelected,
|
||||
expectProfileEditIsPencilOnly,
|
||||
expectProfileVisibleForProvider,
|
||||
openModelPicker,
|
||||
openAgentProfilesFromEmptyPrompt,
|
||||
seedAgentProfiles,
|
||||
selectModelRow,
|
||||
} from "../support/helpers/agent-profiles";
|
||||
import { expectWorkspaceAgentConfiguration } from "../support/helpers/command-center-agent-controls";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
@@ -118,34 +115,4 @@ test.describe("Agent profiles in the model picker", () => {
|
||||
await seed.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("a model row and its create-profile action are separate buttons", async ({ page }) => {
|
||||
const seed = await seedAgentProfiles([]);
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "agent-profiles-row-action-",
|
||||
title: "Agent profiles row action",
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
|
||||
try {
|
||||
const oneMinute = {
|
||||
provider: "mock",
|
||||
modelId: "one-minute-stream",
|
||||
modelLabel: "One minute stream",
|
||||
};
|
||||
await openAgentRoute(page, workspace);
|
||||
await expectComposerVisible(page);
|
||||
await openModelPicker(page);
|
||||
await expectModelRowProfileActionBesideRow(page, oneMinute);
|
||||
await expectCreateProfileFromModelRow(page, oneMinute);
|
||||
await expectComposerModel(page, "Ten second stream");
|
||||
await openModelPicker(page);
|
||||
await selectModelRow(page, oneMinute);
|
||||
await expectComposerModel(page, oneMinute.modelLabel);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
await seed.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import {
|
||||
@@ -28,39 +25,3 @@ test("settings restart waits for a replacement worker while retaining the superv
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("settings restart replaces the worker when a provider CLI answers --version slowly", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.skip(process.platform === "win32", "The slow provider stub is a POSIX script");
|
||||
const daemon = await startIsolatedHostDaemon(randomUUID(), {
|
||||
paseoHome: await createHomeWithSlowProvider(1_800),
|
||||
});
|
||||
try {
|
||||
const previousWorker = await readWorkerPid(daemon);
|
||||
await openDaemonOverview(page, daemon);
|
||||
await restartDaemonInSettings(page);
|
||||
await expectDaemonRestartComplete(page);
|
||||
expect(await readWorkerPid(daemon)).not.toBe(previousWorker);
|
||||
} finally {
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function createHomeWithSlowProvider(versionDelayMs: number): Promise<string> {
|
||||
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-slow-provider-"));
|
||||
const provider = path.join(paseoHome, "slow-provider");
|
||||
await writeFile(
|
||||
provider,
|
||||
`#!${process.execPath}\nsetTimeout(() => console.log("provider 1.0.0"), ${versionDelayMs});\n`,
|
||||
{ mode: 0o700 },
|
||||
);
|
||||
await writeFile(
|
||||
path.join(paseoHome, "config.json"),
|
||||
`${JSON.stringify({
|
||||
version: 1,
|
||||
agents: { providers: { claude: { command: { mode: "replace", argv: [provider] } } } },
|
||||
})}\n`,
|
||||
);
|
||||
return paseoHome;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import { openSettingsSection } from "../support/helpers/settings";
|
||||
@@ -121,75 +120,3 @@ test("applies a contributed theme and falls back when its plugin is gone", async
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
const MANY_THEMES_PLUGIN_ID = "plugin-theme-many-e2e";
|
||||
const MANY_THEMES_COUNT = 40;
|
||||
const LAST_THEME_NAME = `Pack theme ${MANY_THEMES_COUNT}`;
|
||||
|
||||
const MANY_THEMES_SOURCE = `export default function contribute(plugin) {
|
||||
for (let index = 1; index <= ${MANY_THEMES_COUNT}; index += 1) {
|
||||
plugin.addTheme({
|
||||
id: "pack-" + index,
|
||||
name: "Pack theme " + index,
|
||||
appearance: "dark",
|
||||
colors: {
|
||||
background: "#1e1e2e",
|
||||
foreground: "#cdd6f4",
|
||||
raised: "#313244",
|
||||
control: "#45475a",
|
||||
border: "#45475a",
|
||||
accent: "#cba6f7",
|
||||
mutedForeground: "#a6adc8",
|
||||
ring: "#6c7086",
|
||||
},
|
||||
});
|
||||
}
|
||||
return () => {};
|
||||
}`;
|
||||
|
||||
async function scrollThemeMenuToLastTheme(page: Page) {
|
||||
const firstTheme = page.getByText("Pack theme 1", { exact: true });
|
||||
await expect(firstTheme).toBeVisible({ timeout: 30_000 });
|
||||
await firstTheme.hover();
|
||||
await page.mouse.wheel(0, 4000);
|
||||
await expect(page.getByText(LAST_THEME_NAME, { exact: true })).toBeInViewport({ ratio: 1 });
|
||||
}
|
||||
|
||||
test("scrolls to the last theme when a plugin contributes more themes than fit", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-theme-many-e2e-"));
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previousConfig = await client.getDaemonConfig();
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: MANY_THEMES_PLUGIN_ID, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.ts"), MANY_THEMES_SOURCE);
|
||||
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await page.goto("/settings");
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
await openSettingsSection(page, "appearance");
|
||||
|
||||
await page.getByLabel("Theme: System", { exact: true }).click();
|
||||
await scrollThemeMenuToLastTheme(page);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("plugin-theme-picker-scrolled.png"),
|
||||
animations: "disabled",
|
||||
});
|
||||
|
||||
await page.getByText(LAST_THEME_NAME, { exact: true }).click();
|
||||
await expect(page.getByLabel(`Theme: ${LAST_THEME_NAME}`, { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await client.removePlugin(MANY_THEMES_PLUGIN_ID).catch(() => undefined);
|
||||
await client
|
||||
.patchDaemonConfig({ pluginsEnabled: previousConfig.config.pluginsEnabled ?? false })
|
||||
.catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import path from "node:path";
|
||||
import type { Page, TestInfo } from "@playwright/test";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
@@ -12,7 +11,6 @@ import {
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { expectWorkspaceHeader } from "../support/helpers/workspace-ui";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
|
||||
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
|
||||
import { escapeRegex } from "../support/helpers/regex";
|
||||
import { openFilesPanel } from "../support/helpers/workspace-tabs";
|
||||
@@ -110,207 +108,7 @@ async function withPaseoOwnedWorktree(
|
||||
}
|
||||
}
|
||||
|
||||
async function readCachedRowIds(
|
||||
page: Page,
|
||||
rowKind: "workspace" | "checkpoint",
|
||||
): Promise<string[]> {
|
||||
return page.evaluate(
|
||||
async ({ serverId, kind }) => {
|
||||
const database = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open("paseo-replica-row-store");
|
||||
request.addEventListener("success", () => resolve(request.result));
|
||||
request.addEventListener("error", () => reject(request.error));
|
||||
});
|
||||
try {
|
||||
return await new Promise<string[]>((resolve, reject) => {
|
||||
const transaction = database.transaction("rows", "readonly");
|
||||
const request = transaction.objectStore("rows").getAll();
|
||||
request.onsuccess = () =>
|
||||
resolve(
|
||||
request.result
|
||||
.filter(
|
||||
(row: { serverId: string; kind: string }) =>
|
||||
row.serverId === serverId && row.kind === kind,
|
||||
)
|
||||
.map((row: { id: string }) => row.id)
|
||||
.sort(),
|
||||
);
|
||||
request.addEventListener("error", () => reject(request.error));
|
||||
});
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
},
|
||||
{ serverId: getServerId(), kind: rowKind },
|
||||
);
|
||||
}
|
||||
|
||||
async function simulateDamagedLegacyDirectoryCache(
|
||||
page: Page,
|
||||
workspaceIdToRemove: string,
|
||||
): Promise<void> {
|
||||
// Stop the app before altering storage so no pending app write can repair the fixture.
|
||||
const fixtureUrl = new URL("/__directory_cache_fixture", page.url()).href;
|
||||
await page.route(fixtureUrl, (route) =>
|
||||
route.fulfill({ contentType: "text/html", body: "<html><body>Cache fixture</body></html>" }),
|
||||
);
|
||||
await page.goto(fixtureUrl);
|
||||
await page.evaluate(
|
||||
async ({ serverId, missingWorkspaceId }) => {
|
||||
const database = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open("paseo-replica-row-store");
|
||||
request.addEventListener("success", () => resolve(request.result));
|
||||
request.addEventListener("error", () => reject(request.error));
|
||||
});
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const transaction = database.transaction("rows", "readwrite");
|
||||
const store = transaction.objectStore("rows");
|
||||
const request = store.getAll();
|
||||
request.onsuccess = () => {
|
||||
const rows = request.result as Array<{
|
||||
serverId: string;
|
||||
kind: string;
|
||||
id: string;
|
||||
payload: string;
|
||||
}>;
|
||||
const checkpoint = rows.find(
|
||||
(row) => row.serverId === serverId && row.kind === "checkpoint",
|
||||
);
|
||||
if (!checkpoint) {
|
||||
transaction.abort();
|
||||
return;
|
||||
}
|
||||
const stored = JSON.parse(checkpoint.payload);
|
||||
// Accept either writer so this fixture also reproduces on the unfixed client.
|
||||
const cursors = stored.version === 1 ? stored.cursors : stored;
|
||||
if (!cursors.workspaces?.generation) {
|
||||
transaction.abort();
|
||||
return;
|
||||
}
|
||||
store.put({ ...checkpoint, payload: JSON.stringify(cursors) });
|
||||
for (const row of rows) {
|
||||
if (
|
||||
row.serverId === serverId &&
|
||||
row.kind === "workspace" &&
|
||||
row.id === missingWorkspaceId
|
||||
)
|
||||
store.delete([serverId, row.kind, row.id]);
|
||||
}
|
||||
};
|
||||
transaction.addEventListener("complete", () => resolve());
|
||||
transaction.addEventListener("abort", () =>
|
||||
reject(transaction.error ?? new Error("Missing current directory checkpoint")),
|
||||
);
|
||||
transaction.addEventListener("error", () => reject(transaction.error));
|
||||
});
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
},
|
||||
{ serverId: getServerId(), missingWorkspaceId: workspaceIdToRemove },
|
||||
);
|
||||
expect(await readCachedRowIds(page, "workspace")).not.toContain(workspaceIdToRemove);
|
||||
}
|
||||
|
||||
async function holdHostResponsesUntilCacheIsVisible(page: Page): Promise<() => void> {
|
||||
let release!: () => void;
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await page.routeWebSocket(new RegExp(`:${getE2EDaemonPort()}(?:/|$)`), (socket) => {
|
||||
const server = socket.connectToServer();
|
||||
server.onMessage(async (message) => {
|
||||
await ready;
|
||||
socket.send(message);
|
||||
});
|
||||
});
|
||||
return release;
|
||||
}
|
||||
|
||||
async function createPinnedSiblingWorkspace(
|
||||
project: Awaited<ReturnType<typeof seedWorkspace>>,
|
||||
): Promise<string> {
|
||||
const sibling = await project.client.createWorkspace({
|
||||
source: { kind: "directory", path: project.repoPath, projectId: project.projectId },
|
||||
title: "Pinned sibling",
|
||||
});
|
||||
if (!sibling.workspace) throw new Error(sibling.error ?? "Sibling workspace was not created");
|
||||
await project.client.setWorkspacePinned(sibling.workspace.id, true);
|
||||
return sibling.workspace.id;
|
||||
}
|
||||
|
||||
async function expectSidebarWorkspaces(page: Page, workspaceIds: string[]): Promise<void> {
|
||||
for (const workspaceId of workspaceIds) {
|
||||
await waitForSidebarWorkspace(page, workspaceId);
|
||||
}
|
||||
}
|
||||
|
||||
async function cacheWorkspaces(page: Page, workspaceIds: string[]): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await expectSidebarWorkspaces(page, workspaceIds);
|
||||
await expect.poll(() => readCachedRowIds(page, "workspace")).toEqual(workspaceIds);
|
||||
}
|
||||
|
||||
async function reopenDamagedCacheBeforeHostResponds(
|
||||
page: Page,
|
||||
missingWorkspaceId: string,
|
||||
): Promise<() => void> {
|
||||
await simulateDamagedLegacyDirectoryCache(page, missingWorkspaceId);
|
||||
const releaseHost = await holdHostResponsesUntilCacheIsVisible(page);
|
||||
await gotoAppShell(page);
|
||||
return releaseHost;
|
||||
}
|
||||
|
||||
async function expectDamagedCacheHydrated(
|
||||
page: Page,
|
||||
retainedWorkspaceId: string,
|
||||
missingWorkspaceId: string,
|
||||
): Promise<void> {
|
||||
await waitForSidebarWorkspace(page, retainedWorkspaceId);
|
||||
await expect(page.getByTestId(getWorkspaceRowTestId(missingWorkspaceId))).toHaveCount(0);
|
||||
await expect.poll(() => readCachedRowIds(page, "checkpoint")).toEqual([]);
|
||||
}
|
||||
|
||||
async function expectRecoveredWorkspacesAfterReload(
|
||||
page: Page,
|
||||
workspaceIds: string[],
|
||||
testInfo: TestInfo,
|
||||
): Promise<void> {
|
||||
await expectSidebarWorkspaces(page, workspaceIds);
|
||||
await expect.poll(() => readCachedRowIds(page, "workspace")).toEqual(workspaceIds);
|
||||
await page.reload();
|
||||
await expectSidebarWorkspaces(page, workspaceIds);
|
||||
await page.screenshot({ path: testInfo.outputPath("recovered-workspaces.png") });
|
||||
await testInfo.attach("recovered-workspaces", {
|
||||
path: testInfo.outputPath("recovered-workspaces.png"),
|
||||
contentType: "image/png",
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Sidebar workspace list", () => {
|
||||
test("recovers missing workspaces from a damaged legacy cache after reload", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const project = await seedWorkspace({ repoPrefix: "directory-cache-recovery-" });
|
||||
try {
|
||||
const siblingId = await createPinnedSiblingWorkspace(project);
|
||||
const ids = [project.workspaceId, siblingId].sort();
|
||||
await test.step("cache both workspaces", () => cacheWorkspaces(page, ids));
|
||||
|
||||
const releaseHost = await test.step("reopen damaged cache before host responds", () =>
|
||||
reopenDamagedCacheBeforeHostResponds(page, project.workspaceId));
|
||||
await test.step("keep cached workspace and discard stale checkpoint", () =>
|
||||
expectDamagedCacheHydrated(page, siblingId, project.workspaceId));
|
||||
|
||||
releaseHost();
|
||||
await test.step("recover missing workspace and retain both after reload", () =>
|
||||
expectRecoveredWorkspacesAfterReload(page, ids, testInfo));
|
||||
} finally {
|
||||
await project.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("project with GitHub remote shows its selected folder name in sidebar", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "sidebar-remote-",
|
||||
|
||||
@@ -482,50 +482,6 @@ export async function expectModelRowSelected(
|
||||
).toHaveAttribute("aria-selected", "true", { timeout: 30_000 });
|
||||
}
|
||||
|
||||
function modelRow(page: Page, input: { provider: string; modelId: string }): Locator {
|
||||
return pickerViewport(page).getByTestId(`model-row-${input.provider}-${input.modelId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* A model row's profile action is its own button. On web, a button inside the
|
||||
* row's button is invalid HTML that React reports on every render.
|
||||
*/
|
||||
export async function expectModelRowProfileActionBesideRow(
|
||||
page: Page,
|
||||
input: { provider: string; modelId: string },
|
||||
): Promise<void> {
|
||||
const row = modelRow(page, input);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(pickerViewport(page).locator("button button")).toHaveCount(0);
|
||||
await expect(row.getByRole("button")).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** The hover-revealed plus on an unprofiled model row opens a seeded profile form. */
|
||||
export async function expectCreateProfileFromModelRow(
|
||||
page: Page,
|
||||
input: { provider: string; modelId: string; modelLabel: string },
|
||||
): Promise<void> {
|
||||
await modelRow(page, input).hover();
|
||||
await pickerViewport(page)
|
||||
.getByTestId(`model-create-profile-${input.provider}-${input.modelId}`)
|
||||
.click();
|
||||
const modal = editModal(page);
|
||||
await expect(modal).toBeVisible({ timeout: 30_000 });
|
||||
await expect(modal.getByRole("textbox", { name: "Name", exact: true })).toHaveValue(
|
||||
input.modelLabel,
|
||||
);
|
||||
await expect(modal.getByRole("button", { name: `Model (${input.modelLabel})` })).toBeVisible();
|
||||
await modal.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await expect(modal).toHaveCount(0, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function selectModelRow(
|
||||
page: Page,
|
||||
input: { provider: string; modelId: string },
|
||||
): Promise<void> {
|
||||
await modelRow(page, input).click();
|
||||
}
|
||||
|
||||
// ─── Cross-provider model search ───────────────────────────────────────────
|
||||
|
||||
export async function searchAllModels(page: Page, query: string): Promise<void> {
|
||||
|
||||
@@ -625,6 +625,13 @@ function ModelRowProfileAction({
|
||||
}) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const visible = hovered || isNative || isCompact;
|
||||
const handlePress = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
event.stopPropagation();
|
||||
onPress();
|
||||
},
|
||||
[onPress],
|
||||
);
|
||||
const pressableStyle = useCallback(
|
||||
({ hovered: buttonHovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.rowIconButton,
|
||||
@@ -638,7 +645,7 @@ function ModelRowProfileAction({
|
||||
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onPress={handlePress}
|
||||
hitSlop={8}
|
||||
style={pressableStyle}
|
||||
pointerEvents={visible ? "auto" : "none"}
|
||||
@@ -771,6 +778,7 @@ function ModelRow({
|
||||
const pressableStyle = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.browserRow,
|
||||
styles.browserModelRow,
|
||||
Boolean(hovered) && styles.browserRowHovered,
|
||||
pressed && styles.browserRowPressed,
|
||||
],
|
||||
@@ -779,7 +787,7 @@ function ModelRow({
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[styles.modelRowHoverBoundary, styles.browserModelRow]}
|
||||
style={styles.modelRowHoverBoundary}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
>
|
||||
@@ -807,17 +815,10 @@ function ModelRow({
|
||||
<ThemedCheck size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
|
||||
) : null}
|
||||
</View>
|
||||
{profileAction ? <View style={styles.rowIconButton} /> : null}
|
||||
{profileAction}
|
||||
</View>
|
||||
</View>
|
||||
</ModelBrowserPressable>
|
||||
{/* The row renders a <button> on web, so its profile action sits beside it,
|
||||
over the slot reserved above, rather than inside it. */}
|
||||
{profileAction ? (
|
||||
<View style={styles.modelRowProfileActionSlot} pointerEvents="box-none">
|
||||
{profileAction}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1589,13 +1590,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
modelRowHoverBoundary: {
|
||||
position: "relative",
|
||||
},
|
||||
modelRowProfileActionSlot: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: isWeb ? theme.spacing[3] : theme.spacing[6],
|
||||
justifyContent: "center",
|
||||
},
|
||||
browserModelRow: isWeb ? {} : { marginBottom: theme.spacing[1] },
|
||||
browserRowHovered: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
resolveWorkingDiffComparison,
|
||||
selectWorkingDiffComparison,
|
||||
} from "@/git/working-diff-comparison";
|
||||
import { draftAgentCommandsQueryKey } from "@/hooks/agent-commands-query";
|
||||
import {
|
||||
applyCheckoutStatusUpdateFromEvent,
|
||||
ensureCheckoutStatus,
|
||||
@@ -198,51 +197,6 @@ describe("applyCheckoutStatusUpdateFromEvent", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("drops the checkout's cached draft slash commands when its branch changes", () => {
|
||||
const queryClient = createQueryClient();
|
||||
queryClient.setQueryData(
|
||||
checkoutStatusQueryKey(serverId, cwd),
|
||||
checkoutStatus({ currentBranch: "chore/build-paseo" }),
|
||||
);
|
||||
const thisCheckout = draftAgentCommandsQueryKey({
|
||||
serverId,
|
||||
draftConfig: { provider: "claude", cwd, model: "haiku" },
|
||||
});
|
||||
const otherCheckout = draftAgentCommandsQueryKey({
|
||||
serverId,
|
||||
draftConfig: { provider: "claude", cwd: "/repo2", model: "haiku" },
|
||||
});
|
||||
queryClient.setQueryData(thisCheckout, [{ name: "build-paseo" }]);
|
||||
queryClient.setQueryData(otherCheckout, [{ name: "build-paseo" }]);
|
||||
|
||||
applyCheckoutStatusUpdateFromEvent({
|
||||
queryClient,
|
||||
serverId,
|
||||
message: checkoutStatusUpdate(checkoutStatus({ currentBranch: "main" })),
|
||||
});
|
||||
|
||||
expect(queryClient.getQueryData(thisCheckout)).toBeUndefined();
|
||||
expect(queryClient.getQueryData(otherCheckout)).toEqual([{ name: "build-paseo" }]);
|
||||
});
|
||||
|
||||
it("keeps the checkout's draft slash commands when a push leaves its branch unchanged", () => {
|
||||
const queryClient = createQueryClient();
|
||||
queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), checkoutStatus());
|
||||
const thisCheckout = draftAgentCommandsQueryKey({
|
||||
serverId,
|
||||
draftConfig: { provider: "claude", cwd, model: "haiku" },
|
||||
});
|
||||
queryClient.setQueryData(thisCheckout, [{ name: "build-paseo" }]);
|
||||
|
||||
applyCheckoutStatusUpdateFromEvent({
|
||||
queryClient,
|
||||
serverId,
|
||||
message: checkoutStatusUpdate(checkoutStatus({ isDirty: true })),
|
||||
});
|
||||
|
||||
expect(queryClient.getQueryData(thisCheckout)).toEqual([{ name: "build-paseo" }]);
|
||||
});
|
||||
|
||||
it("writes the PR status cache when prStatus is present, and skips it otherwise", () => {
|
||||
const queryClient = createQueryClient();
|
||||
const pushedPr = prStatus({ requestId: "pr-1" });
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
invalidatePrPaneTimelineForCheckout,
|
||||
} from "@/git/query-keys";
|
||||
import { type CheckoutPrStatusPayload, normalizeCheckoutPrStatusPayload } from "@/git/pr-status";
|
||||
import { resetDraftAgentCommandsForCheckout } from "@/hooks/agent-commands-query";
|
||||
import { expireWorkingDiffComparisons } from "@/git/working-diff-comparison";
|
||||
|
||||
export type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
||||
@@ -68,13 +67,7 @@ export function applyCheckoutStatusUpdateFromEvent({
|
||||
? normalizeCheckoutPrStatusPayload(payload.prStatus)
|
||||
: undefined;
|
||||
const cachePayload = prStatus ? { ...payload, prStatus } : payload;
|
||||
const previousStatus = queryClient.getQueryData<CheckoutStatusPayload>(
|
||||
checkoutStatusQueryKey(serverId, payload.cwd),
|
||||
);
|
||||
queryClient.setQueryData(checkoutStatusQueryKey(serverId, payload.cwd), cachePayload);
|
||||
if (previousStatus?.currentBranch !== payload.currentBranch) {
|
||||
void resetDraftAgentCommandsForCheckout(queryClient, { serverId, cwd: payload.cwd });
|
||||
}
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: checkoutCommitsQueryKey(serverId, payload.cwd),
|
||||
});
|
||||
|
||||
@@ -38,10 +38,9 @@ describe("agent command query keys", () => {
|
||||
"agentCommands",
|
||||
"server-1",
|
||||
"draft",
|
||||
"codex",
|
||||
"cwd",
|
||||
"/repo",
|
||||
"provider",
|
||||
"codex",
|
||||
"mode",
|
||||
null,
|
||||
"model",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
|
||||
|
||||
@@ -25,24 +24,17 @@ export function sessionAgentCommandsQueryKey(input: { serverId: string; agentId:
|
||||
return [...agentCommandsQueryRoot(input.serverId), "session", input.agentId] as const;
|
||||
}
|
||||
|
||||
function draftAgentCommandsCheckoutScope(input: { serverId: string; cwd: string }) {
|
||||
return [
|
||||
...agentCommandsQueryRoot(input.serverId),
|
||||
"draft",
|
||||
"cwd",
|
||||
normalizeAgentCommandsCwd(input.cwd),
|
||||
] as const;
|
||||
}
|
||||
|
||||
export function draftAgentCommandsQueryKey(input: {
|
||||
serverId: string;
|
||||
draftConfig: AgentCommandsDraftConfig;
|
||||
}) {
|
||||
const { draftConfig } = input;
|
||||
return [
|
||||
...draftAgentCommandsCheckoutScope({ serverId: input.serverId, cwd: draftConfig.cwd }),
|
||||
"provider",
|
||||
...agentCommandsQueryRoot(input.serverId),
|
||||
"draft",
|
||||
draftConfig.provider,
|
||||
"cwd",
|
||||
normalizeAgentCommandsCwd(draftConfig.cwd),
|
||||
"mode",
|
||||
draftConfig.modeId ?? null,
|
||||
"model",
|
||||
@@ -54,16 +46,6 @@ export function draftAgentCommandsQueryKey(input: {
|
||||
] as const;
|
||||
}
|
||||
|
||||
// Draft commands include project skills read from the checkout. When the checkout moves to
|
||||
// another branch the cached list describes files that are gone, so it is dropped rather than
|
||||
// shown while a refetch runs.
|
||||
export function resetDraftAgentCommandsForCheckout(
|
||||
queryClient: QueryClient,
|
||||
input: { serverId: string; cwd: string },
|
||||
): Promise<void> {
|
||||
return queryClient.resetQueries({ queryKey: draftAgentCommandsCheckoutScope(input) });
|
||||
}
|
||||
|
||||
export function agentCommandsQueryKey(input: {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
|
||||
@@ -44,7 +44,6 @@ class FakeDirectoryClient {
|
||||
projectResult: ProjectListResult | null = null;
|
||||
private pendingAgentFetch: Promise<AgentFetchResult> | null = null;
|
||||
private pendingWorkspaceFetch: Promise<WorkspaceFetchResult> | null = null;
|
||||
private pendingProjectFetch: Promise<ProjectListResult> | null = null;
|
||||
private readonly handlers = new Map<
|
||||
SessionOutboundMessage["type"],
|
||||
Set<(message: SessionOutboundMessage) => void>
|
||||
@@ -83,14 +82,6 @@ class FakeDirectoryClient {
|
||||
return complete;
|
||||
}
|
||||
|
||||
holdProjectFetch(): (result: ProjectListResult) => void {
|
||||
let complete!: (result: ProjectListResult) => void;
|
||||
this.pendingProjectFetch = new Promise((resolve) => {
|
||||
complete = resolve;
|
||||
});
|
||||
return complete;
|
||||
}
|
||||
|
||||
async fetchAgents(options?: unknown): Promise<Awaited<ReturnType<DaemonClient["fetchAgents"]>>> {
|
||||
this.fetchAgentsCalls += 1;
|
||||
this.lastAgentOptions = options;
|
||||
@@ -125,11 +116,6 @@ class FakeDirectoryClient {
|
||||
async listProjects(options?: unknown): Promise<ProjectListResult> {
|
||||
this.listProjectsCalls += 1;
|
||||
this.lastProjectOptions = options;
|
||||
if (this.pendingProjectFetch) {
|
||||
const pending = this.pendingProjectFetch;
|
||||
this.pendingProjectFetch = null;
|
||||
return pending;
|
||||
}
|
||||
if (this.projectResult) return this.projectResult;
|
||||
return {
|
||||
requestId: "projects",
|
||||
@@ -241,25 +227,6 @@ function createAgent(serverId: string, id: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function createWorkspaceEntry(id: string): WorkspaceFetchResult["entries"][number] {
|
||||
return {
|
||||
id,
|
||||
projectId: "snapshot-project",
|
||||
projectDisplayName: "Snapshot project",
|
||||
projectRootPath: "/repo",
|
||||
workspaceDirectory: `/repo/${id}`,
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: id,
|
||||
status: "done",
|
||||
statusEnteredAt: null,
|
||||
activityAt: null,
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const serverId of serverIds) useSessionStore.getState().clearSession(serverId);
|
||||
serverIds.clear();
|
||||
@@ -1148,41 +1115,6 @@ describe("DirectorySync session readiness", () => {
|
||||
directory.dispose();
|
||||
});
|
||||
|
||||
it("keeps the accepted baseline when an old connection finishes its workspace request", async () => {
|
||||
const serverId = "superseded-workspace-baseline";
|
||||
const { client, directory } = createDirectory(serverId);
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
|
||||
store.updateSessionServerInfo(serverId, {
|
||||
serverId,
|
||||
hostname: null,
|
||||
version: "test",
|
||||
features: { workspaceMultiplicity: true, directorySync: true },
|
||||
});
|
||||
directory.acceptWorkspaces([normalizeWorkspaceDescriptor(createWorkspaceEntry("retained"))]);
|
||||
const release = client.holdWorkspaceFetch();
|
||||
const refresh = directory.refreshWorkspaces();
|
||||
const rejected = expect(refresh).rejects.toBeInstanceOf(DirectoryRefreshSupersededError);
|
||||
await expect.poll(() => client.fetchWorkspacesCalls).toBe(1);
|
||||
directory.connectionChanged({
|
||||
client: client as unknown as DaemonClient,
|
||||
status: "online",
|
||||
source: { clientGeneration: 1, connectionEpoch: 2 },
|
||||
});
|
||||
release({
|
||||
requestId: "old-empty-snapshot",
|
||||
entries: [],
|
||||
emptyProjects: [],
|
||||
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
|
||||
sync: { generation: "g", headSeq: 7, mode: "snapshot", removals: [] },
|
||||
});
|
||||
await rejected;
|
||||
expect([...useSessionStore.getState().sessions[serverId].workspaces.keys()]).toEqual([
|
||||
"retained",
|
||||
]);
|
||||
directory.dispose();
|
||||
});
|
||||
|
||||
it("rejects a session wait on disconnect so the reconnect can refresh", async () => {
|
||||
const serverId = "session-wait-reconnect";
|
||||
const { client, directory } = createDirectory(serverId);
|
||||
@@ -1250,19 +1182,9 @@ describe("DirectorySync session readiness", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
client.emit({
|
||||
type: "workspace_update",
|
||||
payload: {
|
||||
kind: "upsert",
|
||||
workspace: { ...createWorkspaceEntry("updated-workspace"), name: "Live name" },
|
||||
},
|
||||
});
|
||||
completeFetch({
|
||||
requestId: "workspaces",
|
||||
entries: [
|
||||
createWorkspaceEntry("removed-workspace"),
|
||||
createWorkspaceEntry("updated-workspace"),
|
||||
],
|
||||
entries: [],
|
||||
emptyProjects: [
|
||||
{
|
||||
projectId: "snapshot-project",
|
||||
@@ -1285,11 +1207,6 @@ describe("DirectorySync session readiness", () => {
|
||||
expect(projects?.get("workspace-project")).toMatchObject({
|
||||
projectDisplayName: "Project from workspace update",
|
||||
});
|
||||
expect(
|
||||
[...useSessionStore.getState().sessions[serverId].workspaces.values()].map(
|
||||
({ id, name }) => ({ id, name }),
|
||||
),
|
||||
).toEqual([{ id: "updated-workspace", name: "Live name" }]);
|
||||
directory.dispose();
|
||||
});
|
||||
|
||||
@@ -1361,8 +1278,6 @@ function createSqliteCache() {
|
||||
return {
|
||||
cache,
|
||||
database,
|
||||
storage,
|
||||
reopen: () => new ReplicaCache(storage, { clearLegacyCache: async () => {} }),
|
||||
holdRead: (operation: () => Promise<void>) => {
|
||||
beforeRead = operation;
|
||||
},
|
||||
@@ -1558,198 +1473,3 @@ describe("DirectorySync workspace labels", () => {
|
||||
directory.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["before", "during", "before-metadata", "damaged"] as const)(
|
||||
"retains unchanged workspaces across route/full demand and restart (cache: %s)",
|
||||
async (timing) => {
|
||||
const cacheFirst = timing === "before";
|
||||
const damaged = timing === "damaged";
|
||||
const serverId = `cache-route-overlap-${timing}`;
|
||||
serverIds.add(serverId);
|
||||
const { cache, database, reopen, storage } = createSqliteCache();
|
||||
cache.setHosts([serverId]);
|
||||
const projectEntry: ProjectListResult["projects"][number] = {
|
||||
projectId: "P",
|
||||
projectDisplayName: "Project P",
|
||||
projectRootPath: "/repo",
|
||||
projectKind: "git",
|
||||
};
|
||||
const project = normalizeProjectDescriptor(projectEntry);
|
||||
const workspaceEntries: WorkspaceFetchResult["entries"] = ["A", "B"].map((id) => ({
|
||||
id,
|
||||
projectId: "P",
|
||||
projectDisplayName: "Project P",
|
||||
projectRootPath: "/repo",
|
||||
workspaceDirectory: `/repo/${id}`,
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: id,
|
||||
status: "done",
|
||||
statusEnteredAt: null,
|
||||
activityAt: null,
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
}));
|
||||
const workspaces = workspaceEntries.map(normalizeWorkspaceDescriptor);
|
||||
const cursor = { generation: "g", afterSeq: 7 };
|
||||
cache.replaceDirectoryBaseline(serverId, {
|
||||
agents: new Map(),
|
||||
projects: new Map([[project.projectId, project]]),
|
||||
workspaces: new Map(
|
||||
(damaged ? [] : workspaces).map((workspace) => [workspace.id, workspace]),
|
||||
),
|
||||
checkpoint: { projects: cursor, workspaces: cursor, agents: cursor },
|
||||
});
|
||||
await cache.flush();
|
||||
if (damaged) {
|
||||
await storage.apply({
|
||||
deletes: [],
|
||||
upserts: [
|
||||
{
|
||||
serverId,
|
||||
kind: "checkpoint",
|
||||
id: "singleton",
|
||||
payload: JSON.stringify({ projects: cursor, workspaces: cursor, agents: cursor }),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// The host keeps A/B unchanged. A current cursor legitimately receives no rows.
|
||||
class UnchangedDirectoryClient extends FakeDirectoryClient {
|
||||
readonly workspaceSyncRequests: unknown[] = [];
|
||||
override async fetchWorkspaces(
|
||||
options?: Parameters<DaemonClient["fetchWorkspaces"]>[0],
|
||||
): Promise<WorkspaceFetchResult> {
|
||||
this.fetchWorkspacesCalls += 1;
|
||||
this.lastWorkspaceOptions = options;
|
||||
this.workspaceSyncRequests.push(options?.sync);
|
||||
const changes = options?.sync?.generation === "g" && options.sync.afterSeq === 7;
|
||||
return {
|
||||
requestId: "workspaces",
|
||||
entries: changes ? [] : [workspaceEntries[options?.page?.cursor ? 1 : 0]],
|
||||
emptyProjects: [],
|
||||
pageInfo: {
|
||||
hasMore: !changes && !options?.page?.cursor,
|
||||
nextCursor: !changes && !options?.page?.cursor ? "page-2" : null,
|
||||
prevCursor: null,
|
||||
},
|
||||
sync: {
|
||||
generation: "g",
|
||||
headSeq: 7,
|
||||
mode: changes ? "changes" : "snapshot",
|
||||
removals: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
const client = new UnchangedDirectoryClient();
|
||||
const releaseAgents = client.holdAgentFetch();
|
||||
const releaseProjects = client.holdProjectFetch();
|
||||
const projectResult: ProjectListResult = {
|
||||
requestId: "projects",
|
||||
projects: [projectEntry],
|
||||
sync: { generation: "g", headSeq: 7, mode: "snapshot", removals: [] },
|
||||
};
|
||||
client.projectResult = projectResult;
|
||||
const callbacks = {
|
||||
onAgentStoppedRunning: () => {},
|
||||
markAgentLoading: () => {},
|
||||
markAgentReady: () => {},
|
||||
markAgentError: () => {},
|
||||
};
|
||||
const directory = new DirectorySync(serverId, callbacks, cache);
|
||||
const publishServerInfo = () => {
|
||||
useSessionStore.getState().updateSessionServerInfo(serverId, {
|
||||
serverId,
|
||||
hostname: null,
|
||||
version: "test",
|
||||
features: { workspaceMultiplicity: true, directorySync: true, projectList: true },
|
||||
});
|
||||
};
|
||||
const initializeSession = (ready = true) => {
|
||||
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1);
|
||||
if (ready) publishServerInfo();
|
||||
};
|
||||
const connection = {
|
||||
client: client as unknown as DaemonClient,
|
||||
status: "online" as const,
|
||||
source: { clientGeneration: 1, connectionEpoch: 1 },
|
||||
};
|
||||
initializeSession(timing !== "before-metadata");
|
||||
directory.connectionChanged(connection);
|
||||
const visibleIds = () =>
|
||||
[...useSessionStore.getState().sessions[serverId].workspaces.keys()].sort();
|
||||
let restarted: DirectorySync | undefined;
|
||||
const restartedCache = reopen();
|
||||
try {
|
||||
if (cacheFirst) await directory.restoreCachedDirectory();
|
||||
directory.setAgentRouteDemand(["agent"]);
|
||||
if (timing === "before-metadata") {
|
||||
await directory.restoreCachedDirectory();
|
||||
publishServerInfo();
|
||||
}
|
||||
await expect.poll(() => client.listProjectsCalls).toBe(1);
|
||||
await expect.poll(() => client.fetchAgentsCalls).toBe(1);
|
||||
directory.setDemand({}, true);
|
||||
await directory.restoreCachedDirectory();
|
||||
expect(visibleIds()).toEqual(damaged ? [] : ["A", "B"]);
|
||||
|
||||
releaseProjects(projectResult);
|
||||
releaseAgents({
|
||||
requestId: "agents",
|
||||
entries: [],
|
||||
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
|
||||
sync: {
|
||||
generation: "g",
|
||||
headSeq: 7,
|
||||
mode: cacheFirst ? "changes" : "snapshot",
|
||||
removals: [],
|
||||
},
|
||||
});
|
||||
await directory.refreshDemand();
|
||||
expect(client.lastProjectOptions).toEqual({ sync: cacheFirst ? cursor : {} });
|
||||
expect(client.workspaceSyncRequests).toEqual(cacheFirst ? [cursor] : [{}, {}]);
|
||||
expect.soft(visibleIds(), "after overlapping refresh").toEqual(["A", "B"]);
|
||||
await cache.flush();
|
||||
expect([...(await cache.readDirectory(serverId)).workspaces.keys()].sort()).toEqual([
|
||||
"A",
|
||||
"B",
|
||||
]);
|
||||
|
||||
await directory.refreshAll();
|
||||
await cache.flush();
|
||||
directory.dispose();
|
||||
useSessionStore.getState().clearSession(serverId);
|
||||
restartedCache.setHosts([serverId]);
|
||||
const persisted = await restartedCache.readDirectory(serverId);
|
||||
expect(persisted.checkpoint?.workspaces).toEqual(cursor);
|
||||
expect
|
||||
.soft([...persisted.workspaces.keys()].sort(), "persisted baseline")
|
||||
.toEqual(["A", "B"]);
|
||||
initializeSession();
|
||||
restarted = new DirectorySync(serverId, callbacks, restartedCache);
|
||||
restarted.connectionChanged(connection);
|
||||
await restarted.refreshAll();
|
||||
expect.soft(visibleIds(), "after restart and refresh").toEqual(["A", "B"]);
|
||||
const beforeUpdate = visibleIds();
|
||||
client.emit({
|
||||
type: "workspace_update",
|
||||
payload: { kind: "upsert", workspace: workspaceEntries[0], generation: "g", seq: 8 },
|
||||
});
|
||||
expect(visibleIds()).toEqual([...new Set([...beforeUpdate, "A"])].sort());
|
||||
client.emit({
|
||||
type: "workspace_update",
|
||||
payload: { kind: "upsert", workspace: workspaceEntries[1], generation: "g", seq: 9 },
|
||||
});
|
||||
expect(visibleIds()).toEqual(["A", "B"]);
|
||||
} finally {
|
||||
directory.dispose();
|
||||
restarted?.dispose();
|
||||
await cache.flush();
|
||||
await restartedCache.flush();
|
||||
database.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -61,10 +61,6 @@ interface AgentSnapshot {
|
||||
syncRemovals: Array<{ id: string; seq: number }>;
|
||||
}
|
||||
|
||||
interface WorkspaceRefreshSnapshot extends WorkspaceDirectorySnapshot {
|
||||
readonly requestCursors: Readonly<Pick<DirectoryCheckpoint, "projects" | "workspaces">>;
|
||||
}
|
||||
|
||||
interface AgentPageInfo {
|
||||
hasMore?: boolean;
|
||||
hasMoreAfter?: boolean;
|
||||
@@ -137,7 +133,7 @@ export class DirectorySync {
|
||||
AgentDirectoryDelta
|
||||
>();
|
||||
private readonly workspaceTransactions = new DirectoryTransactionOwner<
|
||||
WorkspaceRefreshSnapshot,
|
||||
WorkspaceDirectorySnapshot,
|
||||
WorkspaceDirectoryDelta
|
||||
>();
|
||||
private readonly agents: AgentDirectoryReplica;
|
||||
@@ -482,20 +478,14 @@ export class DirectorySync {
|
||||
const onlineConnection = this.getOnlineConnection();
|
||||
if (!onlineConnection) return;
|
||||
const { client, source } = onlineConnection;
|
||||
const transaction = this.workspaceTransactions.begin(source, () => {
|
||||
const baseline = this.workspaces.snapshot();
|
||||
return {
|
||||
workspaces: new Map(baseline.workspaces),
|
||||
projects: new Map(baseline.projects),
|
||||
// Cache hydration can advance the live replica while these requests await responses.
|
||||
// Every page must use the cursors belonging to the maps captured here.
|
||||
requestCursors: { ...this.cursors },
|
||||
syncCursors: {},
|
||||
syncModes: {},
|
||||
touchedWorkspaceIds: new Set(),
|
||||
touchedProjectIds: new Set(),
|
||||
};
|
||||
});
|
||||
const transaction = this.workspaceTransactions.begin(source, () => ({
|
||||
workspaces: new Map(useSessionStore.getState().sessions[this.serverId]?.workspaces),
|
||||
projects: new Map(useSessionStore.getState().sessions[this.serverId]?.projects),
|
||||
syncCursors: {},
|
||||
syncModes: {},
|
||||
touchedWorkspaceIds: new Set(),
|
||||
touchedProjectIds: new Set(),
|
||||
}));
|
||||
try {
|
||||
await this.waitForSessionMetadata(client, source);
|
||||
const serverInfo = useSessionStore.getState().sessions[this.serverId]?.serverInfo;
|
||||
@@ -527,7 +517,7 @@ export class DirectorySync {
|
||||
private async fetchWorkspaceSnapshot(
|
||||
client: DaemonClient,
|
||||
source: DirectorySourceToken,
|
||||
transaction: DirectoryTransaction<WorkspaceRefreshSnapshot, WorkspaceDirectoryDelta>,
|
||||
transaction: DirectoryTransaction<WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta>,
|
||||
initialSubscribe: boolean,
|
||||
supportsDirectorySync: boolean,
|
||||
): Promise<void> {
|
||||
@@ -537,9 +527,7 @@ export class DirectorySync {
|
||||
const query: Parameters<DaemonClient["observeWorkspaces"]>[0] = {
|
||||
sort: [{ key: "activity_at", direction: "desc" }],
|
||||
page: cursor ? { limit: PAGE_LIMIT, cursor } : { limit: PAGE_LIMIT },
|
||||
...(supportsDirectorySync
|
||||
? { sync: transaction.snapshot.requestCursors.workspaces ?? {} }
|
||||
: {}),
|
||||
...(supportsDirectorySync ? { sync: this.readCursors().workspaces ?? {} } : {}),
|
||||
};
|
||||
let payload: FetchWorkspacesPayload;
|
||||
if (subscribe) {
|
||||
@@ -679,7 +667,7 @@ export class DirectorySync {
|
||||
private assertWorkspaceTransactionCurrent(
|
||||
client: DaemonClient,
|
||||
source: DirectorySourceToken,
|
||||
transaction: DirectoryTransaction<WorkspaceRefreshSnapshot, WorkspaceDirectoryDelta>,
|
||||
transaction: DirectoryTransaction<WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta>,
|
||||
): void {
|
||||
if (!this.workspaceTransactions.isCurrent(transaction) || !this.isCurrent(client, source)) {
|
||||
throw new DirectoryRefreshSupersededError("workspace fetch no longer current");
|
||||
@@ -736,13 +724,11 @@ export class DirectorySync {
|
||||
private async fetchProjectSnapshot(
|
||||
client: DaemonClient,
|
||||
source: DirectorySourceToken,
|
||||
transaction: DirectoryTransaction<WorkspaceRefreshSnapshot, WorkspaceDirectoryDelta>,
|
||||
transaction: DirectoryTransaction<WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta>,
|
||||
supportsDirectorySync: boolean,
|
||||
): Promise<void> {
|
||||
const payload = await client.listProjects(
|
||||
supportsDirectorySync
|
||||
? { sync: transaction.snapshot.requestCursors.projects ?? {} }
|
||||
: undefined,
|
||||
supportsDirectorySync ? { sync: this.readCursors().projects ?? {} } : undefined,
|
||||
);
|
||||
this.assertWorkspaceTransactionCurrent(client, source, transaction);
|
||||
if (payload.sync?.mode !== "changes") transaction.snapshot.projects.clear();
|
||||
@@ -767,7 +753,7 @@ export class DirectorySync {
|
||||
private completeWorkspaceRefresh(
|
||||
client: DaemonClient,
|
||||
source: DirectorySourceToken,
|
||||
transaction: DirectoryTransaction<WorkspaceRefreshSnapshot, WorkspaceDirectoryDelta>,
|
||||
transaction: DirectoryTransaction<WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta>,
|
||||
): void {
|
||||
if (!this.isCurrent(client, source) || !this.hasMatchingSession(client, source)) {
|
||||
throw new DirectoryRefreshSupersededError("workspace completion no longer current");
|
||||
|
||||
@@ -256,41 +256,6 @@ function deleteDirectory(cache: ReplicaCache, serverId: string): void {
|
||||
}
|
||||
|
||||
describe("ReplicaCache", () => {
|
||||
it("rejects pre-baseline-fix checkpoints without discarding cached rows or timelines", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const writer = createCache(storage);
|
||||
const baseline = directory();
|
||||
writer.replaceDirectoryBaseline(SERVER_ID, baseline);
|
||||
writer.commitTimeline(SERVER_ID, "agent-1", timeline());
|
||||
await writer.flush();
|
||||
await storage.apply({
|
||||
deletes: [],
|
||||
upserts: [
|
||||
{
|
||||
serverId: SERVER_ID,
|
||||
kind: "checkpoint",
|
||||
id: "singleton",
|
||||
payload: JSON.stringify(baseline.checkpoint),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const reader = createCache(storage);
|
||||
const restored = await reader.readDirectory(SERVER_ID);
|
||||
expect(restored.checkpoint).toBeUndefined();
|
||||
expect([...restored.workspaces.keys()]).toEqual([...baseline.workspaces.keys()]);
|
||||
expect([...restored.projects.keys()]).toEqual([...baseline.projects.keys()]);
|
||||
expect([...restored.agents.keys()]).toEqual([...baseline.agents.keys()]);
|
||||
expect(await reader.readTimeline(SERVER_ID, "agent-1")).toEqual(timeline());
|
||||
expect(await storage.read(SERVER_ID, ["checkpoint"])).toEqual([]);
|
||||
|
||||
reader.replaceDirectoryBaseline(SERVER_ID, baseline);
|
||||
await reader.flush();
|
||||
expect((await createCache(storage).readDirectory(SERVER_ID)).checkpoint).toEqual(
|
||||
baseline.checkpoint,
|
||||
);
|
||||
});
|
||||
|
||||
it("does nothing until an owner explicitly commits data", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const cache = createCache(storage);
|
||||
|
||||
@@ -378,21 +378,6 @@ const DirectoryCheckpointSchema = z.strictObject({
|
||||
agents: DirectoryCursorSchema.optional(),
|
||||
});
|
||||
|
||||
// Old checkpoints could describe a baseline that an overlapping refresh had discarded.
|
||||
// Revalidate directory contents once without evicting cached rows or timelines.
|
||||
const StoredDirectoryCheckpointSchema = z.strictObject({
|
||||
version: z.literal(1),
|
||||
cursors: DirectoryCheckpointSchema,
|
||||
});
|
||||
|
||||
function serializeDirectoryCheckpoint(cursors: DirectoryCheckpoint) {
|
||||
return { version: 1 as const, cursors };
|
||||
}
|
||||
|
||||
function deserializeDirectoryCheckpoint(payload: string): DirectoryCheckpoint {
|
||||
return parseStoredPayload(StoredDirectoryCheckpointSchema, payload).cursors;
|
||||
}
|
||||
|
||||
function deserializeTimeline(stored: StoredTimeline | null): CachedTimeline | null {
|
||||
if (!stored) {
|
||||
return null;
|
||||
@@ -864,7 +849,7 @@ function applyDirectoryRow(
|
||||
if (row.id !== REPLICA_SINGLETON_ROW_ID) {
|
||||
throw new Error("Replica checkpoint row id mismatch");
|
||||
}
|
||||
result.checkpoint = deserializeDirectoryCheckpoint(row.payload);
|
||||
result.checkpoint = parseStoredPayload(DirectoryCheckpointSchema, row.payload);
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
@@ -1030,7 +1015,7 @@ export class ReplicaCache {
|
||||
let checkpoint: DirectoryCheckpoint | undefined;
|
||||
if (checkpointRow) {
|
||||
try {
|
||||
checkpoint = deserializeDirectoryCheckpoint(checkpointRow.payload);
|
||||
checkpoint = parseStoredPayload(DirectoryCheckpointSchema, checkpointRow.payload);
|
||||
checkpoint = { ...checkpoint };
|
||||
delete checkpoint[invalidEntity];
|
||||
} catch {
|
||||
@@ -1046,7 +1031,7 @@ export class ReplicaCache {
|
||||
serverId: row.serverId,
|
||||
kind: "checkpoint",
|
||||
id: REPLICA_SINGLETON_ROW_ID,
|
||||
payload: JSON.stringify(serializeDirectoryCheckpoint(checkpoint)),
|
||||
payload: JSON.stringify(checkpoint),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
@@ -1072,7 +1057,7 @@ export class ReplicaCache {
|
||||
serverId,
|
||||
kind: "checkpoint",
|
||||
id: REPLICA_SINGLETON_ROW_ID,
|
||||
payload: JSON.stringify(serializeDirectoryCheckpoint(checkpoint)),
|
||||
payload: JSON.stringify(checkpoint),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
@@ -1338,7 +1323,7 @@ export class ReplicaCache {
|
||||
if (!value) return null;
|
||||
break;
|
||||
case "checkpoint":
|
||||
value = serializeDirectoryCheckpoint(upsert.value);
|
||||
value = upsert.value;
|
||||
break;
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -195,7 +195,7 @@ function ThemeRow({
|
||||
<Text style={styles.triggerText}>{selectedLabel}</Text>
|
||||
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" width={200} scrollable>
|
||||
<DropdownMenuContent side="bottom" align="end" width={200}>
|
||||
{THEME_OPTIONS.map((option, index) => {
|
||||
const previousOption = THEME_OPTIONS[index - 1];
|
||||
return (
|
||||
|
||||
@@ -622,7 +622,7 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
|
||||
{
|
||||
restartServer: (reason) => daemonClient.restartServer(reason),
|
||||
getStatus: async () => ({
|
||||
...(await daemonClient.getDaemonStatus()),
|
||||
...(await daemonClient.getDaemonStatus({ timeout: 1500 })),
|
||||
serverId: daemonClient.getLastServerInfoMessage()?.serverId ?? "",
|
||||
version: daemonClient.getLastServerInfoMessage()?.version ?? null,
|
||||
}),
|
||||
@@ -766,7 +766,7 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
|
||||
void updateDaemonFromSettings(host.serverId, {
|
||||
updateDaemon: () => daemonClient.updateDaemon(requestId),
|
||||
getStatus: async () => ({
|
||||
...(await daemonClient.getDaemonStatus()),
|
||||
...(await daemonClient.getDaemonStatus({ timeout: 1500 })),
|
||||
serverId: daemonClient.getLastServerInfoMessage()?.serverId ?? "",
|
||||
version: daemonClient.getLastServerInfoMessage()?.version ?? null,
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { page, userEvent } from "@vitest/browser/context";
|
||||
import { page } from "@vitest/browser/context";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode";
|
||||
import { encodeTerminalOutput, TerminalEmulatorRuntime } from "./terminal-emulator-runtime";
|
||||
@@ -40,7 +40,6 @@ interface MountedTerminal {
|
||||
sizes: TerminalSize[];
|
||||
terminalKeys: TerminalKeyRecord[];
|
||||
inputModeChanges: TerminalInputModeState[];
|
||||
openedUrls: string[];
|
||||
}
|
||||
|
||||
const mountedTerminals: MountedTerminal[] = [];
|
||||
@@ -96,7 +95,6 @@ function createTerminalHost(input: {
|
||||
const inputs: string[] = [];
|
||||
const terminalKeys: TerminalKeyRecord[] = [];
|
||||
const inputModeChanges: TerminalInputModeState[] = [];
|
||||
const openedUrls: string[] = [];
|
||||
const runtime = new TerminalEmulatorRuntime();
|
||||
runtime.setCallbacks({
|
||||
callbacks: {
|
||||
@@ -112,9 +110,6 @@ function createTerminalHost(input: {
|
||||
onInputModeChange: (state) => {
|
||||
inputModeChanges.push(state);
|
||||
},
|
||||
onOpenExternalUrl: (url) => {
|
||||
openedUrls.push(url);
|
||||
},
|
||||
},
|
||||
});
|
||||
runtime.mount({
|
||||
@@ -129,16 +124,7 @@ function createTerminalHost(input: {
|
||||
},
|
||||
});
|
||||
|
||||
const mounted = {
|
||||
host,
|
||||
root,
|
||||
runtime,
|
||||
inputs,
|
||||
sizes,
|
||||
terminalKeys,
|
||||
inputModeChanges,
|
||||
openedUrls,
|
||||
};
|
||||
const mounted = { host, root, runtime, inputs, sizes, terminalKeys, inputModeChanges };
|
||||
mountedTerminals.push(mounted);
|
||||
return mounted;
|
||||
}
|
||||
@@ -202,43 +188,7 @@ function dispatchTerminalKey(input: {
|
||||
);
|
||||
}
|
||||
|
||||
/** An OSC 8 hyperlink, the escape sequence CLIs such as gh and ls --hyperlink print. */
|
||||
function hyperlink(input: { url: string; text: string }): string {
|
||||
return `\x1b]8;;${input.url}\x1b\\${input.text}\x1b]8;;\x1b\\`;
|
||||
}
|
||||
|
||||
function writeLines(mounted: MountedTerminal, lines: string[]): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
mounted.runtime.write({
|
||||
data: terminalOutput(lines.map((line) => `${line}\r\n`).join("")),
|
||||
onCommitted: resolve,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function clickTerminalLink(input: {
|
||||
host: HTMLElement;
|
||||
row: number;
|
||||
col: number;
|
||||
}): Promise<void> {
|
||||
const terminal = getBrowserTerminal();
|
||||
const screen = input.host.querySelector<HTMLElement>(".xterm-screen");
|
||||
if (!screen) {
|
||||
throw new Error("Expected xterm screen to be mounted");
|
||||
}
|
||||
const position = {
|
||||
x: (input.col + 0.5) * (screen.clientWidth / terminal.cols),
|
||||
y: (input.row + 0.5) * (screen.clientHeight / terminal.rows),
|
||||
};
|
||||
await userEvent.hover(screen, { position });
|
||||
await waitFor({
|
||||
predicate: () => input.host.querySelector(".xterm-cursor-pointer") !== null,
|
||||
});
|
||||
await userEvent.click(screen, { position });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
for (const mounted of mountedTerminals.splice(0)) {
|
||||
mounted.runtime.unmount();
|
||||
mounted.root.remove();
|
||||
@@ -576,23 +526,4 @@ describe("terminal emulator runtime in a real browser", () => {
|
||||
|
||||
expect(reset).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens OSC 8 hyperlinks the same way as plain-text URLs", async () => {
|
||||
await page.viewport(900, 600);
|
||||
const mounted = createTerminalHost({ width: 720, height: 360 });
|
||||
const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
|
||||
|
||||
await waitFor({ predicate: () => mounted.sizes.length > 0 });
|
||||
|
||||
await writeLines(mounted, [
|
||||
hyperlink({ url: "https://example.com/osc8", text: "example" }),
|
||||
"https://example.com/plain",
|
||||
]);
|
||||
|
||||
await clickTerminalLink({ host: mounted.host, row: 1, col: 4 });
|
||||
await clickTerminalLink({ host: mounted.host, row: 0, col: 2 });
|
||||
|
||||
expect(mounted.openedUrls).toEqual(["https://example.com/plain", "https://example.com/osc8"]);
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -434,10 +434,6 @@ export class TerminalEmulatorRuntime {
|
||||
this.inputModeTracker.reset();
|
||||
this.emitInputModeChange();
|
||||
|
||||
const openExternalLink = (event: MouseEvent, uri: string) => {
|
||||
event.preventDefault();
|
||||
void this.callbacks.onOpenExternalUrl?.(uri);
|
||||
};
|
||||
const terminal = new Terminal({
|
||||
allowProposedApi: true,
|
||||
convertEol: false,
|
||||
@@ -445,8 +441,6 @@ export class TerminalEmulatorRuntime {
|
||||
cursorStyle: "bar",
|
||||
fontFamily: resolveTerminalFontFamily(input.fontFamily),
|
||||
fontSize: resolveTerminalFontSize(input.fontSize),
|
||||
// OSC 8 hyperlinks; without a handler xterm prompts and calls window.open().
|
||||
linkHandler: { activate: openExternalLink },
|
||||
lineHeight: 1.0,
|
||||
macOptionIsMeta: true,
|
||||
minimumContrastRatio: 1,
|
||||
@@ -463,7 +457,12 @@ export class TerminalEmulatorRuntime {
|
||||
let imageAddon: ImageAddon | null = null;
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.loadAddon(unicode11Addon);
|
||||
terminal.loadAddon(new WebLinksAddon(openExternalLink));
|
||||
terminal.loadAddon(
|
||||
new WebLinksAddon((event, uri) => {
|
||||
event.preventDefault();
|
||||
void this.callbacks.onOpenExternalUrl?.(uri);
|
||||
}),
|
||||
);
|
||||
const localFileLinkProvider = terminal.registerLinkProvider(
|
||||
createTerminalLocalFileLinkProvider(terminal, {
|
||||
resolveLink: async (source) => {
|
||||
|
||||
@@ -108,17 +108,6 @@ describe("formatMessageTimestamp", () => {
|
||||
expect(formatted).toMatch(/10:12 PM|22:12/);
|
||||
});
|
||||
|
||||
it("shows the full date for last week's same weekday, even under seven days ago", () => {
|
||||
// 2026-09-18 is a Friday. The reply is 6 days 23h45m old and lands on today's weekday,
|
||||
// so a weekday label would read as today.
|
||||
const now = new Date(2026, 8, 25, 11, 47);
|
||||
const date = new Date(2026, 8, 18, 12, 2);
|
||||
const formatted = formatMessageTimestamp(date, now);
|
||||
expect(formatted).toMatch(/Sep|September/);
|
||||
expect(formatted).toMatch(/18/);
|
||||
expect(formatted).not.toMatch(/Friday/);
|
||||
});
|
||||
|
||||
it("includes full date for older timestamps", () => {
|
||||
const now = new Date(2026, 4, 14, 17, 30);
|
||||
const date = new Date(2026, 3, 1, 9, 5);
|
||||
|
||||
@@ -91,14 +91,12 @@ export function formatCompactTimeAgo(date: Date, now: Date = new Date()): string
|
||||
return describeCompactTimeAgo(date, now).label;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many local midnights lie between two instants: 0 for the same day, 1 for yesterday.
|
||||
* Counted on the calendar rather than in elapsed time, so six days and 23 hours ago on
|
||||
* today's weekday is 7, and rounded so a DST day of 23 or 25 hours still counts as one.
|
||||
*/
|
||||
function localCalendarDaysBetween(earlier: Date, later: Date): number {
|
||||
const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
return Math.round((startOfDay(later).getTime() - startOfDay(earlier).getTime()) / DAY_MS);
|
||||
function isSameLocalDay(a: Date, b: Date): boolean {
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
// Cached Intl formatter. Explicitly carrying `hourCycle` from the resolved
|
||||
@@ -122,18 +120,19 @@ function getTimeFormatter(): Intl.DateTimeFormat {
|
||||
/**
|
||||
* Format a chat-message timestamp for hover-revealed UI.
|
||||
* - Same day: "10:11 PM" or "22:11" depending on user preference
|
||||
* - The previous 6 calendar days: "Wednesday 10:11 PM"
|
||||
* - Older, including today's weekday last week: "14 May 2026, 10:11 PM"
|
||||
* - Within ~6 days: "Wednesday 10:11 PM"
|
||||
* - Older: "14 May 2026, 10:11 PM"
|
||||
*/
|
||||
export function formatMessageTimestamp(date: Date, now: Date = new Date()): string {
|
||||
const time = getTimeFormatter().format(date);
|
||||
const daysAgo = localCalendarDaysBetween(date, now);
|
||||
|
||||
if (daysAgo === 0) {
|
||||
if (isSameLocalDay(date, now)) {
|
||||
return time;
|
||||
}
|
||||
|
||||
if (daysAgo > 0 && daysAgo < 7) {
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
if (diffDays >= 0 && diffDays < 7) {
|
||||
const weekday = date.toLocaleDateString(undefined, { weekday: "long" });
|
||||
return `${weekday} ${time}`;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client";
|
||||
import {
|
||||
resolveExistingRunWorkspace,
|
||||
resolveRunCallerAgentId,
|
||||
@@ -9,51 +8,13 @@ import {
|
||||
|
||||
const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" };
|
||||
|
||||
// Answers fetchAgent the way a daemon does: an unknown id is an error.
|
||||
function daemonWithAgents(...agentIds: string[]) {
|
||||
return {
|
||||
async fetchAgent({ agentId }: { agentId: string }) {
|
||||
if (!agentIds.includes(agentId)) {
|
||||
throw new Error(`Agent not found: ${agentId}`);
|
||||
}
|
||||
return { agent: { id: agentId } };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("managed agent caller context", () => {
|
||||
it("uses a trimmed PASEO_AGENT_ID when the target daemon runs that agent", async () => {
|
||||
await expect(
|
||||
resolveRunCallerAgentId(daemonWithAgents("parent-agent"), {
|
||||
PASEO_AGENT_ID: " parent-agent ",
|
||||
}),
|
||||
).resolves.toBe("parent-agent");
|
||||
it("propagates a trimmed PASEO_AGENT_ID", () => {
|
||||
expect(resolveRunCallerAgentId({ PASEO_AGENT_ID: " parent-agent " })).toBe("parent-agent");
|
||||
});
|
||||
|
||||
it("runs without a caller when PASEO_AGENT_ID belongs to another daemon", async () => {
|
||||
await expect(
|
||||
resolveRunCallerAgentId(daemonWithAgents("other-agent"), {
|
||||
PASEO_AGENT_ID: "parent-agent",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("fails instead of dropping the caller when the lookup loses its connection", async () => {
|
||||
const disconnectedDaemon = {
|
||||
async fetchAgent(): Promise<never> {
|
||||
throw new DaemonConnectionError("Connection lost before message could be sent");
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
resolveRunCallerAgentId(disconnectedDaemon, { PASEO_AGENT_ID: "parent-agent" }),
|
||||
).rejects.toBeInstanceOf(DaemonConnectionError);
|
||||
});
|
||||
|
||||
it("omits blank caller ids", async () => {
|
||||
await expect(
|
||||
resolveRunCallerAgentId(daemonWithAgents(), { PASEO_AGENT_ID: " " }),
|
||||
).resolves.toBeUndefined();
|
||||
it("omits blank caller ids", () => {
|
||||
expect(resolveRunCallerAgentId({ PASEO_AGENT_ID: " " })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
StructuredAgentResponseError,
|
||||
} from "@getpaseo/server/agent-response";
|
||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||
import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import type {
|
||||
CommandOptions,
|
||||
@@ -527,7 +526,7 @@ export async function resolveExistingRunWorkspace(
|
||||
|
||||
// Workspace policy for `paseo run`. Precedence:
|
||||
// 1. --workspace <id> -> run in that existing workspace
|
||||
// 2. caller agent -> daemon resolves the caller's workspace
|
||||
// 2. $PASEO_AGENT_ID -> daemon resolves the caller's workspace
|
||||
// 3. $PASEO_WORKSPACE_ID -> exported by workspace terminals
|
||||
// 4. --new-workspace <kind> -> mint a new workspace explicitly
|
||||
// 5. bare run -> mint a new local-backed workspace for cwd
|
||||
@@ -535,7 +534,6 @@ async function resolveRunWorkspace(
|
||||
client: ConnectedDaemonClient,
|
||||
options: AgentRunOptions,
|
||||
cwd: string,
|
||||
callerAgentId: string | undefined,
|
||||
): Promise<RunWorkspace> {
|
||||
const newWorkspace = resolveNewWorkspaceKind(options);
|
||||
const explicit = newWorkspace ? undefined : options.workspace?.trim();
|
||||
@@ -544,7 +542,7 @@ async function resolveRunWorkspace(
|
||||
return resolveExistingRunWorkspace(client, explicit);
|
||||
}
|
||||
|
||||
if (!newWorkspace && callerAgentId) {
|
||||
if (!newWorkspace && resolveRunCallerAgentId()) {
|
||||
return { cwd };
|
||||
}
|
||||
|
||||
@@ -611,9 +609,9 @@ export async function runRunCommand(
|
||||
const env = parseRunEnv(options.env);
|
||||
const requestEnv = Object.keys(env).length > 0 ? env : undefined;
|
||||
|
||||
const callerAgentId = await resolveRunCallerAgentId(client);
|
||||
const workspace = await resolveRunWorkspace(client, options, cwd, callerAgentId);
|
||||
const workspace = await resolveRunWorkspace(client, options, cwd);
|
||||
const workspaceId = workspace.id;
|
||||
const callerAgentId = resolveRunCallerAgentId();
|
||||
const runCwd = workspace.cwd;
|
||||
|
||||
if (outputSchema) {
|
||||
@@ -745,28 +743,8 @@ export async function runRunCommand(
|
||||
}
|
||||
}
|
||||
|
||||
export interface RunCallerLookupClient {
|
||||
fetchAgent(options: { agentId: string }): Promise<{ agent: { id: string } } | null>;
|
||||
}
|
||||
|
||||
// PASEO_AGENT_ID names an agent on the daemon that launched this shell. A run
|
||||
// sent to another daemon (--host or --home) has no caller there, so it runs as
|
||||
// a top-level agent instead of failing on an unknown caller.
|
||||
export async function resolveRunCallerAgentId(
|
||||
client: RunCallerLookupClient,
|
||||
export function resolveRunCallerAgentId(
|
||||
env: { PASEO_AGENT_ID?: string } = process.env,
|
||||
): Promise<string | undefined> {
|
||||
const agentId = env.PASEO_AGENT_ID?.trim();
|
||||
if (!agentId) {
|
||||
return undefined;
|
||||
}
|
||||
const caller = await client.fetchAgent({ agentId }).catch((error: unknown) => {
|
||||
// A daemon without this agent answers with an error. A lost or timed-out
|
||||
// connection is not an answer, so it must not drop the caller.
|
||||
if (error instanceof DaemonConnectionError) {
|
||||
throw error;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
return caller?.agent.id === agentId ? agentId : undefined;
|
||||
): string | undefined {
|
||||
return env.PASEO_AGENT_ID?.trim() || undefined;
|
||||
}
|
||||
|
||||
@@ -57,17 +57,6 @@ function createCommandError(code: string, message: string, details?: string): Co
|
||||
return { code, message, ...(details ? { details } : {}) };
|
||||
}
|
||||
|
||||
function terminalPasswordPrompt(): PromptPassword {
|
||||
if (!process.stdin.isTTY) {
|
||||
throw createCommandError(
|
||||
"PASSWORD_TTY_REQUIRED",
|
||||
"paseo daemon set-password needs a terminal to read the password",
|
||||
"Run it in an interactive terminal, or set PASEO_PASSWORD in the daemon's environment instead.",
|
||||
);
|
||||
}
|
||||
return (message) => passwordPrompt({ message });
|
||||
}
|
||||
|
||||
async function promptForPassword(promptPassword: PromptPassword): Promise<string> {
|
||||
const first = await promptPassword("New daemon password");
|
||||
if (isCancel(first)) {
|
||||
@@ -123,7 +112,7 @@ export async function runSetPasswordCommand(
|
||||
const promptPassword =
|
||||
typeof options.promptPassword === "function"
|
||||
? (options.promptPassword as PromptPassword)
|
||||
: terminalPasswordPrompt();
|
||||
: (message: string) => passwordPrompt({ message });
|
||||
const newPassword = await promptForPassword(promptPassword);
|
||||
const result = await setDaemonPasswordInConfig(newPassword, {
|
||||
home: options.daemonTarget.kind === "instance" ? options.daemonTarget.home : undefined,
|
||||
|
||||
@@ -6,7 +6,6 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Command } from "commander";
|
||||
import { isBearerTokenValid } from "@getpaseo/server/auth";
|
||||
import { runLocalPaseo } from "./helpers/local-cli.ts";
|
||||
import {
|
||||
runSetPasswordCommand,
|
||||
setDaemonPasswordInConfig,
|
||||
@@ -106,27 +105,6 @@ try {
|
||||
);
|
||||
console.log("✓ command refuses password mismatch\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 4: piped stdin fails with a message instead of exiting silently");
|
||||
const pipedHome = join(root, "piped");
|
||||
await mkdir(pipedHome, { recursive: true });
|
||||
const run = runLocalPaseo(["daemon", "set-password", "--home", pipedHome, "--json"]);
|
||||
run.stdin.end("x\nx\n");
|
||||
const result = await run;
|
||||
|
||||
assert.strictEqual(result.exitCode, 1, result.stderr);
|
||||
const error = JSON.parse(result.stderr).error as {
|
||||
code: string;
|
||||
message: string;
|
||||
details: string;
|
||||
};
|
||||
assert.strictEqual(error.code, "PASSWORD_TTY_REQUIRED");
|
||||
assert.match(error.message, /needs a terminal/);
|
||||
assert.match(error.details, /PASEO_PASSWORD/);
|
||||
await assert.rejects(readFile(join(pipedHome, "config.json"), "utf-8"));
|
||||
console.log("✓ piped stdin reports that a terminal is required\n");
|
||||
}
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -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\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\"",
|
||||
"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: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,7 +115,6 @@
|
||||
"@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",
|
||||
|
||||
@@ -4,27 +4,22 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
import { build } from "esbuild";
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
@@ -23,10 +23,6 @@ async function runSupervisorFixture(options: {
|
||||
workerSource: string;
|
||||
restartOnCrash?: boolean;
|
||||
timeoutMs?: number;
|
||||
/** POSIX RLIMIT_FSIZE for the supervisor, in 512-byte blocks: writes past it fail like a full disk. */
|
||||
fileSizeLimitBlocks?: number;
|
||||
/** Occupy the log path with a directory so the supervisor cannot open daemon.log. */
|
||||
blockLogPath?: boolean;
|
||||
}): Promise<{
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
@@ -40,9 +36,6 @@ async function runSupervisorFixture(options: {
|
||||
const workerPath = path.join(tempDir, "worker.mjs");
|
||||
const runnerPath = path.join(tempDir, "runner.mjs");
|
||||
|
||||
if (options.blockLogPath) {
|
||||
await mkdir(logPath);
|
||||
}
|
||||
await writeFile(
|
||||
workerPath,
|
||||
`process.send?.({ type: "paseo:ready", listen: "fixture", serverId: "srv_fixture" });\n${options.workerSource}`,
|
||||
@@ -69,18 +62,7 @@ async function runSupervisorFixture(options: {
|
||||
);
|
||||
|
||||
const startedAt = Date.now();
|
||||
const runnerArgs = [process.execPath, "--import", "tsx", runnerPath];
|
||||
const [command, ...args] =
|
||||
options.fileSizeLimitBlocks === undefined
|
||||
? runnerArgs
|
||||
: [
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
`ulimit -f ${options.fileSizeLimitBlocks} && exec "$@"`,
|
||||
"sh",
|
||||
...runnerArgs,
|
||||
];
|
||||
const child = spawn(command, args, {
|
||||
const child = spawn(process.execPath, ["--import", "tsx", runnerPath], {
|
||||
cwd: repoRoot,
|
||||
env: { ...process.env },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
@@ -366,55 +348,4 @@ describe("supervisor durable logging", () => {
|
||||
expect(result.log).toContain("Supervisor exiting");
|
||||
},
|
||||
);
|
||||
|
||||
// POSIX-only: RLIMIT_FSIZE is how the test makes daemon.log writes fail.
|
||||
test.skipIf(isPlatform("win32"))(
|
||||
"keeps supervising the worker when daemon.log can no longer be written",
|
||||
async () => {
|
||||
const result = await runSupervisorFixture({
|
||||
fileSizeLimitBlocks: 128,
|
||||
workerSource: `
|
||||
process.on("message", (message) => {
|
||||
if (message?.type === "paseo:graceful-shutdown") process.exit(0);
|
||||
});
|
||||
const line = '{"level":30,"msg":"' + "x".repeat(1000) + '"}\\n';
|
||||
for (let i = 0; i < 200; i += 1) process.stdout.write(line);
|
||||
setTimeout(() => {
|
||||
process.send?.({ type: "paseo:shutdown", reason: "log_write_failure_probe" });
|
||||
}, 1000);
|
||||
setInterval(() => {}, 1000);
|
||||
`,
|
||||
});
|
||||
|
||||
expect(result.stderr).not.toContain("Unhandled 'error' event");
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.signal).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
test("resumes writing daemon.log once the log path is writable again", async () => {
|
||||
const result = await runSupervisorFixture({
|
||||
blockLogPath: true,
|
||||
workerSource: `
|
||||
import { rmdirSync } from "node:fs";
|
||||
|
||||
process.on("message", (message) => {
|
||||
if (message?.type === "paseo:graceful-shutdown") process.exit(0);
|
||||
});
|
||||
process.stdout.write("line while daemon.log is blocked\\n");
|
||||
setTimeout(() => {
|
||||
rmdirSync(process.argv[1].replace(/worker\\.mjs$/, "daemon.log"));
|
||||
process.stdout.write("line after daemon.log is writable\\n");
|
||||
setTimeout(() => {
|
||||
process.send?.({ type: "paseo:shutdown", reason: "log_recovery_probe" });
|
||||
}, 200);
|
||||
}, 500);
|
||||
setInterval(() => {}, 1000);
|
||||
`,
|
||||
});
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.log).toContain("line after daemon.log is writable\n");
|
||||
expect(result.log).toContain('"reason":"log_recovery_probe"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { fork, spawn, type ChildProcess } from "child_process";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
createStream as createRotatingFileStream,
|
||||
type RotatingFileStream,
|
||||
} from "rotating-file-stream";
|
||||
import { createStream as createRotatingFileStream } from "rotating-file-stream";
|
||||
import { signalProcessTree } from "../src/utils/tree-kill.js";
|
||||
|
||||
const WORKER_HEARTBEAT_INTERVAL_MS = 1_000;
|
||||
@@ -112,58 +110,17 @@ function toRotatingFileStreamSize(size: string): string {
|
||||
return `${value}${unit}`;
|
||||
}
|
||||
|
||||
// daemon.log is best effort: a full disk or a replaced log path must not take the
|
||||
// supervisor, and with it the worker, down. A failed stream is dropped and a new one
|
||||
// is opened on the next write, so logging resumes once the file is writable again.
|
||||
function createDurableLog(
|
||||
options: SupervisorLogFileOptions | undefined,
|
||||
reportFailure: (message: string) => void,
|
||||
) {
|
||||
let stream: RotatingFileStream | null = null;
|
||||
let failing = false;
|
||||
function createSupervisorLogStream(options: SupervisorLogFileOptions | undefined) {
|
||||
if (!options) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const open = (logFile: SupervisorLogFileOptions): RotatingFileStream => {
|
||||
const next = createRotatingFileStream(path.basename(logFile.path), {
|
||||
path: path.dirname(logFile.path),
|
||||
size: toRotatingFileStreamSize(logFile.rotate.maxSize),
|
||||
maxFiles: logFile.rotate.maxFiles,
|
||||
});
|
||||
next.on("error", (error) => {
|
||||
if (stream === next) {
|
||||
stream = null;
|
||||
}
|
||||
if (!failing) {
|
||||
failing = true;
|
||||
reportFailure(
|
||||
`Cannot write ${logFile.path}, will retry on the next log line: ${error.message}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
return next;
|
||||
};
|
||||
|
||||
return {
|
||||
write(chunk: string | Buffer): void {
|
||||
if (!options) {
|
||||
return;
|
||||
}
|
||||
stream ??= open(options);
|
||||
stream.write(chunk, (error) => {
|
||||
if (!error) {
|
||||
failing = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
close(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (!stream) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
stream.end(() => resolve());
|
||||
});
|
||||
},
|
||||
};
|
||||
mkdirSync(path.dirname(options.path), { recursive: true });
|
||||
return createRotatingFileStream(path.basename(options.path), {
|
||||
path: path.dirname(options.path),
|
||||
size: toRotatingFileStreamSize(options.rotate.maxSize),
|
||||
maxFiles: options.rotate.maxFiles,
|
||||
});
|
||||
}
|
||||
|
||||
export function runSupervisor(options: SupervisorOptions): SupervisorController {
|
||||
@@ -180,12 +137,10 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
|
||||
let lifecycleFailed = false;
|
||||
let publication = Promise.resolve();
|
||||
let forceKillTimer: NodeJS.Timeout | null = null;
|
||||
const durableLog = createDurableLog(options.logFile, (message) => {
|
||||
process.stderr.write(`[${options.name}] ${message}\n`);
|
||||
});
|
||||
const logStream = createSupervisorLogStream(options.logFile);
|
||||
|
||||
const writeDurableChunk = (chunk: string | Buffer): void => {
|
||||
durableLog.write(chunk);
|
||||
logStream?.write(chunk);
|
||||
};
|
||||
|
||||
const writeLifecycleLog = (message: string, fields: Record<string, unknown> = {}): void => {
|
||||
@@ -206,7 +161,14 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
|
||||
writeLifecycleLog(message);
|
||||
};
|
||||
|
||||
const closeLogStream = (): Promise<void> => durableLog.close();
|
||||
const closeLogStream = (): Promise<void> =>
|
||||
new Promise((resolve) => {
|
||||
if (!logStream) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
logStream.end(resolve);
|
||||
});
|
||||
|
||||
const exitSupervisor = (code: number): void => {
|
||||
if (exiting) {
|
||||
|
||||
@@ -11244,129 +11244,3 @@ 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<AgentStreamEvent> {
|
||||
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<AgentStreamEvent> {
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3488,31 +3488,11 @@ 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, {
|
||||
@@ -4043,9 +4023,6 @@ export class AgentManager {
|
||||
private async primeTimelineFromLegacyProviderHistory(
|
||||
agent: ActiveManagedAgent,
|
||||
broadcast: boolean | (() => boolean),
|
||||
history:
|
||||
| AsyncIterable<AgentStreamEvent>
|
||||
| Iterable<AgentStreamEvent> = agent.session.streamHistory(),
|
||||
): Promise<void> {
|
||||
const deferredBroadcast = typeof broadcast === "function";
|
||||
const historyEvents: Extract<AgentStreamEvent, { type: "timeline" }>[] = [];
|
||||
@@ -4055,7 +4032,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 history) {
|
||||
for await (const rawEvent of agent.session.streamHistory()) {
|
||||
const event = limitAgentStreamEventContent(rawEvent);
|
||||
if (event.type === "provider_subagent") {
|
||||
historySubagentEvents.push(event);
|
||||
|
||||
@@ -425,11 +425,6 @@ interface NotifySafelyOptions {
|
||||
permissionRequest?: AgentPermissionRequest;
|
||||
}
|
||||
|
||||
// A caller waits on a child through one armed notification. Arming again, such as a
|
||||
// follow-up prompt while the child still runs, replaces the earlier one so the child's
|
||||
// next finish reaches the caller once.
|
||||
const armedFinishNotifications = new WeakMap<AgentManager, Map<string, () => void>>();
|
||||
|
||||
export function setupFinishNotification(params: SetupFinishNotificationParams): void {
|
||||
const {
|
||||
agentManager,
|
||||
@@ -445,19 +440,10 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
let notificationQueue = Promise.resolve();
|
||||
|
||||
const armedByManager = armedFinishNotifications.get(agentManager) ?? new Map();
|
||||
armedFinishNotifications.set(agentManager, armedByManager);
|
||||
const armedKey = JSON.stringify([childAgentId, callerAgentId]);
|
||||
armedByManager.get(armedKey)?.();
|
||||
armedByManager.set(armedKey, stop);
|
||||
|
||||
function stop(): void {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
unsubscribe?.();
|
||||
if (armedByManager.get(armedKey) === stop) {
|
||||
armedByManager.delete(armedKey);
|
||||
}
|
||||
}
|
||||
|
||||
async function notify(
|
||||
|
||||
@@ -665,9 +665,6 @@ 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<AgentRunResult>;
|
||||
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
|
||||
steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise<SteerResult>;
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { realpathSync } from "node:fs";
|
||||
import { realpathSync, rmSync } from "node:fs";
|
||||
import { access, mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join, resolve as resolvePath } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -14,16 +13,7 @@ import { createAgentMcpServer } from "./mcp-server.js";
|
||||
import { AgentManager, type ManagedAgent } from "./agent-manager.js";
|
||||
import { AgentStorage, type StoredAgentRecord } from "./agent-storage.js";
|
||||
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
|
||||
import type {
|
||||
AgentClient,
|
||||
AgentMode,
|
||||
AgentPromptInput,
|
||||
AgentProvider,
|
||||
AgentRunResult,
|
||||
AgentSession,
|
||||
AgentStreamEvent,
|
||||
ProviderSnapshotEntry,
|
||||
} from "./agent-sdk-types.js";
|
||||
import type { AgentMode, AgentProvider, ProviderSnapshotEntry } from "./agent-sdk-types.js";
|
||||
import type { ProviderSnapshotManager } from "./provider-snapshot-manager.js";
|
||||
import { createProviderSnapshotManagerStub } from "../test-utils/session-stubs.js";
|
||||
import {
|
||||
@@ -192,16 +182,6 @@ async function removeTempDir(path: string): Promise<void> {
|
||||
await rm(path, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
}
|
||||
|
||||
async function removeAgentStateDir(
|
||||
agentManager: AgentManager,
|
||||
storage: AgentStorage,
|
||||
path: string,
|
||||
): Promise<void> {
|
||||
await agentManager.flush();
|
||||
await storage.flush();
|
||||
await removeTempDir(path);
|
||||
}
|
||||
|
||||
type AgentManagerSpies = ReturnType<typeof buildAgentManagerSpies>;
|
||||
type AgentStorageSpies = ReturnType<typeof buildAgentStorageSpies>;
|
||||
|
||||
@@ -3430,7 +3410,7 @@ describe("create_agent MCP tool", () => {
|
||||
expect(storedChild?.workspaceId).toBe("wks_parent");
|
||||
expect(storedChild?.labels[PARENT_AGENT_ID_LABEL]).toBe(parent.id);
|
||||
} finally {
|
||||
await removeAgentStateDir(agentManager, storage, workdir);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3684,142 +3664,6 @@ describe("create_agent MCP tool", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const HELD_TURN_CAPABILITIES = {
|
||||
supportsStreaming: false,
|
||||
supportsSessionPersistence: false,
|
||||
supportsSessionListing: false,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: false,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Provider session that records every prompt it receives. With `holdTurns`, a started
|
||||
* turn stays running until `finishTurn()` so a caller's bounded wait can run out first.
|
||||
*/
|
||||
class HeldTurnAgentSession implements AgentSession {
|
||||
readonly capabilities = HELD_TURN_CAPABILITIES;
|
||||
readonly id = randomUUID();
|
||||
readonly prompts: string[] = [];
|
||||
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
||||
private activeTurnId: string | null = null;
|
||||
|
||||
constructor(
|
||||
readonly provider: AgentProvider,
|
||||
private readonly holdTurns: boolean,
|
||||
) {}
|
||||
|
||||
async run(): Promise<AgentRunResult> {
|
||||
return { sessionId: this.id, finalText: "", timeline: [] };
|
||||
}
|
||||
|
||||
async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
|
||||
this.prompts.push(typeof prompt === "string" ? prompt : JSON.stringify(prompt));
|
||||
const turnId = randomUUID();
|
||||
this.activeTurnId = turnId;
|
||||
setTimeout(() => {
|
||||
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
||||
if (!this.holdTurns && this.activeTurnId === turnId) {
|
||||
this.finishTurn();
|
||||
}
|
||||
}, 0);
|
||||
return { turnId };
|
||||
}
|
||||
|
||||
finishTurn(): void {
|
||||
const turnId = this.activeTurnId;
|
||||
if (!turnId) {
|
||||
throw new Error("No held turn to finish");
|
||||
}
|
||||
this.activeTurnId = null;
|
||||
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
|
||||
}
|
||||
|
||||
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
|
||||
this.subscribers.add(callback);
|
||||
return () => {
|
||||
this.subscribers.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
private pushEvent(event: AgentStreamEvent): void {
|
||||
for (const callback of this.subscribers) {
|
||||
callback(event);
|
||||
}
|
||||
}
|
||||
|
||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {}
|
||||
|
||||
async getRuntimeInfo() {
|
||||
return { provider: this.provider, sessionId: this.id, model: null, modeId: null };
|
||||
}
|
||||
|
||||
async getAvailableModes() {
|
||||
return [];
|
||||
}
|
||||
|
||||
async getCurrentMode() {
|
||||
return null;
|
||||
}
|
||||
|
||||
async setMode(): Promise<void> {}
|
||||
|
||||
getPendingPermissions() {
|
||||
return [];
|
||||
}
|
||||
|
||||
async respondToPermission(): Promise<void> {}
|
||||
|
||||
describePersistence() {
|
||||
return { provider: this.provider, sessionId: this.id };
|
||||
}
|
||||
|
||||
async interrupt(): Promise<void> {
|
||||
const turnId = this.activeTurnId;
|
||||
if (!turnId) {
|
||||
return;
|
||||
}
|
||||
this.activeTurnId = null;
|
||||
this.pushEvent({
|
||||
type: "turn_canceled",
|
||||
provider: this.provider,
|
||||
reason: "interrupted",
|
||||
turnId,
|
||||
});
|
||||
}
|
||||
|
||||
async close(): Promise<void> {}
|
||||
}
|
||||
|
||||
class HeldTurnAgentClient implements AgentClient {
|
||||
readonly capabilities = HELD_TURN_CAPABILITIES;
|
||||
readonly sessions: HeldTurnAgentSession[] = [];
|
||||
|
||||
constructor(
|
||||
readonly provider: AgentProvider,
|
||||
private readonly holdTurns: boolean,
|
||||
) {}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
async createSession(): Promise<AgentSession> {
|
||||
const session = new HeldTurnAgentSession(this.provider, this.holdTurns);
|
||||
this.sessions.push(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
async fetchCatalog() {
|
||||
return { models: [], modes: [] };
|
||||
}
|
||||
|
||||
async resumeSession(): Promise<AgentSession> {
|
||||
return await this.createSession();
|
||||
}
|
||||
}
|
||||
|
||||
describe("send_agent_prompt MCP tool", () => {
|
||||
const logger = createTestLogger();
|
||||
const existingCwd = process.cwd();
|
||||
@@ -3963,128 +3807,6 @@ describe("send_agent_prompt MCP tool", () => {
|
||||
expect.objectContaining({ waitForActive: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it("notifies the caller when a blocking agent-scoped prompt outlasts the wait", async () => {
|
||||
const workdir = await mkdtemp(join(tmpdir(), "mcp-blocking-send-timeout-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const parentClient = new HeldTurnAgentClient("claude", false);
|
||||
const childClient = new HeldTurnAgentClient("codex", true);
|
||||
const agentManager = new AgentManager({
|
||||
clients: { claude: parentClient, codex: childClient },
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
|
||||
try {
|
||||
const parent = await agentManager.createAgent(
|
||||
{ provider: "claude", cwd: existingCwd },
|
||||
undefined,
|
||||
{ workspaceId: "wks_parent" },
|
||||
);
|
||||
const child = await agentManager.createAgent(
|
||||
{ provider: "codex", cwd: existingCwd },
|
||||
undefined,
|
||||
{ workspaceId: "wks_parent" },
|
||||
);
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage: storage,
|
||||
callerAgentId: parent.id,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "send_agent_prompt");
|
||||
|
||||
vi.useFakeTimers();
|
||||
const pending = invokeToolWithParsedInput(tool, {
|
||||
agentId: child.id,
|
||||
prompt: "Follow up",
|
||||
background: false,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(31_000);
|
||||
const response = await pending;
|
||||
expect(response.structuredContent).toMatchObject({
|
||||
status: "running",
|
||||
guidance:
|
||||
"You will get notified when the prompted agent finishes, errors, or needs permission. Do not poll for status; continue with other work until the notification arrives.",
|
||||
});
|
||||
expect(response.structuredContent.lastMessage).toContain("timed out after 30s");
|
||||
|
||||
childClient.sessions[0]!.finishTurn();
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
vi.useRealTimers();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const parentPrompts = parentClient.sessions[0]!.prompts;
|
||||
expect(parentPrompts).toHaveLength(1);
|
||||
expect(parentPrompts[0]).toContain(child.id);
|
||||
expect(parentPrompts[0]).toContain("finished");
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
await removeAgentStateDir(agentManager, storage, workdir);
|
||||
}
|
||||
});
|
||||
it("notifies the caller once when it prompts a created child that is still running", async () => {
|
||||
const workdir = await mkdtemp(join(tmpdir(), "mcp-send-running-child-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const parentClient = new HeldTurnAgentClient("claude", false);
|
||||
const childClient = new HeldTurnAgentClient("codex", true);
|
||||
const agentManager = new AgentManager({
|
||||
clients: { claude: parentClient, codex: childClient },
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
|
||||
try {
|
||||
const parent = await agentManager.createAgent(
|
||||
{ provider: "claude", cwd: existingCwd },
|
||||
undefined,
|
||||
{ workspaceId: "wks_parent" },
|
||||
);
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage: storage,
|
||||
callerAgentId: parent.id,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
logger,
|
||||
});
|
||||
|
||||
const created = await invokeToolWithParsedInput(registeredTool(server, "create_agent"), {
|
||||
relationship: { kind: "subagent" },
|
||||
workspace: { kind: "current" },
|
||||
title: "Busy Child",
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "Run a long command",
|
||||
});
|
||||
const childId = z.object({ agentId: z.string() }).parse(created.structuredContent).agentId;
|
||||
await vi.waitFor(() => expect(agentManager.getAgent(childId)?.lifecycle).toBe("running"));
|
||||
|
||||
const sent = await invokeToolWithParsedInput(registeredTool(server, "send_agent_prompt"), {
|
||||
agentId: childId,
|
||||
prompt: "Stop and reply instead",
|
||||
});
|
||||
expect(sent.structuredContent).toMatchObject({
|
||||
success: true,
|
||||
status: "running",
|
||||
});
|
||||
const childSession = childClient.sessions[0]!;
|
||||
await vi.waitFor(() => expect(childSession.prompts).toHaveLength(2));
|
||||
|
||||
childSession.finishTurn();
|
||||
|
||||
function finishNotifications() {
|
||||
return (parentClient.sessions[0]?.prompts ?? []).filter((prompt) =>
|
||||
prompt.includes(`Agent ${childId} (Busy Child) finished.`),
|
||||
);
|
||||
}
|
||||
await vi.waitFor(() => expect(finishNotifications()).not.toHaveLength(0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(finishNotifications()).toHaveLength(1);
|
||||
} finally {
|
||||
await removeAgentStateDir(agentManager, storage, workdir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("update_agent MCP tool", () => {
|
||||
|
||||
@@ -86,11 +86,6 @@ export function resolveRequiredProviderModel(
|
||||
};
|
||||
}
|
||||
|
||||
export interface AgentWaitWithTimeoutResult extends WaitForAgentResult {
|
||||
/** The wait gave up while the agent was still working. */
|
||||
timedOut: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps agentManager.waitForAgentEvent with a self-imposed timeout.
|
||||
* Returns a friendly message when timeout occurs, rather than letting
|
||||
@@ -103,7 +98,7 @@ export async function waitForAgentWithTimeout(
|
||||
signal?: AbortSignal;
|
||||
waitForActive?: boolean;
|
||||
},
|
||||
): Promise<AgentWaitWithTimeoutResult> {
|
||||
): Promise<WaitForAgentResult> {
|
||||
const timeoutController = new AbortController();
|
||||
const combinedController = new AbortController();
|
||||
|
||||
@@ -138,7 +133,7 @@ export async function waitForAgentWithTimeout(
|
||||
signal: combinedController.signal,
|
||||
waitForActive: options?.waitForActive,
|
||||
});
|
||||
return { ...result, timedOut: false };
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "wait timeout") {
|
||||
const snapshot = agentManager.getAgent(agentId);
|
||||
@@ -155,7 +150,6 @@ export async function waitForAgentWithTimeout(
|
||||
status: snapshot?.lifecycle ?? "idle",
|
||||
permission: null,
|
||||
lastMessage: message,
|
||||
timedOut: true,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
|
||||
@@ -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 { OpenCodeRuntimeClient } from "./providers/opencode/runtime-client.js";
|
||||
import { OpenCodeAgentClient } from "./providers/opencode-agent.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<string, ProviderClientFactory> = {
|
||||
env: runtimeSettings?.env,
|
||||
}),
|
||||
opencode: (logger, runtimeSettings, options) =>
|
||||
new OpenCodeRuntimeClient(logger, runtimeSettings, {
|
||||
new OpenCodeAgentClient(logger, runtimeSettings, {
|
||||
managedProcesses: options?.managedProcesses,
|
||||
bridge: options?.openCodeBridge,
|
||||
}),
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
AgentSideConnection,
|
||||
@@ -3524,34 +3521,6 @@ describe("ACPAgentSession close() tree-kill", () => {
|
||||
});
|
||||
|
||||
describe("ACPAgentSession initialization cleanup", () => {
|
||||
test("rejects a resume whose working directory was deleted instead of crashing", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-acp-deleted-cwd-"));
|
||||
const deletedCwd = path.join(root, "worktree");
|
||||
const terminator = new FakeTerminator();
|
||||
const session = new ACPAgentSession(
|
||||
{ provider: "test-acp", cwd: deletedCwd },
|
||||
{
|
||||
provider: "test-acp",
|
||||
logger: createTestLogger(),
|
||||
defaultCommand: [process.execPath, "-e", "process.stdin.resume()"],
|
||||
defaultModes: [],
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
},
|
||||
handle: { provider: "test-acp", sessionId: "archived-session" },
|
||||
terminateProcess: terminator.terminate,
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
await expect(session.initializeResumedSession()).rejects.toThrow("ENOENT");
|
||||
expect(terminator.terminated).toHaveLength(1);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("terminates the ACP process when session/new fails", async () => {
|
||||
const terminator = new FakeTerminator();
|
||||
const child = createProbeChildStub();
|
||||
|
||||
@@ -136,17 +136,6 @@ function assertChildWithPipes(
|
||||
}
|
||||
}
|
||||
|
||||
// Node reports a failed spawn (missing binary or missing cwd) as a child
|
||||
// `error` event; without a listener it becomes an uncaught exception.
|
||||
function rejectOnSpawnError(child: ChildProcess, stderrChunks: string[]): Promise<never> {
|
||||
return new Promise<never>((_, reject) => {
|
||||
child.once("error", (error) => {
|
||||
const stderr = stderrChunks.join("").trim();
|
||||
reject(new Error(stderr ? `${String(error)}\n${stderr}` : String(error)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -1365,7 +1354,12 @@ export class ACPAgentClient implements AgentClient {
|
||||
stderrChunks.push(chunk.toString());
|
||||
});
|
||||
|
||||
const spawnErrorPromise = rejectOnSpawnError(child, stderrChunks);
|
||||
const spawnErrorPromise = new Promise<never>((_, reject) => {
|
||||
child.once("error", (error) => {
|
||||
const stderr = stderrChunks.join("").trim();
|
||||
reject(new Error(stderr ? `${String(error)}\n${stderr}` : String(error)));
|
||||
});
|
||||
});
|
||||
const spawnReadyPromise = new Promise<void>((resolve) => {
|
||||
child.once("spawn", () => {
|
||||
resolve();
|
||||
@@ -2738,7 +2732,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
child.stderr.on("data", (chunk: Buffer | string) => {
|
||||
stderrChunks.push(chunk.toString());
|
||||
});
|
||||
const spawnError = rejectOnSpawnError(child, stderrChunks);
|
||||
child.once("exit", (code, signal) => {
|
||||
if (this.closed) {
|
||||
return;
|
||||
@@ -2766,17 +2759,14 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
this.child = child;
|
||||
this.connection = connection;
|
||||
const initialize = await this.runACPRequest(() =>
|
||||
Promise.race([
|
||||
connection.initialize({
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
clientCapabilities: buildACPClientCapabilities(
|
||||
this.clientCapabilityMeta,
|
||||
this.clientCapabilities,
|
||||
),
|
||||
clientInfo: { name: "Paseo", version: "dev" },
|
||||
}),
|
||||
spawnError,
|
||||
]),
|
||||
connection.initialize({
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
clientCapabilities: buildACPClientCapabilities(
|
||||
this.clientCapabilityMeta,
|
||||
this.clientCapabilities,
|
||||
),
|
||||
clientInfo: { name: "Paseo", version: "dev" },
|
||||
}),
|
||||
);
|
||||
|
||||
return { child, connection, initialize };
|
||||
|
||||
@@ -428,7 +428,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
logger,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
resolveVersion: async () => "2.1.219",
|
||||
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: emptyConfigDir } },
|
||||
configDir: emptyConfigDir,
|
||||
});
|
||||
const { models } = await client.fetchCatalog({
|
||||
scope: "workspace",
|
||||
@@ -475,7 +475,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
resolveVersion: async () => {
|
||||
throw new Error("unrecognized version output");
|
||||
},
|
||||
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: emptyConfigDir } },
|
||||
configDir: emptyConfigDir,
|
||||
});
|
||||
const { models } = await client.fetchCatalog({
|
||||
scope: "workspace",
|
||||
@@ -497,7 +497,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
logger,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
resolveVersion: async () => "2.1.219",
|
||||
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: emptyConfigDir } },
|
||||
configDir: emptyConfigDir,
|
||||
});
|
||||
const { models } = await client.fetchCatalog({
|
||||
scope: "workspace",
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ChildProcess } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import { promises } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
type AgentDefinition,
|
||||
@@ -82,7 +83,7 @@ import {
|
||||
type ClaudeRewindSdk,
|
||||
} from "./rewind.js";
|
||||
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
|
||||
import { claudeConfigDir, claudeProjectDirSync } from "./project-dir.js";
|
||||
import { claudeProjectDirSync } from "./project-dir.js";
|
||||
import { THINKING_APPLIES_NEXT_TURN_NOTICE } from "../../provider-notices.js";
|
||||
import {
|
||||
isProviderImageMarkdown,
|
||||
@@ -408,6 +409,7 @@ interface ClaudeAgentClientOptions {
|
||||
queryFactory?: ClaudeQueryFactory;
|
||||
resolveBinary?: () => Promise<string>;
|
||||
resolveVersion?: (signal?: AbortSignal) => Promise<string>;
|
||||
configDir?: string;
|
||||
rewindSdk?: ClaudeRewindSdk;
|
||||
}
|
||||
|
||||
@@ -1504,6 +1506,7 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
private readonly queryFactory?: ClaudeQueryFactory;
|
||||
private readonly resolveBinary: () => Promise<string>;
|
||||
private readonly resolveVersion: (signal?: AbortSignal) => Promise<string>;
|
||||
private readonly configDir?: string;
|
||||
private readonly rewindSdk: ClaudeRewindSdk;
|
||||
|
||||
constructor(options: ClaudeAgentClientOptions) {
|
||||
@@ -1515,6 +1518,7 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
this.resolveVersion =
|
||||
options.resolveVersion ??
|
||||
((signal) => resolveClaudeCodeVersion(this.runtimeSettings, signal));
|
||||
this.configDir = options.configDir;
|
||||
this.rewindSdk = options.rewindSdk ?? realClaudeRewindSdk;
|
||||
}
|
||||
|
||||
@@ -1588,11 +1592,12 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error }, "Failed to resolve Claude Code version for model catalog");
|
||||
}
|
||||
const env = this.buildProviderEnv();
|
||||
const models = await runProviderRefreshActivity(context, "settings", () =>
|
||||
getClaudeModelsWithSettings(this.logger, claudeConfigDir(env), claudeCodeVersion),
|
||||
getClaudeModelsWithSettings(this.logger, this.configDir, claudeCodeVersion),
|
||||
);
|
||||
const modeCatalog = claudeModeCatalog(
|
||||
createProviderEnv({ baseEnv: process.env, runtimeSettings: this.runtimeSettings }),
|
||||
);
|
||||
const modeCatalog = claudeModeCatalog(env);
|
||||
return {
|
||||
models,
|
||||
...modeCatalog,
|
||||
@@ -1600,15 +1605,12 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
}
|
||||
|
||||
async resolveDefaultModeId({ env: launchEnv }: ResolveAgentDefaultModeInput): Promise<string> {
|
||||
return claudeModeCatalog(this.buildProviderEnv(launchEnv)).defaultModeId;
|
||||
}
|
||||
|
||||
private buildProviderEnv(launchEnv?: Record<string, string>): NodeJS.ProcessEnv {
|
||||
return createProviderEnv({
|
||||
const env = createProviderEnv({
|
||||
baseEnv: process.env,
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
overlays: [launchEnv],
|
||||
});
|
||||
return claudeModeCatalog(env).defaultModeId;
|
||||
}
|
||||
|
||||
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
||||
@@ -1622,7 +1624,7 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
async listImportableSessions(
|
||||
options?: ListImportableSessionsOptions,
|
||||
): Promise<ImportableProviderSession[]> {
|
||||
const configDir = claudeConfigDir(this.buildProviderEnv());
|
||||
const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
|
||||
const sessionsRoot = options?.cwd
|
||||
? claudeProjectDirSync(options.cwd, { configDir })
|
||||
: path.join(configDir, "projects");
|
||||
@@ -5049,7 +5051,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private resolveHistoryPath(sessionId: string): string | null {
|
||||
const cwd = this.config.cwd;
|
||||
if (!cwd) return null;
|
||||
const configDir = claudeConfigDir(this.buildSdkEnv());
|
||||
const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
|
||||
const candidates = [cwd];
|
||||
try {
|
||||
const realCwd = fs.realpathSync(cwd);
|
||||
|
||||
@@ -274,25 +274,6 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
expect(models.some((model) => model.id === "claude-fable-5-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("reads settings.json from the provider's own CLAUDE_CONFIG_DIR", async () => {
|
||||
const daemonConfigDir = await createClaudeConfigDir({});
|
||||
const providerConfigDir = await createClaudeConfigDir({ model: "glm-5.1" });
|
||||
vi.stubEnv("CLAUDE_CONFIG_DIR", daemonConfigDir);
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
resolveVersion: async () => "2.1.280",
|
||||
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: providerConfigDir } },
|
||||
});
|
||||
|
||||
const { models } = await client.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: os.tmpdir(),
|
||||
force: true,
|
||||
});
|
||||
|
||||
expect(models.map((model) => model.id)).toContain("glm-5.1");
|
||||
});
|
||||
|
||||
it("falls back to hardcoded models when settings.json is missing", async () => {
|
||||
const configDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-models-"));
|
||||
createdClaudeConfigDirs.push(configDir);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
@@ -50,7 +51,7 @@ export function findClaudeModel(
|
||||
|
||||
export async function getClaudeModelsWithSettings(
|
||||
logger: Logger,
|
||||
configDir: string,
|
||||
configDir?: string,
|
||||
claudeCodeVersion?: string,
|
||||
): Promise<AgentModelDefinition[]> {
|
||||
const hardcodedModels = getClaudeModels(claudeCodeVersion);
|
||||
@@ -78,9 +79,9 @@ export async function getClaudeModelsWithSettings(
|
||||
|
||||
async function readClaudeSettingsModels(
|
||||
logger: Logger,
|
||||
configDir: string,
|
||||
configDir?: string,
|
||||
): Promise<AgentModelDefinition[]> {
|
||||
const settingsPath = path.join(configDir, "settings.json");
|
||||
const settingsPath = path.join(resolveClaudeConfigDir(configDir), "settings.json");
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
@@ -115,6 +116,10 @@ async function readClaudeSettingsModels(
|
||||
return models;
|
||||
}
|
||||
|
||||
function resolveClaudeConfigDir(configDir?: string): string {
|
||||
return configDir ?? process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
|
||||
}
|
||||
|
||||
function addSettingsModel(
|
||||
models: AgentModelDefinition[],
|
||||
value: unknown,
|
||||
|
||||
@@ -66,11 +66,6 @@ function hashSuffix(input: string): string {
|
||||
return Math.abs(hash).toString(36);
|
||||
}
|
||||
|
||||
// Claude Code keeps its settings and transcripts here for a process launched with `env`.
|
||||
export function claudeConfigDir(env: NodeJS.ProcessEnv): string {
|
||||
return env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
|
||||
}
|
||||
|
||||
function resolveConfigDir(options?: ClaudeProjectDirOptions): string {
|
||||
return options?.configDir ?? claudeConfigDir(process.env);
|
||||
return options?.configDir ?? process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
|
||||
}
|
||||
|
||||
@@ -171,16 +171,8 @@ function createSession(
|
||||
return session;
|
||||
}
|
||||
|
||||
function createProviderWithFakeAppServer(
|
||||
appServer: FakeCodexAppServer,
|
||||
options?: {
|
||||
runtimeSettings?: ConstructorParameters<typeof CodexAppServerAgentClient>[1];
|
||||
customProvider?: { id: string; label: string; extends: string };
|
||||
},
|
||||
): CodexAppServerAgentClient {
|
||||
const provider = new CodexAppServerAgentClient(createTestLogger(), options?.runtimeSettings, {
|
||||
customProvider: options?.customProvider,
|
||||
});
|
||||
function createProviderWithFakeAppServer(appServer: FakeCodexAppServer): CodexAppServerAgentClient {
|
||||
const provider = new CodexAppServerAgentClient(createTestLogger());
|
||||
const internals = castInternals<{
|
||||
goalsEnabledPromise: Promise<boolean> | null;
|
||||
autoReviewEnabledPromise: Promise<boolean> | null;
|
||||
@@ -242,54 +234,6 @@ describe("Codex active-turn steering admission", () => {
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("a steer resolves a Codex skill mentioned after prose into app-server skill input", async () => {
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"skills/list": () => ({
|
||||
data: [
|
||||
{
|
||||
cwd: "/workspace/project",
|
||||
skills: [
|
||||
{
|
||||
name: "paseo-implement",
|
||||
description: "Execute an existing Paseo plan.",
|
||||
path: "/workspace/skills/paseo-implement/SKILL.md",
|
||||
},
|
||||
],
|
||||
errors: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
"turn/steer": () => ({ turn: { id: "native-A" } }),
|
||||
});
|
||||
const { session, paseoTurnId } = await startPublicSteeringSession(appServer);
|
||||
|
||||
await expect(
|
||||
session.steerActiveTurn!("Then use /paseo-implement to complete it.", {
|
||||
expectedTurnId: paseoTurnId,
|
||||
}),
|
||||
).resolves.toEqual({ status: "accepted" });
|
||||
|
||||
const steer = appServer.requests().find((request) => request.method === "turn/steer");
|
||||
expect(steer?.params).toEqual(
|
||||
expect.objectContaining({
|
||||
input: [
|
||||
{
|
||||
type: "skill",
|
||||
name: "paseo-implement",
|
||||
path: "/workspace/skills/paseo-implement/SKILL.md",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Then use $paseo-implement to complete it.",
|
||||
text_elements: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await session.close();
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("a clearing steer denies every pending permission through its provider handler", async () => {
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"turn/steer": () => ({ turn: { id: "native-A" } }),
|
||||
@@ -2281,59 +2225,6 @@ describe("Codex app-server provider", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("resolves a Codex skill mentioned after prose into app-server skill input", async () => {
|
||||
const session = createSession();
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "skills/list") {
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
cwd: "/tmp/codex-question-test",
|
||||
skills: [
|
||||
{
|
||||
name: "paseo-implement",
|
||||
description: "Execute an existing Paseo plan.",
|
||||
path: "/tmp/skills/paseo-implement/SKILL.md",
|
||||
},
|
||||
],
|
||||
errors: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (method === "thread/loaded/list") {
|
||||
return { data: ["test-thread"] };
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
return {};
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
});
|
||||
|
||||
session.activeForegroundTurnId = null;
|
||||
session.client = createStub<CodexClientLike>({ request });
|
||||
|
||||
await session.startTurn("Review the accepted plan, then use /paseo-implement to complete it.");
|
||||
|
||||
const turnStartCall = request.mock.calls.find(([method]) => method === "turn/start");
|
||||
expect(turnStartCall?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
input: [
|
||||
{
|
||||
type: "skill",
|
||||
name: "paseo-implement",
|
||||
path: "/tmp/skills/paseo-implement/SKILL.md",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Review the accepted plan, then use $paseo-implement to complete it.",
|
||||
text_elements: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("lists project skill commands when app-server receives the project cwd in cwds", async () => {
|
||||
const commands = await listCommandsFromFakeCodex([
|
||||
{
|
||||
@@ -6321,55 +6212,6 @@ describe("Codex importable sessions", () => {
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
// Codex filters thread/list by model provider: with no `modelProviders` it
|
||||
// returns only threads of the provider its own config selects, an empty
|
||||
// list returns every provider, and a list returns only those providers.
|
||||
function providerFilteringThreadListHandler(threads: Array<Record<string, unknown>>) {
|
||||
return (input: unknown) => {
|
||||
const { modelProviders } = (input ?? {}) as { modelProviders?: string[] };
|
||||
const included = (thread: Record<string, unknown>) => {
|
||||
if (modelProviders === undefined) return thread.modelProvider === "openai";
|
||||
if (modelProviders.length === 0) return true;
|
||||
return modelProviders.includes(String(thread.modelProvider));
|
||||
};
|
||||
return { data: threads.filter(included), nextCursor: null };
|
||||
};
|
||||
}
|
||||
|
||||
const threadsByModelProvider = [
|
||||
{ id: "stock-thread", cwd: "/workspace/project-a", modelProvider: "openai", updatedAt: 2 },
|
||||
{ id: "custom-thread", cwd: "/workspace/project-a", modelProvider: "my-codex", updatedAt: 1 },
|
||||
];
|
||||
|
||||
test("a custom Codex provider lists the threads it created", async () => {
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"thread/list": providerFilteringThreadListHandler(threadsByModelProvider),
|
||||
});
|
||||
const provider = createProviderWithFakeAppServer(appServer, {
|
||||
runtimeSettings: {
|
||||
env: { OPENAI_BASE_URL: "https://llm.example.test/v1", OPENAI_API_KEY: "test-key" },
|
||||
},
|
||||
customProvider: { id: "my-codex", label: "My Codex", extends: "codex" },
|
||||
});
|
||||
|
||||
const sessions = await provider.listImportableSessions({ cwd: "/workspace/project-a" });
|
||||
|
||||
expect(sessions.map((session) => session.providerHandleId)).toEqual(["custom-thread"]);
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("stock Codex keeps listing only its own threads", async () => {
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"thread/list": providerFilteringThreadListHandler(threadsByModelProvider),
|
||||
});
|
||||
const provider = createProviderWithFakeAppServer(appServer);
|
||||
|
||||
const sessions = await provider.listImportableSessions({ cwd: "/workspace/project-a" });
|
||||
|
||||
expect(sessions.map((session) => session.providerHandleId)).toEqual(["stock-thread"]);
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("listImportableSessions uses thread list metadata without hydrating thread history", async () => {
|
||||
const allThreads = [
|
||||
{
|
||||
|
||||
@@ -259,7 +259,7 @@ interface CodexAppServerAgentDeps {
|
||||
label: string;
|
||||
extends: string;
|
||||
};
|
||||
customCodexConfig?: CodexCustomProviderConfig | null;
|
||||
customCodexConfig?: Record<string, unknown> | null;
|
||||
_createCodexClient?: (
|
||||
child: ChildProcessWithoutNullStreams,
|
||||
logger: Logger,
|
||||
@@ -919,7 +919,7 @@ const CodexModelListResponseSchema = z.object({
|
||||
async function readCodexThreadWindow(
|
||||
client: Pick<CodexAppServerClientLike, "request">,
|
||||
logger: Logger,
|
||||
window: { limit: number; cwd?: string; modelProviders?: string[] },
|
||||
window: { limit: number; cwd?: string },
|
||||
): Promise<Array<Record<string, unknown>>> {
|
||||
// A thread updated while the scan is paging moves under `updated_at` order
|
||||
// and can come back on a later page, so identify each one and keep it once.
|
||||
@@ -935,7 +935,6 @@ async function readCodexThreadWindow(
|
||||
// Older Codex builds ignore the unknown key and keep that order.
|
||||
sortKey: "updated_at",
|
||||
...(window.cwd ? { cwd: window.cwd } : {}),
|
||||
...(window.modelProviders ? { modelProviders: window.modelProviders } : {}),
|
||||
...(cursor ? { cursor } : {}),
|
||||
}),
|
||||
);
|
||||
@@ -3169,26 +3168,6 @@ function enabledCodexSkills(
|
||||
return Array.from(skillsByName.values());
|
||||
}
|
||||
|
||||
// Composer autocomplete inserts skills as `/name` anywhere in the prompt, while
|
||||
// Codex only injects a skill it receives as a skill input item.
|
||||
const SKILL_MENTION_PATTERN = /(^|\s)\/([^\s/"']+)/g;
|
||||
|
||||
function resolveCodexSkillMentions(
|
||||
text: string,
|
||||
skills: ReadonlyArray<{ name: string; path: string }>,
|
||||
): { skills: CodexSkillPromptBlock[]; text: string } {
|
||||
const mentioned = new Map<string, CodexSkillPromptBlock>();
|
||||
const rewritten = text.replace(SKILL_MENTION_PATTERN, (mention, prefix: string, name: string) => {
|
||||
const skill = skills.find((entry) => entry.name === name);
|
||||
if (!skill) {
|
||||
return mention;
|
||||
}
|
||||
mentioned.set(skill.name, { type: "skill", name: skill.name, path: skill.path });
|
||||
return `${prefix}$${skill.name}`;
|
||||
});
|
||||
return { skills: Array.from(mentioned.values()), text: rewritten };
|
||||
}
|
||||
|
||||
type CodexPromptContentBlock = AgentPromptContentBlock | CodexSkillPromptBlock;
|
||||
type CodexPromptInput = string | CodexPromptContentBlock[];
|
||||
interface CodexTextElement {
|
||||
@@ -3299,15 +3278,10 @@ function normalizeOpenAICompatibleBaseUrl(value: string): string | null {
|
||||
return `${withoutTrailingSlashes}/v1`;
|
||||
}
|
||||
|
||||
interface CodexCustomProviderConfig {
|
||||
model_provider: string;
|
||||
model_providers: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
function buildCodexCustomProviderConfig(
|
||||
runtimeSettings: ProviderRuntimeSettings | undefined,
|
||||
customProvider: CodexAppServerAgentDeps["customProvider"],
|
||||
): CodexCustomProviderConfig | null {
|
||||
): Record<string, unknown> | null {
|
||||
if (customProvider?.extends !== CODEX_PROVIDER) {
|
||||
return null;
|
||||
}
|
||||
@@ -4083,18 +4057,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
return args ? `$${commandName} ${args}` : `$${commandName}`;
|
||||
}
|
||||
|
||||
private async attachMentionedSkills(prompt: AgentPromptInput): Promise<CodexPromptInput> {
|
||||
if (typeof prompt !== "string" || !prompt.includes("/")) {
|
||||
return prompt;
|
||||
}
|
||||
await this.loadSkills();
|
||||
const resolved = resolveCodexSkillMentions(prompt, this.cachedSkills ?? []);
|
||||
if (resolved.skills.length === 0) {
|
||||
return prompt;
|
||||
}
|
||||
return [...resolved.skills, { type: "text", text: resolved.text }];
|
||||
}
|
||||
|
||||
private async buildTurnStartParams(
|
||||
prompt: CodexPromptInput,
|
||||
options?: AgentRunOptions,
|
||||
@@ -4298,7 +4260,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
const slashCommand = await this.resolveSlashCommandInvocation(prompt);
|
||||
const effectivePrompt = slashCommand
|
||||
? await this.buildCommandPromptInput(slashCommand.commandName, slashCommand.args)
|
||||
: await this.attachMentionedSkills(prompt);
|
||||
: prompt;
|
||||
|
||||
if (this.currentThreadId) {
|
||||
await this.ensureThreadLoaded();
|
||||
@@ -4365,7 +4327,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
if (!this.matchesSteerAdmission({ client, threadId, nativeTurnId, foregroundTurnId })) {
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
const input = await this.buildUserInput(await this.attachMentionedSkills(prompt));
|
||||
const input = await this.buildUserInput(prompt);
|
||||
if (!this.matchesSteerAdmission({ client, threadId, nativeTurnId, foregroundTurnId })) {
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
@@ -7059,14 +7021,13 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
private sessionDeps(): CodexAppServerAgentDeps {
|
||||
return {
|
||||
...this.deps,
|
||||
customCodexConfig: this.customProviderConfig(),
|
||||
customCodexConfig: buildCodexCustomProviderConfig(
|
||||
this.runtimeSettings,
|
||||
this.deps.customProvider,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private customProviderConfig(): CodexCustomProviderConfig | null {
|
||||
return buildCodexCustomProviderConfig(this.runtimeSettings, this.deps.customProvider);
|
||||
}
|
||||
|
||||
private resolveGoalsEnabled(): Promise<boolean> {
|
||||
if (!this.goalsEnabledPromise) {
|
||||
this.goalsEnabledPromise = (async () => {
|
||||
@@ -7230,14 +7191,9 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
// filtering since most threads will be from other cwds, then keep the
|
||||
// local realpath-aware filter for symlink-equivalent workspace paths.
|
||||
const listLimit = options?.cwd ? Math.max(scanLimit, 50) : scanLimit;
|
||||
// Codex records each thread under the model provider that ran it and,
|
||||
// unless told otherwise, lists only the provider its own config selects.
|
||||
// A custom provider runs under its own id, so ask for exactly that one.
|
||||
const customProviderConfig = this.customProviderConfig();
|
||||
const allThreads = await readCodexThreadWindow(client, this.logger, {
|
||||
limit: listLimit,
|
||||
cwd: options?.cwd,
|
||||
...(customProviderConfig ? { modelProviders: [customProviderConfig.model_provider] } : {}),
|
||||
});
|
||||
const threads = filterCodexThreadsByCwd(allThreads, options?.cwd);
|
||||
return threads.slice(0, limit).map((thread) => {
|
||||
|
||||
-139
@@ -1,139 +0,0 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { once } from "node:events";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { CodexAppServerAgentClient } from "./codex-app-server-agent.js";
|
||||
|
||||
// Runs the real `codex` binary against a local Responses endpoint standing in
|
||||
// for a custom provider's OPENAI_BASE_URL, so no login is needed.
|
||||
|
||||
function sse(events: Array<Record<string, unknown>>): string {
|
||||
return events
|
||||
.map((event) => `event: ${String(event.type)}\ndata: ${JSON.stringify(event)}\n\n`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function finalAnswer(): string {
|
||||
return sse([
|
||||
{ type: "response.created", response: { id: "resp-1" } },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
id: "msg-1",
|
||||
content: [{ type: "output_text", text: "done" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp-1",
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
input_tokens_details: null,
|
||||
output_tokens: 0,
|
||||
output_tokens_details: null,
|
||||
total_tokens: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
async function startResponsesEndpoint(): Promise<{
|
||||
baseUrl: string;
|
||||
server: Server;
|
||||
requestCount: () => number;
|
||||
}> {
|
||||
let requests = 0;
|
||||
const server = createServer((req, res) => {
|
||||
req.resume();
|
||||
req.on("end", () => {
|
||||
if (req.method !== "POST" || req.url !== "/v1/responses") {
|
||||
res.statusCode = 404;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
requests += 1;
|
||||
res.setHeader("content-type", "text/event-stream");
|
||||
res.end(finalAnswer());
|
||||
});
|
||||
});
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("no server address");
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
||||
server,
|
||||
requestCount: () => requests,
|
||||
};
|
||||
}
|
||||
|
||||
async function closeServer(server: Server): Promise<void> {
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
}
|
||||
|
||||
describe("Codex custom provider import list", () => {
|
||||
const cleanups: Array<() => void | Promise<void>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const cleanup of cleanups.splice(0).toReversed()) await cleanup();
|
||||
});
|
||||
|
||||
test("lists a custom provider's session under that provider and imports it there", async () => {
|
||||
const codexHome = mkdtempSync(path.join(os.tmpdir(), "codex-custom-import-home-"));
|
||||
const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-custom-import-cwd-"));
|
||||
cleanups.push(() => rmSync(codexHome, { recursive: true, force: true }));
|
||||
cleanups.push(() => rmSync(cwd, { recursive: true, force: true }));
|
||||
const { baseUrl, server, requestCount } = await startResponsesEndpoint();
|
||||
cleanups.push(() => closeServer(server));
|
||||
|
||||
const logger = createTestLogger();
|
||||
const customCodex = new CodexAppServerAgentClient(
|
||||
logger,
|
||||
{ env: { CODEX_HOME: codexHome, OPENAI_BASE_URL: baseUrl, OPENAI_API_KEY: "test-key" } },
|
||||
{ customProvider: { id: "my-codex", label: "My Codex", extends: "codex" } },
|
||||
);
|
||||
const stockCodex = new CodexAppServerAgentClient(logger, { env: { CODEX_HOME: codexHome } });
|
||||
|
||||
const session = await customCodex.createSession({
|
||||
provider: "codex",
|
||||
cwd,
|
||||
model: "mock-model",
|
||||
});
|
||||
try {
|
||||
await session.run("hello from the custom provider");
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
|
||||
const customSessions = await customCodex.listImportableSessions({ cwd });
|
||||
const stockSessions = await stockCodex.listImportableSessions({ cwd });
|
||||
|
||||
expect(customSessions.map((entry) => entry.firstPromptPreview)).toEqual([
|
||||
"hello from the custom provider",
|
||||
]);
|
||||
expect(stockSessions).toEqual([]);
|
||||
|
||||
const config = { provider: "codex", cwd, model: "mock-model" };
|
||||
const imported = await customCodex.importSession(
|
||||
{ providerHandleId: customSessions[0].providerHandleId, cwd },
|
||||
{ config, storedConfig: config },
|
||||
);
|
||||
const requestsBeforeImportedTurn = requestCount();
|
||||
try {
|
||||
await imported.session.run("hello again after import");
|
||||
} finally {
|
||||
await imported.session.close();
|
||||
}
|
||||
expect(requestCount()).toBeGreaterThan(requestsBeforeImportedTurn);
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -1,108 +0,0 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { GenericACPAgentClient } from "./generic-acp-agent.js";
|
||||
|
||||
describe("GenericACPAgentClient slash commands", () => {
|
||||
test("lists commands an agent advertises right after session/new", async () => {
|
||||
await withFakeACPAgent("commands-after-session-new", async (command, cwd) => {
|
||||
const client = new GenericACPAgentClient({
|
||||
logger: createTestLogger(),
|
||||
command,
|
||||
// Long enough that the update always lands inside the wait, however slowly
|
||||
// the fake agent is scheduled.
|
||||
initialCommandsWaitTimeoutMs: 60_000,
|
||||
});
|
||||
const session = await client.createSession({ provider: "acp", cwd });
|
||||
try {
|
||||
await expect(session.listCommands?.()).resolves.toEqual([
|
||||
{ name: "review", description: "Review the diff", argumentHint: "", kind: "command" },
|
||||
]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("answers with no commands for an agent that never advertises any", async () => {
|
||||
await withFakeACPAgent("silent", async (command, cwd) => {
|
||||
const client = new GenericACPAgentClient({
|
||||
logger: createTestLogger(),
|
||||
command,
|
||||
initialCommandsWaitTimeoutMs: 50,
|
||||
});
|
||||
const session = await client.createSession({ provider: "acp", cwd });
|
||||
try {
|
||||
await expect(session.listCommands?.()).resolves.toEqual([]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function withFakeACPAgent(
|
||||
mode: "commands-after-session-new" | "silent",
|
||||
run: (command: [string, ...string[]], cwd: string) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const testDir = await mkdtemp(path.join(tmpdir(), "paseo-acp-commands-"));
|
||||
try {
|
||||
const scriptPath = path.join(testDir, "fake-acp-agent.cjs");
|
||||
await writeFile(scriptPath, fakeACPAgentScript, "utf8");
|
||||
await run([process.execPath, scriptPath, mode], testDir);
|
||||
} finally {
|
||||
await rm(testDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const fakeACPAgentScript = `
|
||||
const readline = require("node:readline");
|
||||
|
||||
const mode = process.argv[2];
|
||||
const rl = readline.createInterface({ input: process.stdin });
|
||||
|
||||
function write(message) {
|
||||
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\\n");
|
||||
}
|
||||
|
||||
rl.on("line", (line) => {
|
||||
const message = JSON.parse(line);
|
||||
if (message.method === "initialize") {
|
||||
write({
|
||||
id: message.id,
|
||||
result: {
|
||||
protocolVersion: message.params?.protocolVersion ?? 1,
|
||||
agentCapabilities: { sessionCapabilities: { close: {} } },
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.method === "session/new") {
|
||||
write({ id: message.id, result: { sessionId: "session-1" } });
|
||||
if (mode === "commands-after-session-new") {
|
||||
setTimeout(() => {
|
||||
write({
|
||||
method: "session/update",
|
||||
params: {
|
||||
sessionId: "session-1",
|
||||
update: {
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [{ name: "review", description: "Review the diff" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.id !== undefined) {
|
||||
write({ id: message.id, result: {} });
|
||||
}
|
||||
});
|
||||
`;
|
||||
@@ -62,7 +62,6 @@ describe("GenericACPAgentClient", () => {
|
||||
supportsRewindFiles: false,
|
||||
supportsRewindBoth: false,
|
||||
},
|
||||
waitForInitialCommands: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -70,9 +70,7 @@ export class GenericACPAgentClient extends ACPAgentClient {
|
||||
},
|
||||
defaultCommand: options.command,
|
||||
capabilities: buildGenericACPCapabilities(providerParams),
|
||||
// ACP agents advertise slash commands with available_commands_update after
|
||||
// session/new, so the first listCommands() waits for that batch.
|
||||
waitForInitialCommands: options.waitForInitialCommands ?? true,
|
||||
waitForInitialCommands: options.waitForInitialCommands,
|
||||
initialCommandsWaitTimeoutMs: options.initialCommandsWaitTimeoutMs,
|
||||
clientCapabilities: providerParams.clientCapabilities,
|
||||
clientCapabilityMeta: options.clientCapabilityMeta,
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { OmpAgentMessage } from "./rpc-types.js";
|
||||
import type { OmpNoTurnScheduler, OmpProviderIdleScheduler } from "./agent.js";
|
||||
import type { OmpUsagePollScheduler } from "./usage-poller.js";
|
||||
import { resolveOmpProviderParams } from "./provider-config.js";
|
||||
import { OmpRuntimeEventSchema } from "./rpc-types.js";
|
||||
import { OmpHarness } from "./test-utils/omp-harness.js";
|
||||
|
||||
const TURN_LIFECYCLE_EVENTS = new Set<AgentStreamEvent["type"]>([
|
||||
@@ -43,6 +42,7 @@ test("OMP ready timeout defaults to 20 seconds and RPC timeout overrides both",
|
||||
rpcTimeoutMs: 90_000,
|
||||
});
|
||||
});
|
||||
|
||||
class ManualIdleScheduler implements OmpProviderIdleScheduler {
|
||||
private readonly retries: Array<() => void> = [];
|
||||
private readonly waiters: Array<{ count: number; resolve: () => void }> = [];
|
||||
@@ -582,106 +582,6 @@ describe("OMP agent client and session", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("maps legacy select options without descriptions and preserves ordinary responses", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
|
||||
omp.emit({
|
||||
type: "extension_ui_request",
|
||||
id: "select-legacy",
|
||||
method: "select",
|
||||
title: "Choose",
|
||||
options: ["Approve", "Deny"],
|
||||
});
|
||||
|
||||
expect(omp.pendingPermissions()[0]?.input).toMatchObject({
|
||||
questions: [{ options: [{ label: "Approve" }, { label: "Deny" }] }],
|
||||
});
|
||||
await omp.respondToPermission("select-legacy", {
|
||||
behavior: "allow",
|
||||
updatedInput: { answers: { Response: "Deny" } },
|
||||
});
|
||||
expect(omp.extensionUiResponses()).toContainEqual({
|
||||
id: "select-legacy",
|
||||
response: { value: "Deny" },
|
||||
});
|
||||
});
|
||||
|
||||
test("maps described and mixed select metadata by option index", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
|
||||
omp.emit({
|
||||
type: "extension_ui_request",
|
||||
id: "select-described",
|
||||
method: "select",
|
||||
title: "Choose",
|
||||
options: ["First", "Second", "Third"],
|
||||
optionDetails: [{ description: "First detail" }, {}, { description: " \t" }],
|
||||
});
|
||||
|
||||
expect(omp.pendingPermissions()[0]?.input?.questions?.[0]?.options).toStrictEqual([
|
||||
{ label: "First", description: "First detail" },
|
||||
{ label: "Second" },
|
||||
{ label: "Third" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("accepts malformed or misaligned optional metadata and falls back to labels", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
const parsed = OmpRuntimeEventSchema.safeParse({
|
||||
type: "extension_ui_request",
|
||||
id: "select-malformed",
|
||||
method: "select",
|
||||
options: ["First", "Second"],
|
||||
optionDetails: [{ description: 42 }, { description: "\n\t" }, { description: "extra" }],
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
if (!parsed.success) throw new Error("Expected malformed metadata event to parse");
|
||||
|
||||
omp.emit(parsed.data);
|
||||
expect(omp.pendingPermissions()[0]?.input?.questions?.[0]?.options).toStrictEqual([
|
||||
{ label: "First" },
|
||||
{ label: "Second" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("preserves combined selection descriptions and freeform sentinel behavior", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
omp.emit({
|
||||
type: "tool_execution_start",
|
||||
toolCallId: "ask-user-1",
|
||||
toolName: "ask_user",
|
||||
args: { allowComment: true, allowFreeform: true, allowMultiple: false },
|
||||
});
|
||||
omp.emit({
|
||||
type: "extension_ui_request",
|
||||
id: "select-combined",
|
||||
method: "select",
|
||||
title: "Choose",
|
||||
options: ["First", "✏️ Type custom response..."],
|
||||
optionDetails: [{ description: "First detail" }, { description: "ignored" }],
|
||||
});
|
||||
|
||||
const combinedInput = omp.pendingPermissions()[0]?.input;
|
||||
expect(combinedInput?.questions?.[0]?.options).toStrictEqual([
|
||||
{ label: "First", description: "First detail" },
|
||||
]);
|
||||
expect(combinedInput).toMatchObject({
|
||||
questions: [{ allowOther: true }, { header: "Comment" }],
|
||||
});
|
||||
await omp.respondToPermission("select-combined", {
|
||||
behavior: "allow",
|
||||
updatedInput: { answers: { Response: "custom", Comment: "note" } },
|
||||
});
|
||||
expect(omp.extensionUiResponses()).toContainEqual({
|
||||
id: "select-combined",
|
||||
response: { value: "✏️ Type custom response..." },
|
||||
});
|
||||
});
|
||||
|
||||
test("exposes OMP modes and commands through the domain session", async () => {
|
||||
const omp = new OmpHarness();
|
||||
omp.queueCommands([{ name: "review", description: "Review changes", source: "skill" }]);
|
||||
|
||||
@@ -568,24 +568,6 @@ function getInputQuestionTitle(title: string | undefined, placeholder: string |
|
||||
return "Optional response";
|
||||
}
|
||||
|
||||
interface OmpSelectOption {
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
function readSelectOptions(options: unknown, optionDetails: unknown): OmpSelectOption[] {
|
||||
const labels = readStringArray(options);
|
||||
const details = Array.isArray(optionDetails) ? optionDetails : [];
|
||||
return labels.map((label, index) => {
|
||||
const detail = details[index];
|
||||
const description =
|
||||
isRecord(detail) && typeof detail.description === "string" && detail.description.trim() !== ""
|
||||
? detail.description
|
||||
: undefined;
|
||||
return description === undefined ? { label } : { label, description };
|
||||
});
|
||||
}
|
||||
|
||||
function readStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === "string")
|
||||
@@ -604,7 +586,7 @@ function mapExtensionUiRequestToPermission(
|
||||
const label = options.label ?? "OMP";
|
||||
switch (event.method) {
|
||||
case "select": {
|
||||
const selectOptions = readSelectOptions(event.options, event.optionDetails);
|
||||
const selectOptions = readStringArray(event.options);
|
||||
if (options.combineOptionalComment) {
|
||||
return buildCombinedAskUserQuestionPermission(event, {
|
||||
provider,
|
||||
@@ -651,7 +633,7 @@ function mapExtensionUiRequestToPermission(
|
||||
question: [optionalString(event.title), optionalString(event.message)]
|
||||
.filter(Boolean)
|
||||
.join("\n\n"),
|
||||
options: [{ label: "Yes" }, { label: "No" }],
|
||||
options: ["Yes", "No"],
|
||||
multiSelect: false,
|
||||
});
|
||||
default:
|
||||
@@ -696,7 +678,7 @@ function buildExtensionUiQuestionPermission(
|
||||
provider: AgentProvider;
|
||||
label: string;
|
||||
question: string;
|
||||
options: OmpSelectOption[];
|
||||
options: string[];
|
||||
multiSelect: boolean;
|
||||
placeholder?: string;
|
||||
allowEmpty?: boolean;
|
||||
@@ -714,10 +696,7 @@ function buildExtensionUiQuestionPermission(
|
||||
{
|
||||
question: input.question,
|
||||
header: QUESTION_RESPONSE_HEADER,
|
||||
options: input.options.map((option) => ({
|
||||
label: option.label,
|
||||
...(option.description === undefined ? {} : { description: option.description }),
|
||||
})),
|
||||
options: input.options.map((label) => ({ label })),
|
||||
multiSelect: input.multiSelect,
|
||||
...(input.placeholder ? { placeholder: input.placeholder } : {}),
|
||||
...(input.allowEmpty ? { allowEmpty: true } : {}),
|
||||
@@ -738,13 +717,11 @@ function buildCombinedAskUserQuestionPermission(
|
||||
provider: AgentProvider;
|
||||
label: string;
|
||||
question: string;
|
||||
options: OmpSelectOption[];
|
||||
options: string[];
|
||||
allowFreeform: boolean;
|
||||
},
|
||||
): AgentPermissionRequest {
|
||||
const visibleOptions = input.options.filter(
|
||||
(option) => !isOmpAskUserFreeformOption(option.label),
|
||||
);
|
||||
const visibleOptions = input.options.filter((option) => !isOmpAskUserFreeformOption(option));
|
||||
const allowOther = input.allowFreeform || visibleOptions.length !== input.options.length;
|
||||
return {
|
||||
id: event.id,
|
||||
@@ -757,10 +734,7 @@ function buildCombinedAskUserQuestionPermission(
|
||||
{
|
||||
question: input.question,
|
||||
header: QUESTION_RESPONSE_HEADER,
|
||||
options: visibleOptions.map((option) => ({
|
||||
label: option.label,
|
||||
...(option.description === undefined ? {} : { description: option.description }),
|
||||
})),
|
||||
options: visibleOptions.map((label) => ({ label })),
|
||||
multiSelect: false,
|
||||
...(allowOther ? { allowOther: true } : {}),
|
||||
},
|
||||
@@ -779,7 +753,7 @@ function buildCombinedAskUserQuestionPermission(
|
||||
answerHeader: QUESTION_RESPONSE_HEADER,
|
||||
commentHeader: QUESTION_COMMENT_HEADER,
|
||||
combinedAskUser: COMBINED_ASK_USER_METADATA,
|
||||
selectOptions: visibleOptions.map((option) => option.label),
|
||||
selectOptions: visibleOptions,
|
||||
...(allowOther ? { freeformSentinel: OMP_ASK_USER_FREEFORM_SENTINEL } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -444,7 +444,6 @@ const OmpExtensionUiRequestSchema = z
|
||||
title: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
options: z.array(z.string()).optional(),
|
||||
optionDetails: z.unknown().optional(),
|
||||
placeholder: z.string().optional(),
|
||||
url: z.string().optional(),
|
||||
launchUrl: z.string().optional(),
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
type OmpProviderIdleScheduler,
|
||||
} from "../agent.js";
|
||||
import type { OmpUsagePollScheduler } from "../usage-poller.js";
|
||||
import type { OmpAgentMessage, OmpRpcSlashCommand, OmpRuntimeEvent } from "../rpc-types.js";
|
||||
import type { OmpAgentMessage, OmpRpcSlashCommand } from "../rpc-types.js";
|
||||
import { FakeOmp } from "./fake-omp.js";
|
||||
|
||||
const CWD = "/tmp/paseo-omp-agent-test";
|
||||
@@ -422,10 +422,6 @@ export class OmpHarness {
|
||||
this.omp.latestSession().requestToolApproval(input);
|
||||
}
|
||||
|
||||
emit(event: OmpRuntimeEvent): void {
|
||||
this.omp.latestSession().emit(event);
|
||||
}
|
||||
|
||||
pendingPermissions() {
|
||||
return this.requireSession().getPendingPermissions();
|
||||
}
|
||||
|
||||
@@ -169,105 +169,6 @@ test("orders queued live deltas behind an incomplete recovery snapshot and suppr
|
||||
await upstream.close();
|
||||
});
|
||||
|
||||
test("sends the next turn to the new OpenCode server after the old one exits", async () => {
|
||||
const exited = await createRecoveryUpstream();
|
||||
const current = await createRecoveryUpstream();
|
||||
const ports = [exited.port, current.port];
|
||||
const processes: RecoveryServerProcess[] = [];
|
||||
const manager = new OpenCodeServerManager({
|
||||
logger: createTestLogger(),
|
||||
portAllocator: async () => ports.shift() as number,
|
||||
resolveCommandPrefix: async () => ({ command: "opencode", args: [] }),
|
||||
resolveHomeDir: () => process.cwd(),
|
||||
spawnServerProcess: () => {
|
||||
const serverProcess = new RecoveryServerProcess();
|
||||
processes.push(serverProcess);
|
||||
return serverProcess as unknown as ChildProcess;
|
||||
},
|
||||
terminateProcess: async (serverProcess) => {
|
||||
(serverProcess as unknown as RecoveryServerProcess).exit();
|
||||
return "terminated";
|
||||
},
|
||||
});
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: manager,
|
||||
createClient: ({ baseUrl, directory }) => createOpencodeClient({ baseUrl, directory }),
|
||||
});
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/workspace" });
|
||||
const observed: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => observed.push(event));
|
||||
await exited.connected(1);
|
||||
exited.send(0, connectedRecord());
|
||||
processes[0]?.exit();
|
||||
await exited.close();
|
||||
|
||||
const turn = session.startTurn("after restart");
|
||||
await current.connected(1);
|
||||
current.send(0, connectedRecord());
|
||||
await turn;
|
||||
await current.dispatched(1);
|
||||
current.send(0, idleRecord());
|
||||
|
||||
await eventually(() => expect(observed.map((event) => event.type)).toContain("turn_completed"));
|
||||
expect(observed.filter((event) => event.type === "turn_failed")).toHaveLength(0);
|
||||
|
||||
await session.close();
|
||||
await manager.shutdown();
|
||||
await current.close();
|
||||
});
|
||||
|
||||
test("adds the session's MCP servers to the new OpenCode server after the old one exits", async () => {
|
||||
const exited = await createRecoveryUpstream();
|
||||
const current = await createRecoveryUpstream();
|
||||
const ports = [exited.port, current.port];
|
||||
const processes: RecoveryServerProcess[] = [];
|
||||
const manager = new OpenCodeServerManager({
|
||||
logger: createTestLogger(),
|
||||
portAllocator: async () => ports.shift() as number,
|
||||
resolveCommandPrefix: async () => ({ command: "opencode", args: [] }),
|
||||
resolveHomeDir: () => process.cwd(),
|
||||
spawnServerProcess: () => {
|
||||
const serverProcess = new RecoveryServerProcess();
|
||||
processes.push(serverProcess);
|
||||
return serverProcess as unknown as ChildProcess;
|
||||
},
|
||||
terminateProcess: async (serverProcess) => {
|
||||
(serverProcess as unknown as RecoveryServerProcess).exit();
|
||||
return "terminated";
|
||||
},
|
||||
});
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: manager,
|
||||
createClient: ({ baseUrl, directory }) => createOpencodeClient({ baseUrl, directory }),
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/workspace",
|
||||
mcpServers: { paseo: { type: "http", url: "http://127.0.0.1:1/mcp" } },
|
||||
});
|
||||
await exited.connected(1);
|
||||
exited.send(0, connectedRecord());
|
||||
const firstTurn = session.startTurn("before restart");
|
||||
await exited.dispatched(1);
|
||||
exited.send(0, idleRecord());
|
||||
await firstTurn;
|
||||
expect(exited.mcpAdds()).toEqual(["paseo"]);
|
||||
processes[0]?.exit();
|
||||
await exited.close();
|
||||
|
||||
const turn = session.startTurn("after restart");
|
||||
await current.connected(1);
|
||||
current.send(0, connectedRecord());
|
||||
await turn;
|
||||
await current.dispatched(1);
|
||||
|
||||
expect(current.mcpAdds()).toEqual(["paseo"]);
|
||||
|
||||
await session.close();
|
||||
await manager.shutdown();
|
||||
await current.close();
|
||||
});
|
||||
|
||||
class RecoveryTiming implements OpenCodeEventConsumerTiming {
|
||||
private resolveWait: (() => void) | null = null;
|
||||
arm(): () => void {
|
||||
@@ -339,7 +240,6 @@ class RecoveryServerProcess extends EventEmitter {
|
||||
async function createRecoveryUpstream() {
|
||||
const streams: ServerResponse[] = [];
|
||||
const dispatchIds: string[] = [];
|
||||
const mcpAddNames: string[] = [];
|
||||
let messages: unknown[] = [];
|
||||
let messageReadCount = 0;
|
||||
let status: "busy" | "idle" = "idle";
|
||||
@@ -354,11 +254,6 @@ async function createRecoveryUpstream() {
|
||||
if (request.method === "POST" && pathname === "/session") {
|
||||
return json(response, { id: "session-1", directory: "/workspace" });
|
||||
}
|
||||
if (request.method === "POST" && pathname === "/mcp") {
|
||||
const body = JSON.parse(await readBody(request)) as { name: string };
|
||||
mcpAddNames.push(body.name);
|
||||
return json(response, {});
|
||||
}
|
||||
if (request.url?.includes("/prompt_async")) {
|
||||
const body = JSON.parse(await readBody(request)) as { messageID: string };
|
||||
dispatchIds.push(body.messageID);
|
||||
@@ -390,7 +285,6 @@ async function createRecoveryUpstream() {
|
||||
return [...dispatchIds];
|
||||
},
|
||||
messageReads: () => messageReadCount,
|
||||
mcpAdds: () => [...mcpAddNames],
|
||||
setMessages(nextMessages: unknown[]) {
|
||||
messages = nextMessages;
|
||||
},
|
||||
|
||||
@@ -1944,75 +1944,6 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("streamHistory hides user text that OpenCode marks synthetic", async () => {
|
||||
const fakeClient = {
|
||||
session: {
|
||||
get: vi.fn().mockResolvedValue({
|
||||
data: { revert: undefined },
|
||||
error: undefined,
|
||||
}),
|
||||
messages: vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
info: { id: "msg_continue", sessionID: "ses_unit_test", role: "user" },
|
||||
parts: [
|
||||
{
|
||||
id: "prt_continue",
|
||||
sessionID: "ses_unit_test",
|
||||
messageID: "msg_continue",
|
||||
type: "text",
|
||||
text: "Summarize the task tool output above and continue with your task.",
|
||||
synthetic: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
info: { id: "msg_user", sessionID: "ses_unit_test", role: "user" },
|
||||
parts: [
|
||||
{
|
||||
id: "prt_user",
|
||||
sessionID: "ses_unit_test",
|
||||
messageID: "msg_user",
|
||||
type: "text",
|
||||
text: "Read the notes",
|
||||
},
|
||||
{
|
||||
id: "prt_resource",
|
||||
sessionID: "ses_unit_test",
|
||||
messageID: "msg_user",
|
||||
type: "text",
|
||||
text: "Reading MCP resource: notes.md",
|
||||
synthetic: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
error: undefined,
|
||||
}),
|
||||
},
|
||||
} as never;
|
||||
|
||||
const session = new __openCodeInternals.OpenCodeAgentSession(
|
||||
{ provider: "opencode", cwd: "/tmp/test" },
|
||||
fakeClient,
|
||||
"ses_unit_test",
|
||||
createTestLogger(),
|
||||
);
|
||||
|
||||
const history: AgentStreamEvent[] = [];
|
||||
for await (const event of session.streamHistory()) {
|
||||
history.push(event);
|
||||
}
|
||||
|
||||
expect(history).toEqual([
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: { type: "user_message", text: "Read the notes", messageId: "msg_user" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("streamHistory omits replay timestamps when OpenCode omits times", async () => {
|
||||
const fakeClient = {
|
||||
session: {
|
||||
|
||||
@@ -1318,10 +1318,7 @@ function buildOpenCodeReplayTimelineEvents(
|
||||
}
|
||||
if (info.role === "user") {
|
||||
const text = parts
|
||||
.filter(
|
||||
(part): part is Extract<OpenCodePart, { type: "text" }> =>
|
||||
part.type === "text" && isUserAuthoredOpenCodeText(part),
|
||||
)
|
||||
.filter((part): part is Extract<OpenCodePart, { type: "text" }> => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("");
|
||||
|
||||
@@ -1448,9 +1445,12 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
options?: AgentCreateSessionOptions,
|
||||
): Promise<AgentSession> {
|
||||
const openCodeConfig = this.assertConfig(config);
|
||||
const connectServer = this.connectServer.bind(this, openCodeConfig, launchContext);
|
||||
const connection = await connectServer();
|
||||
const { client } = connection;
|
||||
const acquisition = await this.acquireServer(openCodeConfig, launchContext);
|
||||
const { url } = acquisition.server;
|
||||
const client = this.createOpenCodeClient({
|
||||
baseUrl: url,
|
||||
directory: openCodeConfig.cwd,
|
||||
});
|
||||
|
||||
// OpenCode stores permission rules on the session, so they are set here and on resume
|
||||
// rather than sent with each prompt, which drops them.
|
||||
@@ -1492,17 +1492,16 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
session.id,
|
||||
this.logger,
|
||||
new Map(this.modelContextWindows),
|
||||
connection.events,
|
||||
connection.release,
|
||||
acquisition.events,
|
||||
acquisition.release,
|
||||
options?.persistSession,
|
||||
launchContext?.agentId,
|
||||
connection.url,
|
||||
url,
|
||||
false,
|
||||
unbindBridge,
|
||||
connectServer,
|
||||
);
|
||||
} catch (error) {
|
||||
await connection.release();
|
||||
await acquisition.release();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1529,11 +1528,13 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
const registeredAcquisition = registeredServerUrl
|
||||
? this.serverManager.acquireExisting(registeredServerUrl)
|
||||
: null;
|
||||
const connectServer = this.connectServer.bind(this, openCodeConfig, launchContext);
|
||||
const connection = registeredAcquisition
|
||||
? this.toServerConnection(registeredAcquisition, openCodeConfig.cwd)
|
||||
: await connectServer();
|
||||
const { client } = connection;
|
||||
const acquisition =
|
||||
registeredAcquisition ?? (await this.acquireServer(openCodeConfig, launchContext));
|
||||
const { url } = acquisition.server;
|
||||
const client = this.createOpenCodeClient({
|
||||
baseUrl: url,
|
||||
directory: openCodeConfig.cwd,
|
||||
});
|
||||
|
||||
try {
|
||||
await this.applySessionPermissionRules(client, openCodeConfig, handle.sessionId);
|
||||
@@ -1546,17 +1547,16 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
handle.sessionId,
|
||||
this.logger,
|
||||
new Map(this.modelContextWindows),
|
||||
connection.events,
|
||||
connection.release,
|
||||
acquisition.events,
|
||||
acquisition.release,
|
||||
undefined,
|
||||
launchContext?.agentId,
|
||||
connection.url,
|
||||
url,
|
||||
registeredAcquisition !== null,
|
||||
unbindBridge,
|
||||
connectServer,
|
||||
);
|
||||
} catch (error) {
|
||||
await connection.release();
|
||||
await acquisition.release();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1578,26 +1578,6 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
}
|
||||
}
|
||||
|
||||
private async connectServer(
|
||||
config: OpenCodeAgentConfig,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<OpenCodeServerConnection> {
|
||||
const acquisition = await this.acquireServer(config, launchContext);
|
||||
return this.toServerConnection(acquisition, config.cwd);
|
||||
}
|
||||
|
||||
private toServerConnection(
|
||||
acquisition: OpenCodeServerAcquisition,
|
||||
directory: string,
|
||||
): OpenCodeServerConnection {
|
||||
return {
|
||||
client: this.createOpenCodeClient({ baseUrl: acquisition.server.url, directory }),
|
||||
events: acquisition.events,
|
||||
url: acquisition.server.url,
|
||||
release: acquisition.release,
|
||||
};
|
||||
}
|
||||
|
||||
private acquireServer(
|
||||
config: OpenCodeAgentConfig,
|
||||
launchContext?: AgentLaunchContext,
|
||||
@@ -2857,11 +2837,6 @@ function shouldSuppressOpenCodeAssistantPart(
|
||||
);
|
||||
}
|
||||
|
||||
// OpenCode marks text it adds to a user message itself with `synthetic` and hides it from its own UI.
|
||||
function isUserAuthoredOpenCodeText(part: { synthetic?: boolean }): boolean {
|
||||
return part.synthetic !== true;
|
||||
}
|
||||
|
||||
function appendOpenCodeTextPart(
|
||||
part: Extract<
|
||||
Extract<OpenCodeEvent, { type: "message.part.updated" }>["properties"]["part"],
|
||||
@@ -2872,11 +2847,7 @@ function appendOpenCodeTextPart(
|
||||
events: AgentStreamEvent[],
|
||||
): void {
|
||||
if (messageRole === "user") {
|
||||
if (
|
||||
!part.text ||
|
||||
!isUserAuthoredOpenCodeText(part) ||
|
||||
state.emittedUserMessageIds?.has(part.messageID)
|
||||
) {
|
||||
if (!part.text || state.emittedUserMessageIds?.has(part.messageID)) {
|
||||
return;
|
||||
}
|
||||
state.emittedUserMessageIds?.add(part.messageID);
|
||||
@@ -3352,24 +3323,12 @@ async function listOpenCodeChildSessions(
|
||||
return readOpenCodeChildSessionInfosFromResponse(sessionIdResponse) ?? [];
|
||||
}
|
||||
|
||||
/** One OpenCode server generation a session talks to. */
|
||||
interface OpenCodeServerConnection {
|
||||
client: OpencodeClient;
|
||||
events: OpenCodeEventSource;
|
||||
url: string;
|
||||
release: () => Promise<void>;
|
||||
}
|
||||
|
||||
class OpenCodeAgentSession implements AgentSession {
|
||||
readonly provider = "opencode" as const;
|
||||
readonly capabilities = OPENCODE_CAPABILITIES;
|
||||
|
||||
private readonly config: OpenCodeAgentConfig;
|
||||
private server: OpenCodeServerConnection;
|
||||
/** Connects to the server generation that is current now. */
|
||||
private readonly connectServer: (() => Promise<OpenCodeServerConnection>) | null;
|
||||
private serverExited = false;
|
||||
private reconnection: Promise<void> | null = null;
|
||||
private readonly client: OpencodeClient;
|
||||
private readonly sessionId: string;
|
||||
private readonly logger: Logger;
|
||||
private readonly modelContextWindowsByModelKey: ReadonlyMap<string, number>;
|
||||
@@ -3379,7 +3338,8 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
private abortController: AbortController | null = null;
|
||||
private accumulatedUsage: AgentUsage = {};
|
||||
private sessionTotalCostUsd: number | undefined;
|
||||
private mcpSetup: Promise<void> | null = null;
|
||||
private mcpConfigured = false;
|
||||
private mcpSetupPromise: Promise<void> | null = null;
|
||||
private messageRoles = new Map<string, OpenCodeMessageRole>();
|
||||
private pendingUserMessageText: string | null = null;
|
||||
private pendingClientMessageId: string | null = null;
|
||||
@@ -3426,6 +3386,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
private childHydrationCompleted = false;
|
||||
private readonly unrelatedSessionIds = new Set<string>();
|
||||
private selectedModelContextWindowMaxTokens: number | undefined;
|
||||
private releaseServer: (() => Promise<void>) | null;
|
||||
private releaseBridge: (() => void) | null;
|
||||
private ingress = Promise.resolve();
|
||||
private gapRepairRevision = 0;
|
||||
@@ -3440,45 +3401,30 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
sessionId: string,
|
||||
logger: Logger,
|
||||
modelContextWindowsByModelKey: ReadonlyMap<string, number> = new Map(),
|
||||
events: OpenCodeEventSource = EMPTY_OPENCODE_EVENT_SOURCE,
|
||||
releaseServer: () => Promise<void> = async () => undefined,
|
||||
private readonly events: OpenCodeEventSource = EMPTY_OPENCODE_EVENT_SOURCE,
|
||||
releaseServer?: () => Promise<void>,
|
||||
persistSession = true,
|
||||
private readonly agentId?: string,
|
||||
serverUrl?: string,
|
||||
private readonly serverUrl?: string,
|
||||
private readonly externallyDriven = false,
|
||||
releaseBridge?: () => void,
|
||||
connectServer?: () => Promise<OpenCodeServerConnection>,
|
||||
) {
|
||||
this.config = config;
|
||||
this.server = { client, events, url: serverUrl ?? "", release: releaseServer };
|
||||
this.connectServer = connectServer ?? null;
|
||||
this.client = client;
|
||||
this.sessionId = sessionId;
|
||||
this.logger = logger.child({ agentId: this.agentId });
|
||||
this.modelContextWindowsByModelKey = modelContextWindowsByModelKey;
|
||||
this.currentMode = normalizeOpenCodeModeId(config.modeId);
|
||||
this.autoAcceptEnabled = !config.toolPolicy && isOpenCodeAutoAcceptEnabled(config);
|
||||
this.releaseServer = releaseServer ?? null;
|
||||
this.releaseBridge = releaseBridge ?? null;
|
||||
this.persistSession = persistSession;
|
||||
this.selectedModelContextWindowMaxTokens = this.resolveConfiguredModelContextWindowMaxTokens(
|
||||
config.model,
|
||||
);
|
||||
this.subscribeServerEvents();
|
||||
}
|
||||
|
||||
private get client(): OpencodeClient {
|
||||
return this.server.client;
|
||||
}
|
||||
|
||||
private get events(): OpenCodeEventSource {
|
||||
return this.server.events;
|
||||
}
|
||||
|
||||
private subscribeServerEvents(): void {
|
||||
this.unsubscribeEvents = this.events.subscribe((input) => {
|
||||
if ("type" in input && input.type === "server-exited") {
|
||||
if ("type" in input && input.type === "server-exited")
|
||||
this.recoveryAbortController.abort(input.error);
|
||||
this.serverExited = true;
|
||||
}
|
||||
this.ingress = this.ingress
|
||||
.then(() => this.consumeEventSourceInput(input))
|
||||
.catch((error) => {
|
||||
@@ -3490,47 +3436,6 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The OpenCode session outlives the server process that served it, and Paseo starts the
|
||||
* next server on a new port. Move to the current server before talking to OpenCode again,
|
||||
* so the session does not keep calling a port nothing listens on.
|
||||
*/
|
||||
private async reconnectIfServerExited(): Promise<void> {
|
||||
if (!this.serverExited || !this.connectServer) return;
|
||||
this.reconnection ??= this.reconnect(this.connectServer).finally(() => {
|
||||
this.reconnection = null;
|
||||
});
|
||||
await this.reconnection;
|
||||
}
|
||||
|
||||
private async reconnect(connectServer: () => Promise<OpenCodeServerConnection>): Promise<void> {
|
||||
const exited = this.server;
|
||||
const next = await connectServer();
|
||||
if (this.closed) {
|
||||
await next.release();
|
||||
return;
|
||||
}
|
||||
this.unsubscribeEvents?.();
|
||||
this.unsubscribeEvents = null;
|
||||
// Let the exited server's queued events, including its exit, settle while no turn is
|
||||
// running, so none of them can fail a turn started on the new server.
|
||||
await this.ingress;
|
||||
if (this.closed) {
|
||||
await next.release();
|
||||
return;
|
||||
}
|
||||
this.server = next;
|
||||
this.serverExited = false;
|
||||
this.recoveryAbortController = new AbortController();
|
||||
this.mcpSetup = null;
|
||||
this.subscribeServerEvents();
|
||||
this.logger.info(
|
||||
{ sessionId: this.sessionId, previousUrl: exited.url, url: next.url },
|
||||
"OpenCode session moved to the current server after its server exited",
|
||||
);
|
||||
await exited.release();
|
||||
}
|
||||
|
||||
get id(): string | null {
|
||||
return this.sessionId;
|
||||
}
|
||||
@@ -3696,7 +3601,6 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async revertBoth(input: { messageId: string }): Promise<void> {
|
||||
await this.reconnectIfServerExited();
|
||||
await revertOpenCodeConversationAndFiles({
|
||||
client: this.client,
|
||||
sessionId: this.sessionId,
|
||||
@@ -3821,7 +3725,6 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
if (this.turnState.status === "running") {
|
||||
throw new Error("A foreground turn is already active");
|
||||
}
|
||||
await this.reconnectIfServerExited();
|
||||
try {
|
||||
await this.awaitRunnerQuiescence();
|
||||
} catch (error) {
|
||||
@@ -4328,8 +4231,8 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
if (event.event.cwd) {
|
||||
this.childSessionCwds.set(event.event.id, event.event.cwd);
|
||||
}
|
||||
if (this.server.url) {
|
||||
registerOpenCodeChildSessionServerUrl(event.event.id, this.server.url);
|
||||
if (this.serverUrl) {
|
||||
registerOpenCodeChildSessionServerUrl(event.event.id, this.serverUrl);
|
||||
}
|
||||
} else if (event.event.type === "remove") {
|
||||
unregisterOpenCodeChildSessionServerUrl(event.event.id);
|
||||
@@ -4861,7 +4764,6 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||
await this.reconnectIfServerExited();
|
||||
const sessionResponse = await this.client.session.get({
|
||||
sessionID: this.sessionId,
|
||||
directory: this.config.cwd,
|
||||
@@ -4891,7 +4793,6 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
return this.availableModesCache;
|
||||
}
|
||||
|
||||
await this.reconnectIfServerExited();
|
||||
const response = await openCodeMetadataLimit(() =>
|
||||
this.client.app.agents({
|
||||
directory: this.config.cwd,
|
||||
@@ -4910,7 +4811,6 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async listCommands(): Promise<AgentSlashCommand[]> {
|
||||
await this.reconnectIfServerExited();
|
||||
return await listOpenCodeCommandsFromSdk(this.client, this.config.cwd);
|
||||
}
|
||||
|
||||
@@ -5028,7 +4928,8 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
} finally {
|
||||
this.releaseBridge?.();
|
||||
this.releaseBridge = null;
|
||||
await this.server.release();
|
||||
await this.releaseServer?.();
|
||||
this.releaseServer = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5104,18 +5005,25 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private async ensureMcpServersConfigured(): Promise<void> {
|
||||
const mcpServers = this.config.mcpServers;
|
||||
if (!mcpServers || Object.keys(mcpServers).length === 0) {
|
||||
if (this.mcpConfigured) {
|
||||
return;
|
||||
}
|
||||
|
||||
const setup = (this.mcpSetup ??= this.configureMcpServers(mcpServers));
|
||||
const mcpServers = this.config.mcpServers;
|
||||
if (!mcpServers || Object.keys(mcpServers).length === 0) {
|
||||
this.mcpConfigured = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.mcpSetupPromise) {
|
||||
this.mcpSetupPromise = this.configureMcpServers(mcpServers);
|
||||
}
|
||||
|
||||
try {
|
||||
await setup;
|
||||
await this.mcpSetupPromise;
|
||||
this.mcpConfigured = true;
|
||||
} catch (error) {
|
||||
if (this.mcpSetup === setup) {
|
||||
this.mcpSetup = null;
|
||||
}
|
||||
this.mcpSetupPromise = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
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";
|
||||
@@ -6,18 +5,11 @@ 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-"));
|
||||
@@ -94,12 +86,7 @@ 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: process.env.OPENCODE_TEST_MODEL ?? "opencode/big-pickle",
|
||||
modeId: "build",
|
||||
},
|
||||
{ provider: "opencode", cwd: firstCwd, model: "opencode/big-pickle", modeId: "build" },
|
||||
{
|
||||
agentId: "real-agent-one",
|
||||
env: { PASEO_AGENT_ID: "real-agent-one", PASEO_AGENT_CWD: firstCwd },
|
||||
@@ -140,22 +127,6 @@ 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)='",
|
||||
@@ -199,6 +170,11 @@ 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",
|
||||
@@ -221,142 +197,8 @@ function createCallerCatalog(callerAgentId: string): PaseoToolCatalog {
|
||||
};
|
||||
}
|
||||
|
||||
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<ReturnType<typeof client.createSession>> | 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<ReturnType<typeof client.createSession>> | undefined;
|
||||
let resumed: Awaited<ReturnType<typeof client.resumeSession>> | 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,
|
||||
);
|
||||
function readAssistantText(timeline: ReadonlyArray<{ type: string; text?: string }>): string {
|
||||
return timeline
|
||||
.flatMap((item) => (item.type === "assistant_message" ? [item.text ?? ""] : []))
|
||||
.join("");
|
||||
}
|
||||
|
||||
@@ -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()).toMatch(
|
||||
/opencode-(?:ai|windows-(?:x64|arm64))[\\/]bin[\\/]opencode\.exe$/,
|
||||
expect(path.normalize(record.command).toLowerCase()).toContain(
|
||||
path.normalize("node_modules/opencode-ai/bin/opencode.exe").toLowerCase(),
|
||||
);
|
||||
expect(record.command.toLowerCase()).not.toBe(detectedOpenCode!.toLowerCase());
|
||||
expect(record.args).toEqual(["serve", "--port", String(acquisition.server.port)]);
|
||||
|
||||
@@ -1,409 +0,0 @@
|
||||
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<V2Api>();
|
||||
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<ReturnType<typeof client.createSession>>[] = [];
|
||||
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<ReturnType<typeof client.createSession>> | 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<ReturnType<typeof client.createSession>> | 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<ReturnType<typeof client.createSession>> | 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<ReturnType<typeof client.createSession>> | 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<ReturnType<typeof client.createSession>> | undefined;
|
||||
let imported: Awaited<ReturnType<typeof client.importSession>> | 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<ReturnType<typeof client.createSession>> | 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<ReturnType<typeof client.createSession>> | 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<ReturnType<typeof client.createSession>> | 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);
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { SessionMessageInfo } from "@opencode/client";
|
||||
import { mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
@@ -9,11 +8,7 @@ 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,
|
||||
materializeOpenCodeV2Plugin,
|
||||
} from "./bridge.js";
|
||||
import { OpenCodeBridge, loadOpenCodeBridgePluginArtifact } from "./bridge.js";
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
@@ -201,182 +196,6 @@ 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<string, V2TestTool>();
|
||||
let filter!: (input: V2TestContext) => Promise<void>;
|
||||
const module: {
|
||||
default: { setup(context: V2TestPluginContext): Promise<() => Promise<void>> };
|
||||
} = 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<void>> };
|
||||
} = 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<string, V2TestTool>();
|
||||
let hook!: (input: V2TestContext) => Promise<void>;
|
||||
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);
|
||||
@@ -405,29 +224,3 @@ describe("OpenCodeBridge", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
interface V2TestTool {
|
||||
name: string;
|
||||
execute(input: unknown, call: { sessionID: string }): Promise<unknown>;
|
||||
}
|
||||
interface V2TestContext {
|
||||
sessionID: string;
|
||||
tools: Record<string, { input?: unknown }>;
|
||||
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<void> }>;
|
||||
};
|
||||
session: {
|
||||
context(input: { sessionID: string }): Promise<SessionMessageInfo[]>;
|
||||
get(input: { sessionID: string }): Promise<{ parentID: string }>;
|
||||
hook(
|
||||
name: string,
|
||||
callback: (input: V2TestContext) => Promise<void>,
|
||||
): Promise<{ dispose(): Promise<void> }>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -48,7 +48,6 @@ 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) {
|
||||
@@ -116,14 +115,6 @@ export class OpenCodeBridge {
|
||||
};
|
||||
}
|
||||
|
||||
async decorateV2ServerEnv(env: Record<string, string>): Promise<Record<string, string>> {
|
||||
this.v2PluginUrl ??= await this.materializePlugin(2);
|
||||
return decorateOpenCodeV2Env(env, this.v2PluginUrl, {
|
||||
baseUrl: this.requireBaseUrl(),
|
||||
token: this.token,
|
||||
});
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
const server = this.server;
|
||||
this.server = null;
|
||||
@@ -132,8 +123,7 @@ export class OpenCodeBridge {
|
||||
if (server) await closeServer(server);
|
||||
}
|
||||
|
||||
private async materializePlugin(version: 1 | 2 = 1): Promise<string> {
|
||||
if (version === 2) return materializeOpenCodeV2Plugin(this.paseoHome);
|
||||
private async materializePlugin(): Promise<string> {
|
||||
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`);
|
||||
@@ -162,12 +152,7 @@ export class OpenCodeBridge {
|
||||
sendJson(response, 404, { error: "OpenCode session is not bound to a Paseo agent" });
|
||||
return;
|
||||
}
|
||||
sendJson(response, 200, {
|
||||
env: binding.env,
|
||||
...(url.searchParams.has("tools")
|
||||
? { tools: [...(binding.tools?.tools.keys() ?? [])] }
|
||||
: {}),
|
||||
});
|
||||
sendJson(response, 200, { env: binding.env });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -246,63 +231,13 @@ export class OpenCodeBridge {
|
||||
}
|
||||
}
|
||||
|
||||
export async function materializeOpenCodeV2Plugin(paseoHome: string): Promise<string> {
|
||||
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<string, string>,
|
||||
pluginUrl: string,
|
||||
options: Record<string, string> = {},
|
||||
): Record<string, string> {
|
||||
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<Uint8Array>;
|
||||
|
||||
export async function loadOpenCodeBridgePluginArtifact(
|
||||
moduleUrl: string,
|
||||
compileSource: CompileOpenCodeBridgePlugin = compileOpenCodeBridgePlugin,
|
||||
version: 1 | 2 = 1,
|
||||
): Promise<Uint8Array> {
|
||||
const directory = version === 2 ? "./v2/" : "./";
|
||||
const bundleUrl = new URL(`${directory}bridge-plugin.bundle.mjs`, moduleUrl);
|
||||
const bundleUrl = new URL("./bridge-plugin.bundle.mjs", moduleUrl);
|
||||
try {
|
||||
return await readFile(bundleUrl);
|
||||
} catch (error) {
|
||||
@@ -310,7 +245,7 @@ export async function loadOpenCodeBridgePluginArtifact(
|
||||
throw new Error("Bundled OpenCode bridge plugin artifact is missing", { cause: error });
|
||||
}
|
||||
}
|
||||
return compileSource(fileURLToPath(new URL(`${directory}bridge-plugin.mjs`, moduleUrl)));
|
||||
return compileSource(fileURLToPath(new URL("./bridge-plugin.mjs", moduleUrl)));
|
||||
}
|
||||
|
||||
async function compileOpenCodeBridgePlugin(sourcePath: string): Promise<Uint8Array> {
|
||||
@@ -325,7 +260,6 @@ async function compileOpenCodeBridgePlugin(sourcePath: string): Promise<Uint8Arr
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "neutral",
|
||||
mainFields: ["module", "main"],
|
||||
target: "es2022",
|
||||
write: false,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { SessionMessageAssistant, SessionMessageUser } from "@opencode/client";
|
||||
import { V2Timeline } from "./v2/timeline.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { translateOpenCodeEvent, type OpenCodeEventTranslationState } from "../opencode-agent.js";
|
||||
@@ -1278,44 +1276,6 @@ describe("translateOpenCodeEvent", () => {
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("shows OpenCode user text parts unless OpenCode marks them synthetic", () => {
|
||||
const state = createState();
|
||||
const userTextPart = (messageId: string, text: string, synthetic: boolean) => {
|
||||
translateOpenCodeEvent(
|
||||
{
|
||||
type: "message.updated",
|
||||
properties: { info: { id: messageId, sessionID: "session-1", role: "user" } },
|
||||
},
|
||||
state,
|
||||
);
|
||||
return translateOpenCodeEvent(
|
||||
{
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: `part-${messageId}`,
|
||||
sessionID: "session-1",
|
||||
messageID: messageId,
|
||||
type: "text",
|
||||
text,
|
||||
synthetic,
|
||||
},
|
||||
},
|
||||
},
|
||||
state,
|
||||
);
|
||||
};
|
||||
|
||||
expect(userTextPart("msg-continue", "Continue if you have next steps", true)).toEqual([]);
|
||||
expect(userTextPart("msg-user", "Plugin prompt", false)).toEqual([
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: { type: "user_message", text: "Plugin prompt", messageId: "msg-user" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores message.part.delta for user messages", () => {
|
||||
const state = createState();
|
||||
|
||||
@@ -1558,90 +1518,3 @@ describe("translateOpenCodeEvent", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
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<V2Timeline["messages"]>) =>
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
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";
|
||||
@@ -69,37 +67,3 @@ 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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
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,
|
||||
);
|
||||
@@ -1,164 +0,0 @@
|
||||
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<typeof OpenCodeAgentClient>[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<OpenCodeAgentClient | OpenCodeV2AgentClient> | 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<OpenCodeAgentClient | OpenCodeV2AgentClient> {
|
||||
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<AgentSessionConfig>,
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
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<AgentStreamEvent> {
|
||||
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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -2,7 +2,6 @@ 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";
|
||||
|
||||
@@ -623,23 +622,25 @@ async function resolveOpenCodeBinary(): Promise<string> {
|
||||
}
|
||||
|
||||
if (process.platform === "win32" && path.extname(found).toLowerCase() === ".cmd") {
|
||||
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;
|
||||
// Global npm: <prefix>/opencode.cmd → <prefix>/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;
|
||||
|
||||
// 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.
|
||||
}
|
||||
}
|
||||
// Local/pnpm: <project>/node_modules/.bin/opencode.cmd → <project>/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;
|
||||
|
||||
console.warn(
|
||||
"[opencode-server] Found opencode.cmd but could not resolve the real opencode.exe. " +
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
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<string, string> }> = [];
|
||||
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<Error>(() => undefined),
|
||||
};
|
||||
readonly runtime = { acquire: async () => this.connection, shutdown: async () => undefined };
|
||||
|
||||
private async *events(signal?: AbortSignal): AsyncGenerator<OpenCodeEvent> {
|
||||
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<void>((resolve) => {
|
||||
this.notify = resolve;
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
signal?.removeEventListener("abort", wake);
|
||||
}
|
||||
}
|
||||
}
|
||||
-152
@@ -1,152 +0,0 @@
|
||||
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<Pick<AgentTimelineItem, "type"> & { 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<AgentStreamEvent[]> {
|
||||
return drain(session);
|
||||
}
|
||||
|
||||
async function drain(session: AgentSession): Promise<AgentStreamEvent[]> {
|
||||
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<string[]> {
|
||||
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<void> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,404 +0,0 @@
|
||||
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<void>((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<void>((resolve) => {
|
||||
accept = resolve;
|
||||
});
|
||||
harness.wait = () =>
|
||||
new Promise<void>((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<void>((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<string, string>; dedicated?: boolean }> = [];
|
||||
const runtime = {
|
||||
acquire: async (input: { env?: Record<string, string>; 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<string, string>; dedicated?: boolean }> = [];
|
||||
const runtime = {
|
||||
acquire: async (input: { env?: Record<string, string>; 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<void>((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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,264 +0,0 @@
|
||||
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<V2Runtime, "acquire" | "shutdown">;
|
||||
}
|
||||
|
||||
export class OpenCodeV2AgentClient implements AgentClient {
|
||||
readonly provider = "opencode";
|
||||
readonly capabilities: AgentCapabilityFlags;
|
||||
private readonly runtime: Pick<V2Runtime, "acquire" | "shutdown">;
|
||||
private readonly connections = new Map<string, V2Connection>();
|
||||
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 = <T>(name: string, operation: () => Promise<T>) =>
|
||||
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<AgentSession> {
|
||||
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<AgentSessionConfig>,
|
||||
launch?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
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<string, () => 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<AgentFeature[]> {
|
||||
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<ImportableProviderSession[]> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
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<OpenCodeClient["session"]["form"], "list" | "reply" | "cancel">;
|
||||
};
|
||||
plugin: Pick<OpenCodeClient["plugin"], "list">;
|
||||
model: OpenCodeClient["model"];
|
||||
provider: Pick<OpenCodeClient["provider"], "list">;
|
||||
agent: Pick<OpenCodeClient["agent"], "list">;
|
||||
command: OpenCodeClient["command"];
|
||||
skill: OpenCodeClient["skill"];
|
||||
message: OpenCodeClient["message"];
|
||||
mcp: Pick<OpenCodeClient["mcp"], "add" | "list">;
|
||||
permission: Pick<OpenCodeClient["permission"], "list" | "reply">;
|
||||
event: OpenCodeClient["event"];
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
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();
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
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,
|
||||
};
|
||||
@@ -1,91 +0,0 @@
|
||||
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<void>;
|
||||
bindChild?: (id: string) => void;
|
||||
}
|
||||
export class SessionChildren {
|
||||
private readonly children = new Map<string, V2Timeline>();
|
||||
private readonly childStates = new Map<string, string>();
|
||||
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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
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<AgentSlashCommand[]> {
|
||||
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<string, AgentSlashCommand>();
|
||||
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()];
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
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 }]);
|
||||
});
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
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<AgentSessionConfig>,
|
||||
) {
|
||||
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));
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { SessionMessageInfo } from "@opencode/client";
|
||||
import type { V2Api } from "./api.js";
|
||||
|
||||
export async function messages(client: V2Api, sessionID: string): Promise<SessionMessageInfo[]> {
|
||||
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;
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
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 }]);
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
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<V2Api["session"]["form"]["reply"]>[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<V2Api["permission"]["reply"]>[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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,157 +0,0 @@
|
||||
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<string, AgentPermissionRequest>();
|
||||
private readonly permissionOwners = new Map<string, string>();
|
||||
private readonly forms = new Map<string, FormInfo>();
|
||||
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<string, FormValue> = {};
|
||||
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}`);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
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<V2Api, "plugin">;
|
||||
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<void> {
|
||||
return waitForPlugins({ ...input, requireBridge: false });
|
||||
}
|
||||
|
||||
export function awaitPaseoPlugin(input: ReadinessInput): Promise<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
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<void>;
|
||||
retain(): V2Connection;
|
||||
readonly exited: Promise<Error>;
|
||||
}
|
||||
interface Generation {
|
||||
client: V2Api;
|
||||
users: number;
|
||||
stop(): Promise<void>;
|
||||
exited: Promise<Error>;
|
||||
}
|
||||
export interface V2RuntimeOptions {
|
||||
logger: Logger;
|
||||
settings?: ProviderRuntimeSettings;
|
||||
managedProcesses?: ManagedProcessRegistry;
|
||||
decorateEnv?: (env: Record<string, string>) => Promise<Record<string, string>>;
|
||||
}
|
||||
|
||||
export interface V2RuntimeAcquireOptions {
|
||||
fresh?: boolean;
|
||||
dedicated?: boolean;
|
||||
env?: Record<string, string>;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
// The runtime owns a credential for each subprocess; sessions only receive its authenticated client.
|
||||
export class V2Runtime {
|
||||
private current: Promise<Generation> | null = null;
|
||||
private generations = new Set<Generation>();
|
||||
private starts = new Set<Promise<Generation>>();
|
||||
private closed = false;
|
||||
|
||||
constructor(private readonly options: V2RuntimeOptions) {}
|
||||
|
||||
async acquire(input: V2RuntimeAcquireOptions = {}): Promise<V2Connection> {
|
||||
if (this.closed) throw new Error("OpenCode runtime is closed");
|
||||
input.signal?.throwIfAborted();
|
||||
let pending: Promise<Generation>;
|
||||
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<Generation>): 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<void> {
|
||||
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<string, string>): Promise<Generation> {
|
||||
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<string, string> = {}): Promise<Generation> {
|
||||
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<Error>((resolve) =>
|
||||
process.once("exit", (code) => {
|
||||
const error = new Error(`OpenCode helper server exited (${code})`);
|
||||
processAbort.abort(error);
|
||||
resolve(error);
|
||||
}),
|
||||
);
|
||||
let stopped: Promise<void> | 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<string>((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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,424 +0,0 @@
|
||||
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<void> | null = null;
|
||||
private sync: Promise<void> = Promise.resolve();
|
||||
private dirty = false;
|
||||
private refreshTimer: ReturnType<typeof setTimeout> | 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<void>((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<SteerResult> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
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<void>((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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
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();
|
||||
};
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
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");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
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<string, string>();
|
||||
private readonly streams = new Map<string, string>();
|
||||
|
||||
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<SessionMessageInfo, { type: "user" }>,
|
||||
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<SessionMessageInfo, { type: "assistant" }>,
|
||||
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<SessionMessageInfo, { type: "assistant" }>["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<SessionMessageInfo, { type: "assistant" }>["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<SessionMessageInfo, { type: "assistant" }>["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<SessionMessageInfo, { type: "compaction" }>,
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
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<TurnSnapshot>;
|
||||
clearPermissions(): Promise<void>;
|
||||
}
|
||||
interface Turn {
|
||||
output?: ReturnType<typeof structuredOutput>;
|
||||
id: string;
|
||||
submitted: Promise<void>;
|
||||
completion: Promise<void>;
|
||||
}
|
||||
|
||||
export class SessionTurns {
|
||||
private turn: Turn | null = null;
|
||||
private stopping: Promise<void> | 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<void>((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<SessionTurns["promptInput"]>,
|
||||
accept: () => void,
|
||||
options?: AgentRunOptions,
|
||||
output?: ReturnType<typeof structuredOutput>,
|
||||
) {
|
||||
// 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<SessionTurns["promptInput"]>,
|
||||
options?: AgentRunOptions,
|
||||
output?: ReturnType<typeof structuredOutput>,
|
||||
) {
|
||||
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<string> {
|
||||
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<SteerResult> {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -120,42 +120,6 @@ function readUtf8File(pathname: string): string {
|
||||
|
||||
type PaseoExtensionListener = (event: unknown, context?: unknown) => unknown;
|
||||
|
||||
interface PiSessionEntry {
|
||||
type: "message";
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
message: { role: string; content: unknown };
|
||||
}
|
||||
|
||||
function piUserEntry(input: { id: string; parentId: string | null; text: string }): PiSessionEntry {
|
||||
return {
|
||||
type: "message",
|
||||
id: input.id,
|
||||
parentId: input.parentId,
|
||||
message: { role: "user", content: input.text },
|
||||
};
|
||||
}
|
||||
|
||||
function piAssistantEntry(id: string, parentId: string): PiSessionEntry {
|
||||
return { type: "message", id, parentId, message: { role: "assistant", content: [] } };
|
||||
}
|
||||
|
||||
// Pi's context path: the entries from the root to the leaf.
|
||||
function piBranchTo(entries: PiSessionEntry[], leafId: string): PiSessionEntry[] {
|
||||
const byId = new Map(entries.map((entry) => [entry.id, entry]));
|
||||
const branch: PiSessionEntry[] = [];
|
||||
for (let entry = byId.get(leafId); entry; entry = byId.get(entry.parentId ?? "")) {
|
||||
branch.unshift(entry);
|
||||
}
|
||||
return branch;
|
||||
}
|
||||
|
||||
function parseEntryCapture(notification: string): unknown {
|
||||
const prefix = "PASEO_ENTRY_CAPTURE ";
|
||||
expect(notification.startsWith(prefix)).toBe(true);
|
||||
return JSON.parse(notification.slice(prefix.length));
|
||||
}
|
||||
|
||||
async function loadPaseoExtensionListeners(
|
||||
extensionPath: string,
|
||||
): Promise<Map<string, PaseoExtensionListener>> {
|
||||
@@ -957,8 +921,7 @@ describe("PiRpcAgentSession", () => {
|
||||
})) as PiRpcAgentSession;
|
||||
const events = new SessionEvents(session);
|
||||
const fakeSession = pi.latestSession();
|
||||
fakeSession.treeUserEntries = [{ id: "entry-old", parentId: null, text: "old prompt" }];
|
||||
fakeSession.contextUserEntries = fakeSession.treeUserEntries;
|
||||
fakeSession.capturedUserEntries = [{ id: "entry-old", parentId: null, text: "old prompt" }];
|
||||
|
||||
await session.startTurn("new prompt", { clientMessageId: "client-new" });
|
||||
fakeSession.finishSubmittedUserMessage({
|
||||
@@ -1548,47 +1511,6 @@ describe("PiRpcAgentSession", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("captures the session's user entries apart from the ones on the current branch", async () => {
|
||||
const pi = new FakePi();
|
||||
const session = await createClient(pi).createSession(createConfig());
|
||||
onTestFinished(() => session.close());
|
||||
const listeners = await loadPaseoExtensionListeners(pi.recordedLaunches[0]!.extensionPaths[0]!);
|
||||
// "abandoned" was rewound; "two" was sent from the same parent afterwards.
|
||||
const entries = [
|
||||
piUserEntry({ id: "one", parentId: null, text: "first" }),
|
||||
piAssistantEntry("one-reply", "one"),
|
||||
piUserEntry({ id: "abandoned", parentId: "one-reply", text: "rewound away" }),
|
||||
piAssistantEntry("abandoned-reply", "abandoned"),
|
||||
piUserEntry({ id: "two", parentId: "one-reply", text: "second" }),
|
||||
piAssistantEntry("two-reply", "two"),
|
||||
];
|
||||
const notifications: string[] = [];
|
||||
const context = {
|
||||
sessionManager: {
|
||||
getEntries: () => entries,
|
||||
buildContextEntries: () => piBranchTo(entries, "two-reply"),
|
||||
},
|
||||
ui: { notify: (message: string) => notifications.push(message) },
|
||||
};
|
||||
|
||||
await listeners.get("session_start")?.({}, context);
|
||||
|
||||
expect(notifications.map(parseEntryCapture)).toEqual([
|
||||
{
|
||||
reason: "session_start",
|
||||
treeEntries: [
|
||||
{ id: "one", parentId: null, text: "first" },
|
||||
{ id: "abandoned", parentId: "one-reply", text: "rewound away" },
|
||||
{ id: "two", parentId: "one-reply", text: "second" },
|
||||
],
|
||||
contextEntries: [
|
||||
{ id: "one", parentId: null, text: "first" },
|
||||
{ id: "two", parentId: "one-reply", text: "second" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("appends agent and daemon prompts after Pi's discovered system prompt", async () => {
|
||||
const pi = new FakePi();
|
||||
const client = createClient(pi);
|
||||
@@ -2583,26 +2505,6 @@ describe("PiRpcAgentClient", () => {
|
||||
expect(pi.recordedLaunches[0]).toMatchObject({ cwd: "/workspace/with-extension" });
|
||||
});
|
||||
|
||||
test("marks the model Pi resolves from its own settings as the catalog default", async () => {
|
||||
const pi = new FakePi();
|
||||
const unconfigured = { provider: "openai", id: "gpt-4", name: "GPT-4", reasoning: false };
|
||||
const configured = { provider: "zai", id: "glm-5.3", name: "GLM-5.3", reasoning: true };
|
||||
pi.queueSessionSetup((session) => {
|
||||
session.models = [unconfigured, configured];
|
||||
session.state = { ...session.state, model: configured };
|
||||
});
|
||||
|
||||
const catalog = await createClient(pi).fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: "/workspace/project",
|
||||
force: false,
|
||||
});
|
||||
|
||||
expect(catalog.models.filter((model) => model.isDefault).map((model) => model.id)).toEqual([
|
||||
"zai/glm-5.3",
|
||||
]);
|
||||
});
|
||||
|
||||
test("honors per-model Pi thinking maps and clamps the catalog default upward", async () => {
|
||||
const pi = new FakePi();
|
||||
pi.queueSessionSetup((session) => {
|
||||
@@ -2934,11 +2836,10 @@ describe("PiRpcAgentClient", () => {
|
||||
|
||||
test("rewinds conversation through the Pi tree navigation bridge", async () => {
|
||||
const { pi, session, events } = await createSession();
|
||||
pi.latestSession().treeUserEntries = [
|
||||
pi.latestSession().capturedUserEntries = [
|
||||
{ id: "entry-1", parentId: null, text: "first prompt" },
|
||||
{ id: "entry-3", parentId: "entry-2", text: "second prompt" },
|
||||
];
|
||||
pi.latestSession().contextUserEntries = pi.latestSession().treeUserEntries;
|
||||
|
||||
await session.startTurn("first prompt");
|
||||
pi.latestSession().finishTurn({ role: "assistant", content: [] });
|
||||
@@ -2954,23 +2855,6 @@ describe("PiRpcAgentClient", () => {
|
||||
expect(pi.latestSession().treeNavigationRequests).toEqual(["entry-1"]);
|
||||
});
|
||||
|
||||
test("rewinds a row whose Pi entry compaction summarized away", async () => {
|
||||
const { pi, session } = await createSession();
|
||||
const fakeSession = pi.latestSession();
|
||||
// Compaction summarized entry-1, so the replayed branch starts at entry-3.
|
||||
fakeSession.treeUserEntries = [
|
||||
{ id: "entry-1", parentId: null, text: "first prompt" },
|
||||
{ id: "entry-3", parentId: "entry-2", text: "second prompt" },
|
||||
];
|
||||
fakeSession.contextUserEntries = [
|
||||
{ id: "entry-3", parentId: "entry-2", text: "second prompt" },
|
||||
];
|
||||
|
||||
await session.revertConversation?.({ messageId: "entry-1" });
|
||||
|
||||
expect(fakeSession.treeNavigationRequests).toEqual(["entry-1"]);
|
||||
});
|
||||
|
||||
test("injects MCP servers without replacing the Pi global MCP config", async () => {
|
||||
const agentDir = mkdtempSync(path.join(tmpdir(), "paseo-pi-agent-"));
|
||||
onTestFinished(() => rmSync(agentDir, { recursive: true, force: true }));
|
||||
|
||||
@@ -94,7 +94,6 @@ const DEFAULT_PI_THINKING_LEVEL: PiThinkingLevel = "medium";
|
||||
const PI_BINARY_COMMAND = process.env.PI_COMMAND ?? process.env.PI_ACP_PI_COMMAND ?? "pi";
|
||||
const PASEO_PI_TREE_EXTENSION_COMMAND = "paseo_tree";
|
||||
const PASEO_PI_CAPTURE_EXTENSION_COMMAND = "paseo_capture_entries";
|
||||
const PASEO_PI_REWIND_ENTRY_TYPE = "paseo_rewind";
|
||||
const PASEO_PI_ENTRY_CAPTURE_MARKER = "PASEO_ENTRY_CAPTURE";
|
||||
const PASEO_PI_SUBMITTED_USER_ENTRY_MARKER = "PASEO_SUBMITTED_USER_ENTRY";
|
||||
const PASEO_PI_COMMAND_RESULT_MARKER = "PASEO_COMMAND_RESULT";
|
||||
@@ -623,8 +622,9 @@ function createPiPaseoExtensionFile(systemPrompt?: string): PiTempFile {
|
||||
.join("\\n\\n");
|
||||
}
|
||||
|
||||
function toCapturedUserEntries(entries) {
|
||||
return entries
|
||||
function getCapturedUserEntries(ctx) {
|
||||
return ctx.sessionManager
|
||||
.getEntries()
|
||||
.filter((entry) => entry.type === "message" && entry.message?.role === "user")
|
||||
.map(toCapturedUserEntry);
|
||||
}
|
||||
@@ -640,14 +640,7 @@ function createPiPaseoExtensionFile(systemPrompt?: string): PiTempFile {
|
||||
function emitEntryCapture(ctx, reason, requestId) {
|
||||
ctx.ui.notify(
|
||||
"${PASEO_PI_ENTRY_CAPTURE_MARKER} " +
|
||||
JSON.stringify({
|
||||
reason,
|
||||
requestId,
|
||||
// Rewind targets: rows can still show entries that a rewind or compaction left off the branch.
|
||||
treeEntries: toCapturedUserEntries(ctx.sessionManager.getEntries()),
|
||||
// The entries getMessages() replays, so the nth one is the nth replayed user message.
|
||||
contextEntries: toCapturedUserEntries(ctx.sessionManager.buildContextEntries()),
|
||||
}),
|
||||
JSON.stringify({ reason, requestId, entries: getCapturedUserEntries(ctx) }),
|
||||
"info",
|
||||
);
|
||||
}
|
||||
@@ -727,8 +720,6 @@ function createPiPaseoExtensionFile(systemPrompt?: string): PiTempFile {
|
||||
const payload = decodePayload(args.trim());
|
||||
try {
|
||||
const result = await ctx.navigateTree(payload.targetId, { summarize: false });
|
||||
// Pi reopens a session at its last entry, so record the rewind on the new branch to keep it.
|
||||
pi.appendEntry("${PASEO_PI_REWIND_ENTRY_TYPE}", { targetId: payload.targetId });
|
||||
emitEntryCapture(ctx, "tree_navigation");
|
||||
emitCommandResult(ctx, payload.requestId, { ok: true, result });
|
||||
} catch (error) {
|
||||
@@ -1274,8 +1265,8 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
private readonly pendingPromptResults = new Map<string, boolean>();
|
||||
private readonly pendingSteerSubmissions: PiPendingSteerSubmission[] = [];
|
||||
currentLeafOverrideId: string | null | undefined;
|
||||
private readonly contextUserEntries: PiCapturedEntry[] = [];
|
||||
private readonly treeUserEntriesById = new Map<string, PiCapturedEntry>();
|
||||
private readonly capturedUserEntries: PiCapturedEntry[] = [];
|
||||
private readonly capturedUserEntriesById = new Map<string, PiCapturedEntry>();
|
||||
private readonly pendingExtensionResults = new Map<string, PendingExtensionResult>();
|
||||
private outOfBandCompactionEmit: ((event: AgentStreamEvent) => void) | null = null;
|
||||
private outOfBandCompactionStarted = false;
|
||||
@@ -1484,7 +1475,7 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
yield* streamPiHistory(
|
||||
this.provider,
|
||||
await this.runtimeSession.getMessages(),
|
||||
this.contextUserEntries,
|
||||
this.capturedUserEntries,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1628,7 +1619,7 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
}
|
||||
await this.refreshState().catch(() => undefined);
|
||||
await this.requestEntryCapture("rewind");
|
||||
const targetEntry = this.treeUserEntriesById.get(input.messageId);
|
||||
const targetEntry = this.capturedUserEntriesById.get(input.messageId);
|
||||
if (!targetEntry) {
|
||||
throw new Error(`Pi rewind target ${input.messageId} was not found in captured tree entries`);
|
||||
}
|
||||
@@ -1974,14 +1965,11 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private recordCapturedUserEntries(input: {
|
||||
treeEntries: PiCapturedEntry[];
|
||||
contextEntries: PiCapturedEntry[];
|
||||
}): void {
|
||||
this.contextUserEntries.splice(0, this.contextUserEntries.length, ...input.contextEntries);
|
||||
this.treeUserEntriesById.clear();
|
||||
for (const entry of input.treeEntries) {
|
||||
this.treeUserEntriesById.set(entry.id, entry);
|
||||
private recordCapturedUserEntries(entries: PiCapturedEntry[]): void {
|
||||
this.capturedUserEntries.splice(0, this.capturedUserEntries.length, ...entries);
|
||||
this.capturedUserEntriesById.clear();
|
||||
for (const entry of entries) {
|
||||
this.capturedUserEntriesById.set(entry.id, entry);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2017,12 +2005,10 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
if (!payload) {
|
||||
return false;
|
||||
}
|
||||
this.recordCapturedUserEntries({
|
||||
treeEntries: parseCapturedEntries(payload.treeEntries),
|
||||
contextEntries: parseCapturedEntries(payload.contextEntries),
|
||||
});
|
||||
const entries = parseCapturedEntries(payload.entries);
|
||||
this.recordCapturedUserEntries(entries);
|
||||
if (typeof payload.requestId === "string") {
|
||||
this.resolveExtensionResult(payload.requestId, undefined);
|
||||
this.resolveExtensionResult(payload.requestId, entries);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -2678,23 +2664,12 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
});
|
||||
if (!runtimeSession) throw new Error("Pi catalog runtime did not start");
|
||||
const catalogSession = runtimeSession;
|
||||
const piModels = await runProviderRefreshActivity(context, "get_available_models", () =>
|
||||
catalogSession.getAvailableModels(null),
|
||||
);
|
||||
// A fresh Pi session starts on the model Pi resolves from its own settings.
|
||||
const { model: configuredModel } = await runProviderRefreshActivity(
|
||||
context,
|
||||
"get_state",
|
||||
() => catalogSession.getState(),
|
||||
);
|
||||
const models = transformPiModels(
|
||||
piModels.map((model) => {
|
||||
const mapped = mapPiModel(model, PI_PROVIDER);
|
||||
const isConfigured =
|
||||
model.provider === configuredModel?.provider && model.id === configuredModel?.id;
|
||||
if (isConfigured) mapped.isDefault = true;
|
||||
return mapped;
|
||||
}),
|
||||
(
|
||||
await runProviderRefreshActivity(context, "get_available_models", () =>
|
||||
catalogSession.getAvailableModels(null),
|
||||
)
|
||||
).map((model) => mapPiModel(model, PI_PROVIDER)),
|
||||
);
|
||||
return { models, modes: [] };
|
||||
} finally {
|
||||
|
||||
@@ -108,10 +108,7 @@ export class FakePiSession implements PiRuntimeSession {
|
||||
readonly handoffRequests: Array<{ customInstructions?: string }> = [];
|
||||
readonly sessionNameRequests: string[] = [];
|
||||
readonly rawFrames: Array<object & { type: string }> = [];
|
||||
// Every user entry in the session file, including rewound and compacted ones.
|
||||
treeUserEntries: FakePiUserEntry[] = [];
|
||||
// The user entries on the current branch that getMessages() replays.
|
||||
contextUserEntries: FakePiUserEntry[] = [];
|
||||
capturedUserEntries: Array<{ id: string; parentId: string | null; text: string }> = [];
|
||||
abortRequested = false;
|
||||
readonly canceledExtensionUiRequests: string[] = [];
|
||||
readonly extensionUiResponses: Array<{
|
||||
@@ -457,8 +454,7 @@ export class FakePiSession implements PiRuntimeSession {
|
||||
message: `PASEO_ENTRY_CAPTURE ${JSON.stringify({
|
||||
reason,
|
||||
requestId,
|
||||
treeEntries: this.treeUserEntries,
|
||||
contextEntries: this.contextUserEntries,
|
||||
entries: this.capturedUserEntries,
|
||||
})}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1885,9 +1885,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
|
||||
}
|
||||
}
|
||||
|
||||
const PROMPTED_AGENT_NOTIFICATION_GUIDANCE =
|
||||
"You will get notified when the prompted agent finishes, errors, or needs permission. Do not poll for status; continue with other work until the notification arrives.";
|
||||
|
||||
registerTool(
|
||||
"send_agent_prompt",
|
||||
{
|
||||
@@ -1910,19 +1907,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
|
||||
background = Boolean(callerAgentId),
|
||||
notifyOnFinish = Boolean(callerAgentId),
|
||||
}) => {
|
||||
function armFinishNotification(): boolean {
|
||||
if (!callerAgentId || !notifyOnFinish) {
|
||||
return false;
|
||||
}
|
||||
setupFinishNotification({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
childAgentId: agentId,
|
||||
callerAgentId,
|
||||
logger: childLogger,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
const shouldNotifyOnFinish = Boolean(callerAgentId && notifyOnFinish && background);
|
||||
|
||||
await sendPromptToAgent({
|
||||
agentManager,
|
||||
@@ -1933,24 +1918,27 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
|
||||
logger: childLogger,
|
||||
});
|
||||
|
||||
if (shouldNotifyOnFinish && callerAgentId) {
|
||||
setupFinishNotification({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
childAgentId: agentId,
|
||||
callerAgentId,
|
||||
logger: childLogger,
|
||||
});
|
||||
}
|
||||
|
||||
// If not running in background, wait for completion
|
||||
if (!background) {
|
||||
const result = await waitForAgentWithTimeout(agentManager, agentId, {
|
||||
waitForActive: true,
|
||||
});
|
||||
// The wait ran out while the agent keeps working, so its result arrives as a
|
||||
// finish notification instead of in this response.
|
||||
const notifying =
|
||||
result.timedOut &&
|
||||
agentManager.getAgent(agentId)?.lifecycle === "running" &&
|
||||
armFinishNotification();
|
||||
|
||||
const responseData = {
|
||||
success: true,
|
||||
status: result.status,
|
||||
lastMessage: result.lastMessage,
|
||||
permission: sanitizePermissionRequest(result.permission),
|
||||
...(notifying ? { guidance: PROMPTED_AGENT_NOTIFICATION_GUIDANCE } : {}),
|
||||
};
|
||||
const validJson = ensureValidJson(responseData);
|
||||
|
||||
@@ -1961,8 +1949,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
|
||||
return response;
|
||||
}
|
||||
|
||||
const notifying = armFinishNotification();
|
||||
|
||||
// Return immediately if background=true
|
||||
// Re-fetch snapshot since the state may have changed
|
||||
const currentSnapshot = agentManager.getAgent(agentId);
|
||||
@@ -1972,7 +1958,12 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
|
||||
status: currentSnapshot?.lifecycle ?? "idle",
|
||||
lastMessage: null,
|
||||
permission: null,
|
||||
...(notifying ? { guidance: PROMPTED_AGENT_NOTIFICATION_GUIDANCE } : {}),
|
||||
...(shouldNotifyOnFinish
|
||||
? {
|
||||
guidance:
|
||||
"You will get notified when the prompted agent finishes, errors, or needs permission. Do not poll for status; continue with other work until the notification arrives.",
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const validJson = ensureValidJson(responseData);
|
||||
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { claudeProjectDirSync } from "../agent/providers/claude/project-dir.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
|
||||
const PROVIDER_ID = "claude-secondary";
|
||||
const SESSION_ID = "provider-config-dir-session";
|
||||
|
||||
function timelineText(entries: ReadonlyArray<{ item: { type: string; text?: string } }>): string {
|
||||
return entries
|
||||
.filter(
|
||||
(entry): entry is { item: { type: "user_message" | "assistant_message"; text: string } } =>
|
||||
entry.item.type === "user_message" || entry.item.type === "assistant_message",
|
||||
)
|
||||
.map((entry) => entry.item.text)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
describe("daemon E2E - Claude history lives in the provider's CLAUDE_CONFIG_DIR", () => {
|
||||
let tempRoot: string;
|
||||
let paseoHomeRoot: string;
|
||||
let providerConfigDir: string;
|
||||
let cwd: string;
|
||||
let prevClaudeConfigDir: string | undefined;
|
||||
let daemon: TestPaseoDaemon | undefined;
|
||||
let client: DaemonClient | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = mkdtempSync(path.join(tmpdir(), "claude-provider-config-dir-"));
|
||||
paseoHomeRoot = path.join(tempRoot, "paseo-home");
|
||||
providerConfigDir = path.join(tempRoot, "custom-claude-dir");
|
||||
cwd = path.join(tempRoot, "repo");
|
||||
mkdirSync(paseoHomeRoot, { recursive: true });
|
||||
mkdirSync(cwd, { recursive: true });
|
||||
|
||||
const projectDir = claudeProjectDirSync(cwd, { configDir: providerConfigDir });
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(projectDir, `${SESSION_ID}.jsonl`),
|
||||
[
|
||||
{
|
||||
type: "user",
|
||||
uuid: "user-uuid-1",
|
||||
sessionId: SESSION_ID,
|
||||
cwd,
|
||||
message: { role: "user", content: "hello from the custom config dir" },
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
sessionId: SESSION_ID,
|
||||
cwd,
|
||||
message: { role: "assistant", content: "reply from the custom config dir" },
|
||||
},
|
||||
]
|
||||
.map((entry) => `${JSON.stringify(entry)}\n`)
|
||||
.join(""),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
// The daemon's own environment does not name the directory; only the provider's env does.
|
||||
prevClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await client?.close().catch(() => undefined);
|
||||
await daemon?.close().catch(() => undefined);
|
||||
client = undefined;
|
||||
daemon = undefined;
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
if (prevClaudeConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = prevClaudeConfigDir;
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
async function startDaemon(): Promise<DaemonClient> {
|
||||
daemon = await createTestPaseoDaemon({
|
||||
paseoHomeRoot,
|
||||
cleanup: false,
|
||||
providerOverrides: {
|
||||
[PROVIDER_ID]: {
|
||||
extends: "claude",
|
||||
label: "Claude (secondary)",
|
||||
env: { CLAUDE_CONFIG_DIR: providerConfigDir },
|
||||
},
|
||||
},
|
||||
});
|
||||
client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
return client;
|
||||
}
|
||||
|
||||
async function stopDaemon(): Promise<void> {
|
||||
await client?.close();
|
||||
await daemon?.close();
|
||||
client = undefined;
|
||||
daemon = undefined;
|
||||
}
|
||||
|
||||
test("an agent's timeline survives a daemon restart", async () => {
|
||||
const firstClient = await startDaemon();
|
||||
const agent = await firstClient.importAgent({
|
||||
provider: PROVIDER_ID,
|
||||
sessionId: SESSION_ID,
|
||||
cwd,
|
||||
});
|
||||
await stopDaemon();
|
||||
|
||||
const restartedClient = await startDaemon();
|
||||
const timeline = await restartedClient.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
|
||||
const text = timelineText(timeline.entries);
|
||||
expect(text).toContain("hello from the custom config dir");
|
||||
expect(text).toContain("reply from the custom config dir");
|
||||
}, 60_000);
|
||||
|
||||
test("the import list finds the provider's sessions", async () => {
|
||||
const daemonClient = await startDaemon();
|
||||
const recent = await daemonClient.fetchRecentProviderSessions({
|
||||
cwd,
|
||||
providers: [PROVIDER_ID],
|
||||
});
|
||||
|
||||
expect(recent.entries.map((entry) => entry.providerHandleId)).toContain(SESSION_ID);
|
||||
}, 60_000);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user