Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 4f5224122a fix(codex): invoke an inline skill steered into a running turn 2026-09-26 19:54:47 +02:00
paseo-bot[bot] af27e28054 fix(codex): invoke a skill picked from autocomplete after prose
Composer autocomplete inserts a Codex skill as /name anywhere in the
prompt, but only a prompt starting with /name was sent to Codex as a
skill input item. A skill placed after other text reached Codex as plain
text and was never injected.

Resolve /name mentions of enabled skills in the prompt into skill input
items and $name text, the same shape the leading form already sends.

Closes #5008
2026-09-26 17:31:15 +02:00
c906c2f4a4 fix(opencode): support v2 with automatic version selection (#5198)
* fix(opencode): support v2 while preserving v1 provider behavior

* fix(opencode): target v2.0.7, keeping v1 support

OpenCode 2.0.4 removed the /api/health endpoint and the plugin
await-activation endpoint, and moved form reply/cancel under the session
namespace. The v2 adapter was pinned to the 2.0.3 SDK, so any 2.0.4+
binary failed at helper-server startup with an opaque HTTP 404.

- Pin @opencode/client to 2.0.7 and require v2 >= 2.0.4 at runtime
  selection, with a clear error for older binaries.
- Use server.info for helper-server readiness.
- Move form reply/cancel/list to session.form and send the permission
  decision field instead of reply.
- Replace plugin.awaitActivation with a plugin.list readiness wait. A cold
  location registers its config-derived commands, skills, and providers
  asynchronously; an empty inventory means it is still warming.
- Send session.command.name, which replaced command, and drop the removed
  SkillInfo.slash field.
- Update the v2 test harness, unit tests, dependency lockfile, CI matrix,
  and provider docs to 2.0.7.

* fix(opencode): target 2.0.10 and address v2 review findings

* test(opencode): colocate v2 suites with their modules

Greptile's review flagged that the v2 tests lived in top-level
opencode-agent.*.test.ts files and imported the readiness helper, v2
client, harness, and API types from a distance. Move each v2 suite beside
the module it describes and leave the top-level files to the v1 public
path:

- opencode/v2/agent.test.ts: session lifecycle, structured output,
  resume, reconciliation, interrupt/replacement, helper isolation.
- opencode/v2/readiness.test.ts: location readiness deadline behavior.
- opencode/v2/permissions.test.ts: question and approval routing.
- opencode/v2/configuration.test.ts: permissionRules, feature flags, and
  helper-isolation decisions, exercised directly through the module.
- opencode/v2/mapping.test.ts: model and mode normalization.
- opencode/runtime-client.test.ts: runtime version selection.

The v1 recovery, permission-action, and catalog-deadline suites stay in
place. Test counts are preserved, with deeper per-module coverage for
configuration.

* test(opencode): split v2 local E2E by behavior and add harness helpers

The re-review flagged the v2 scenarios in opencode-bridge.local.e2e.test.ts
for inline event filtering, permission-kind branching, and polling in test
bodies. Move stream, permission, and subagent coordination behind helpers
in opencode/test-utils/v2-local-e2e-helpers.ts, and split the session
scenarios into opencode-v2.local.e2e.test.ts so each file covers one
behavior:

- opencode-bridge.local.e2e.test.ts: process sharing, shell.env scoping,
  the native tool bridge, and runtime version selection.
- opencode-v2.local.e2e.test.ts: session lifecycle, structured output,
  streaming, approvals, MCP preapproval, stop/replacement, rewind and
  subagents, commands, and skills/attachments.

Each helper waits on or answers the provider directly, so the tests state
domain intent and assert the collected result instead of polling or
branching inline. The CI-selected "versioned runtime" case stays in the
bridge file, and the live approvals and stop/replacement scenarios were
re-run green through the new helpers.

* fix(ci): preserve required server checks across OpenCode versions

Run v1 and v2 integration coverage sequentially so branch protection keeps its existing static check names.

* fix(agents): load startup history before registering sessions

A failed history stream while adding a runtime notice must close the unregistered session instead of leaving an initializing agent behind.

* fix(agents): limit startup history before buffering it

Apply the existing timeline content limits to each incoming history event so raw cumulative tool output is not retained until registration completes.

* fix(opencode): resolve Windows executables from platform packages

Recent v1 npm releases place opencode.exe in the architecture dependency rather than the CLI bin directory. Resolve that dependency from the CLI package while retaining support for the older layout.

* refactor(opencode): name public runtime option types

* test(opencode): let the spec own both runtime versions

Install each version in an isolated temporary directory and select it through provider configuration. Restore the CI workflow unchanged so version coverage is owned by the spec rather than workflow orchestration.

* test(opencode): launch native Windows binaries in isolated specs

Avoid npm command wrappers retaining locked executable files after helper shutdown during temporary installation cleanup.

* fix(opencode): preserve the legacy path when adding v2 support

Fall back to the existing client when version probing is inconclusive. Load v2 code and its bridge plugin only after positive v2 selection, keep v1 sessions undecorated, and restore original v1 discovery errors.

* fix(opencode): retry inconclusive version selection

A failed or unrecognized version probe previously cached the legacy fallback, pinning an upgraded binary to the v1 API until client recreation. Retry detection on the next operation while keeping the legacy path available.

* fix(opencode): release legacy runtime after inconclusive probe

Retryable version detection can clear the selected client after falling back to v1. Track whether the legacy client was selected so shutdown still releases its server without touching an unused v1 manager during a v2-only run.

* test(opencode): assert full runtime notice history

* fix(opencode): restore notice and retry behavior after rebase

---------

Co-authored-by: Jonathan karras <6307688+karrots@users.noreply.github.com>
Co-authored-by: Dhruv Singal <dhruvsingalabc@gmail.com>
2026-09-26 17:18:31 +02:00
paseo-bot[bot] fffd76e8fb List a custom Codex provider's sessions in Import session (#5446)
A provider that extends codex with its own OPENAI_BASE_URL starts its
threads under its own Codex model provider id. Codex's thread/list returns
only the model provider its config selects unless the request names
others, and the import scan named none, so those sessions were missing
from the import list. After switching the default provider in the Codex
config, stock Codex listed them instead and imported them onto itself.

Ask Codex for the custom provider's own threads when listing its
importable sessions. Stock Codex keeps its current listing.

Closes #5381

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-26 17:15:53 +02:00
Mohamed Boudra d2e2154a8a fix(app): keep restored workspaces during directory refresh (#5394)
* fix(app): keep restored workspaces during directory refresh

Capture workspace maps and cursors from one transaction baseline so cache hydration cannot pair an empty map with a current cursor. Reject legacy directory checkpoints to recover caches already persisted with missing workspaces.

* test(app): clarify workspace cache recovery flow
2026-09-26 16:58:53 +02:00
paseo-bot[bot] cb9654a65c fix(server): keep the daemon running when daemon.log cannot be written (#5445)
A failed write to daemon.log (for example ENOSPC on a full disk) made the
supervisor's rotating log stream emit an unhandled 'error' event, which
killed the supervisor; the worker then exited on lost supervisor liveness.

The supervisor now treats daemon.log as best effort: a failed stream is
reported once on stderr, dropped, and reopened on the next log line, so
logging resumes once the file is writable again.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-26 15:56:42 +02:00
paseo-bot[bot]andqinkangdeid 3dc17b7f30 fix(acp): keep the daemon running when an ACP agent cannot start (#5439)
Opening an archived ACP agent whose worktree was removed spawned the
provider in the missing directory. The session spawn had no listener for
the child's error event, so Node's ENOENT became an uncaught exception
and the daemon restarted, then crashed again when the client retried.

The session spawn now rejects initialization on a spawn error, the same
way the provider probe already did, and both share one helper.

Closes #5435

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: qinkangdeid <qinkangdeidd@gmail.com>
2026-09-26 15:10:44 +02:00
paseo-bot[bot] 76a9781ba5 Read Claude history from the provider's own config directory (#5437)
A provider that extends claude and sets CLAUDE_CONFIG_DIR in its env
writes transcripts and settings.json under that directory, but the
daemon looked them up under its own CLAUDE_CONFIG_DIR or ~/.claude.
Agents on that provider reopened with an empty timeline after a daemon
restart, their sessions were missing from the import list, and models
from its settings.json were missing from the catalog.

Resolve the config directory from the same environment the provider is
launched with, and drop the test-only configDir client option.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-26 13:57:27 +02:00
paseo-bot[bot] a272a22d7e fix(opencode): hide user messages OpenCode marks synthetic (#5434)
OpenCode adds text to user messages on its own behalf (continue prompts after
compaction or a subtask, MCP resource contents, plugin prompts) and flags that
text `synthetic: true`. Its own UI hides it. Paseo built user bubbles from every
text part, so reopening an OpenCode chat showed those messages as if the user
had typed them, and appended synthetic text to the user's own message.

Closes #5425

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-26 13:24:18 +02:00
paseo-bot[bot] 26f227bed9 fix(pi): keep a chat rewind after the daemon restarts (#5432)
Pi's navigateTree without a summary only moves the session's leaf in
memory and appends nothing to the session file. Pi reopens a session at
the file's last entry, so after a daemon restart the resumed chat showed
the messages the rewind had removed.

After navigating, the Paseo extension now appends a paseo_rewind custom
entry on the new branch. Custom entries carry no model context, and the
file's last entry now sits on the rewound branch, so Pi reopens there.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-26 11:58:43 +02:00
paseo-bot[bot] aa3ffaeb6c Refresh project skills in the / menu after the checkout's branch changes (#5415)
Draft slash commands are cached per provider and checkout with no expiry, so a skill from the previous branch stayed listed (or a new branch's skill stayed hidden) until the app restarted. A pushed checkout status that reports a different branch now drops that checkout's cached draft commands, and the next / menu fetches them again.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-26 11:06:22 +02:00
paseo-bot[bot] 513f2a9ea2 List a generic ACP agent's slash commands in a new agent's / menu (#5411)
* List a generic ACP agent's slash commands in a new agent's / menu

Generic ACP agents advertise slash commands with available_commands_update
after session/new. The new-agent / menu opens a short-lived session and asks
for its commands right after session/new, so it always got an empty list,
which the app keeps for the draft. Wait for the first batch, bounded by the
existing initial-commands timeout, as the Cursor, Kiro, and TRAE clients do.

Closes #4759

* Keep the generic ACP command test independent of scheduling

* Wait for agent state writes before removing MCP test temp dirs

The blocking send_agent_prompt timeout test removed its temp dir while
AgentManager background tasks and AgentStorage writes were still in
flight, which intermittently failed cleanup with ENOTEMPTY on Linux CI.
Flush both before removing the dir in the tests that use real storage.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-26 10:19:39 +02:00
paseo-bot[bot] 8cd989529e Notify a caller once when it prompts a child that is still running (#5407)
* Notify a caller once when it prompts a child that is still running

create_agent and a background send_agent_prompt each armed their own
finish notification for the same child and caller. When the second
prompt replaces the child's running turn, the child never goes idle in
between, so its one finish fired both and the caller got the same
notification twice. Arming again for a child and caller now replaces
the earlier notification.

Closes #5397

* Declare the notification test helper as a function

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-25 17:02:10 +02:00
paseo-bot[bot] 04c3e003f8 Keep model picker rows free of nested buttons on web (#5404)
* Keep the model row's profile action out of the row button on web

On web the model row renders as a <button>, and the create/edit profile
action inside it rendered a second <button>, so React logged "<button>
cannot be a descendant of <button>" for every row. The action now sits
beside the row button inside the same hover boundary, positioned over a
slot the row reserves, so layout and hover reveal are unchanged.

Closes #5385

* Check the create-profile action leaves the composer's model unchanged

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-25 16:19:28 +02:00
paseo-bot[bot] e68553f759 Run agents on another daemon from inside an agent session (#5392)
* Run agents on another daemon from inside an agent session

paseo run sent PASEO_AGENT_ID as the caller to whichever daemon --host or
--home pointed at. That id names an agent on the daemon that launched the
shell, so another daemon rejected the run with "Caller agent <id> not
found". The run now claims the caller only when the target daemon has
that agent, and otherwise runs as a top-level agent there.

Closes #5313

* Keep the caller when the agent lookup loses its connection

Only the daemon's answer that it has no such agent drops the caller. A
lost or timed-out connection now fails the run instead of creating a
top-level agent on the caller's own daemon.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-25 14:50:04 +02:00
paseo-bot[bot]andliujin0506 dc9799f6fc Open OSC 8 terminal links the same way as plain URLs (#5388)
* Open OSC 8 terminal hyperlinks like plain URLs

Clicking a link a CLI printed with OSC 8 (gh, npm, ls --hyperlink)
showed xterm's "Do you want to navigate to" prompt and then opened a
bare Paseo window, while the same URL printed as plain text opened in
the system browser. The terminal runtime now gives xterm a link handler
that sends OSC 8 links to the same external opener as plain URLs.

Closes #4768

Co-authored-by: liujin0506 <liujin0506@users.noreply.github.com>

* Name the hyperlink and line writes in the terminal link test

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: liujin0506 <liujin0506@users.noreply.github.com>
2026-09-25 13:54:49 +02:00
paseo-bot[bot] 43a2a7969c Rewind a Pi chat to the message you pick after an earlier rewind (#5383)
* Rewind a Pi chat to the message you pick after an earlier rewind

After a rewind, Pi's session file keeps the abandoned branch. Paseo
captured user entry ids from the whole file but replayed history from
the current branch, pairing them by position. Each abandoned user
message shifted later rows onto an earlier entry id, so rewinding a
replayed row went back further than the selected message.

Capture user entries from the same compaction-aware branch Pi replays,
so the nth capture is the nth replayed user message.

* Assert Pi entry captures as data with named test helpers

* Name the fields of Pi user entry test fixtures

* Rewind a Pi row whose entry compaction summarized away

The capture fed both replay pairing and the rewind target lookup. Narrowing
it to the context branch broke rewinding a live row from before /compact,
because that row's entry is off the branch. The extension now reports the
session's user entries for the lookup apart from the branch entries replay
pairs with.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-25 13:16:40 +02:00
Joe Shull 90d978ab68 fix(omp): preserve described select options (#3628)
* fix(omp): preserve described select options

* test(omp): assert exact option arrays instead of toMatchObject

toMatchObject ignores extra/missing keys, so the described-select and
malformed-metadata tests passed even with option-index misalignment or
an unfiltered whitespace-only description. Switched to toStrictEqual
on the options array so both are actually caught.
2026-09-25 10:25:19 +02:00
paseo-bot[bot] a048094da4 Scroll the theme list when plugin themes overflow the window (#5374)
* fix(app): scroll the theme list when plugin themes overflow the window

The theme menu lists every plugin-contributed theme but did not opt into
the menu surface's scrollable mode, so its height was never capped to the
window. A theme pack with dozens of entries pushed the lower themes off
screen with no way to reach them.

* test(app): scroll the theme menu through a named helper

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-25 09:14:24 +02:00
paseo-bot[bot] 315803688d Update and restart a daemon whose provider checks take over 1.5s (#5372)
The host settings Update and Restart buttons read the worker's status
before sending the request, with a 1500 ms timeout. The daemon answers
status only after probing each provider CLI with --version, which may
take up to 2 s per provider. On a host where one provider CLI starts
slowly, the pre-check always timed out and the request was never sent.

Read the status with the client's default request timeout, the same
full status read the supervisor restart test already exercises.

Closes #5367

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-25 08:10:09 +02:00
paseo-bot[bot] bf4dc22c2f Explain that daemon set-password needs a terminal when stdin is piped (#5358)
* Explain that daemon set-password needs a terminal when stdin is piped

The password prompt only resolves on a keypress from a terminal. With
stdin piped or redirected it never settled, so Node exited 13 with an
unsettled top-level await warning and no message. The command now fails
with PASSWORD_TTY_REQUIRED and points at PASEO_PASSWORD.

Closes #5350

* Assert the set-password terminal error message

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-25 06:54:28 +02:00
paseo-bot[bot] 5c9f767d51 Notify the caller when a blocking send_agent_prompt outlasts its 30s wait (#5347)
* fix(server): notify the caller when a blocking send_agent_prompt outlasts its wait

An agent-scoped send_agent_prompt with background: false only armed the
finish notification for background sends. When the 30s wait timed out,
the tool returned "still running" and nothing was armed, so the child's
result never reached the caller. The wait now reports whether it timed
out, and a timed-out wait on a still-running agent arms the same
notification a background send gets and says so in guidance. A blocking
send that finishes inside the wait still returns its result without a
notification.

* refactor(server): declare the finish-notification helper as a function

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-25 05:00:12 +02:00
paseo-bot[bot] 84304b5532 Start model-less Pi agents on Pi's configured default model (#5343)
A Pi agent created without a model took the first entry of Pi's model
list, which ignores defaultProvider/defaultModel in Pi's settings. The Pi
catalog now marks the model a fresh Pi session reports in get_state as
the default, so the daemon's default-model fallback picks it.

Closes #4918

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-25 01:59:26 +02:00
paseo-bot[bot] 28507224dc Show the date for a message from today's weekday last week (#5341)
A reply sent last Friday at 12:02 PM, read on Friday at 11:47 AM, was
labelled "Friday 12:02 PM" because the weekday window counted 24-hour
periods, and that reply was just under seven of them old. Count calendar
days instead, so the weekday form covers only the previous six days and
today's weekday a week back shows its date.

Closes #5225

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-25 00:54:41 +02:00
paseo-bot[bot] db4fd33406 Reconnect OpenCode agents to the new server after the old one exits (#5338)
* Reconnect OpenCode agents to the new server after the old one exits

When the OpenCode server process exits, Paseo starts the next one on a
new port, but existing sessions kept their client bound to the old port
and failed every turn with ECONNREFUSED until the agent was reloaded.

A session now marks its connection dead when the server exits and
connects to the current server before its next turn, rewind, history
read, mode or command lookup.

Closes #5307

* Settle the exited OpenCode server's events before reconnecting

A queued exit from the old server could otherwise fail a turn started on
the new one.

* Re-add a reconnected OpenCode session's MCP servers on the new server

reconnect() reset mcpConfigured but left mcpSetupPromise holding the
first server's resolved setup, so the next turn awaited it and never
called mcp.add on the new server. Track MCP setup with one field that
reconnect clears.

* Re-run CI after an unrelated macOS file-observation flake

* Re-run CI after an unrelated website WebKit timeout

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-25 00:02:53 +02:00
Mohamed Boudra e3c853df58 fix(android): avoid lint memory pressure in store builds 2026-09-24 12:46:57 +02:00
paseo-ai[bot] ea7a741851 fix: update lockfile signatures and Nix hash [skip ci] 2026-09-24 10:20:03 +00:00
102 changed files with 8248 additions and 344 deletions
+7 -3
View File
@@ -69,7 +69,7 @@ model; it does not override the model list returned by a resolver.
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (`providers/omp/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode/runtime-client.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (`providers/omp/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
Claude first-party model metadata lives in `packages/server/src/server/agent/providers/claude/model-manifest.ts`. When adding or updating a Claude model, update that manifest only; the model picker thinking options and Claude-specific feature gates are derived from the manifest. Do not add model-specific Claude capability lists in feature code.
@@ -95,9 +95,13 @@ OMP supports native Paseo host tools. The adapter registers the full caller-scop
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
OpenCode 1 keeps MCP and process environment outside the session boundary. Paseo shares one OpenCode server for ordinary agents and installs a daemon-owned plugin through `OPENCODE_CONFIG_CONTENT`. The plugin reads the exact agent environment and caller-scoped Paseo tool catalog from the daemon's private loopback bridge for each OpenCode session. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifact contains no session data or secrets.
OpenCode adapters target v1.14.46 and v2.0.10. V2 rejects binaries older than the tested 2.0.10 SDK at runtime selection. Runtime selection uses the configured command and environment. A recognized version is cached until provider configuration reload; a failed, timed-out, or unrecognized probe retains the v1 path and retries detection on the next operation. Load v2 code and materialize its plugin only after positive v2 selection; v1 sessions remain undecorated. Keep upstream SDK types inside the version-specific adapter. OpenCode owns storage migration; a missing native session must fail resume rather than create a replacement. V2 has no native archive/unarchive operation: archiving affects Paseo only. V1 retains native archiving.
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. Keep that isolation until OpenCode exposes those values as session-owned configuration. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers.
V2.0.4 also removed the activation endpoint that gated a cold location, and a cold location registers its config-derived commands, skills, and providers asynchronously. Wait until `plugin.list` returns a populated inventory before reading the catalog or commands; an empty inventory means the location is still warming. Fail when the readiness deadline expires, including when an inventory request stalls.
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. V2 supports session environment, but its MCP configuration remains location-scoped, so separate servers still prevent one agent from changing another agent's MCP setup. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers.
OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prompt APIs; let OpenCode create `msg*` IDs and record the user timeline item from the `message.updated` event.
+1 -1
View File
@@ -1 +1 @@
sha256-9UWtpZrCdyYyGq3HGNgSpU1+2Imu3oYqtSumq2DtANc=
sha256-UXnB6q5tubKpTs+A5+u/NLSzc8ZK6rAsQs+kEphEKd8=
+239 -24
View File
@@ -42,6 +42,13 @@
"ws": "^8.20.0"
}
},
"node_modules/7zip-bin": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz",
"integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==",
"dev": true,
"license": "MIT"
},
"node_modules/@0no-co/graphql.web": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.2.0.tgz",
@@ -8271,6 +8278,84 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz",
"integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz",
"integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz",
"integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz",
"integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz",
"integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz",
"integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
@@ -8644,6 +8729,48 @@
"node": ">=20.0"
}
},
"node_modules/@opencode/client": {
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/@opencode/client/-/client-2.0.10.tgz",
"integrity": "sha512-Pz4FD6jVtfZYX97AghVTM+cedi3HfI+U6gg/we5Luf08L85TewKsSXR4jHOZTCvggl0vACTquz1/hOjsJWE0Sg==",
"license": "MIT",
"dependencies": {
"@opencode/protocol": "2.0.10",
"@opencode/schema": "2.0.10"
},
"peerDependencies": {
"effect": "4.0.0-rc.112",
"solid-js": ">=1.9.0"
},
"peerDependenciesMeta": {
"effect": {
"optional": true
},
"solid-js": {
"optional": true
}
}
},
"node_modules/@opencode/protocol": {
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/@opencode/protocol/-/protocol-2.0.10.tgz",
"integrity": "sha512-cHr0aSK39Q9c3JwPDvG45FFlE6rU/ntTFDSaqm4LRl74tki88G4WpzKk6Q56G06K6hKIONrezcPdXtDKtnZzyw==",
"license": "MIT",
"dependencies": {
"@opencode/schema": "2.0.10",
"effect": "4.0.0-rc.112"
}
},
"node_modules/@opencode/schema": {
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/@opencode/schema/-/schema-2.0.10.tgz",
"integrity": "sha512-RpRhRZFH1YFnM+OcrxOZc2ObIW3RQ63z/LkcHrZKa47kWF0OiaZVkbAOITeAoAoofcnS3O5ekZBvgT/7kBwRUA==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "1.1.0",
"effect": "4.0.0-rc.112"
}
},
"node_modules/@opentelemetry/api": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
@@ -14263,13 +14390,6 @@
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/7zip-bin": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz",
"integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==",
"dev": true,
"license": "MIT"
},
"node_modules/abab": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz",
@@ -18622,6 +18742,16 @@
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/effect": {
"version": "4.0.0-rc.112",
"resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-rc.112.tgz",
"integrity": "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A==",
"license": "MIT",
"dependencies": {
"fast-check": "^4.9.0",
"msgpackr": "^2.0.5"
}
},
"node_modules/ejs": {
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
@@ -21866,6 +21996,28 @@
"node": ">=18"
}
},
"node_modules/fast-check": {
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.0.tgz",
"integrity": "sha512-hhqQL+IJllZi3aM4TKvmCj3bywLEcycNTTLZeLhA9ttMxBrCqM07q7Di4kl+j9EWSTXvJH1+EpIgsDbF/+8H5Q==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT",
"dependencies": {
"pure-rand": "^8.0.0"
},
"engines": {
"node": ">=12.17.0"
}
},
"node_modules/fast-copy": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz",
@@ -28141,6 +28293,37 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/msgpackr": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.1.0.tgz",
"integrity": "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==",
"license": "MIT",
"optionalDependencies": {
"msgpackr-extract": "^3.0.4"
}
},
"node_modules/msgpackr-extract": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz",
"integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-gyp-build-optional-packages": "5.2.2"
},
"bin": {
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
},
"optionalDependencies": {
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4",
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4"
}
},
"node_modules/multipasta": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz",
@@ -28466,6 +28649,21 @@
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/node-gyp-build-optional-packages": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.1"
},
"bin": {
"node-gyp-build-optional-packages": "bin.js",
"node-gyp-build-optional-packages-optional": "optional.js",
"node-gyp-build-optional-packages-test": "build-test.js"
}
},
"node_modules/node-gyp/node_modules/chownr": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
@@ -30141,6 +30339,22 @@
"node": ">=6"
}
},
"node_modules/pure-rand": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz",
"integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT"
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
@@ -33127,23 +33341,6 @@
"node": ">=4"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/string_decoder/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true,
"license": "MIT"
},
"node_modules/string-length": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/string-length/-/string-length-5.0.1.tgz",
@@ -33318,6 +33515,23 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/string_decoder/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true,
"license": "MIT"
},
"node_modules/stringify-entities": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
@@ -38995,6 +39209,7 @@
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
"@opencode/client": "2.0.10",
"@xterm/headless": "^6.0.0",
"ai": "5.0.78",
"ajv": "^8.20.0",
@@ -6,13 +6,16 @@ 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";
@@ -115,4 +118,34 @@ 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,4 +1,7 @@
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 {
@@ -25,3 +28,39 @@ 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,6 +2,7 @@ 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";
@@ -120,3 +121,75 @@ 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,4 +1,5 @@
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 {
@@ -11,6 +12,7 @@ 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";
@@ -108,7 +110,207 @@ 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,6 +482,50 @@ 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> {
+2 -1
View File
@@ -19,7 +19,8 @@
"APP_VARIANT": "production"
},
"android": {
"resourceClass": "large"
"resourceClass": "large",
"gradleCommand": ":app:bundleRelease -x lint -x lintVitalAnalyzeRelease -x lintVitalRelease -x generateReleaseLintModel -x generateReleaseLintVitalModel"
}
},
"production-apk": {
+17 -11
View File
@@ -625,13 +625,6 @@ 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,
@@ -645,7 +638,7 @@ function ModelRowProfileAction({
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<Pressable
onPress={handlePress}
onPress={onPress}
hitSlop={8}
style={pressableStyle}
pointerEvents={visible ? "auto" : "none"}
@@ -778,7 +771,6 @@ function ModelRow({
const pressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.browserRow,
styles.browserModelRow,
Boolean(hovered) && styles.browserRowHovered,
pressed && styles.browserRowPressed,
],
@@ -787,7 +779,7 @@ function ModelRow({
return (
<View
style={styles.modelRowHoverBoundary}
style={[styles.modelRowHoverBoundary, styles.browserModelRow]}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
>
@@ -815,10 +807,17 @@ function ModelRow({
<ThemedCheck size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
) : null}
</View>
{profileAction}
{profileAction ? <View style={styles.rowIconButton} /> : null}
</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>
);
}
@@ -1590,6 +1589,13 @@ 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,6 +16,7 @@ import {
resolveWorkingDiffComparison,
selectWorkingDiffComparison,
} from "@/git/working-diff-comparison";
import { draftAgentCommandsQueryKey } from "@/hooks/agent-commands-query";
import {
applyCheckoutStatusUpdateFromEvent,
ensureCheckoutStatus,
@@ -197,6 +198,51 @@ 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,6 +8,7 @@ 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"];
@@ -67,7 +68,13 @@ 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,9 +38,10 @@ describe("agent command query keys", () => {
"agentCommands",
"server-1",
"draft",
"codex",
"cwd",
"/repo",
"provider",
"codex",
"mode",
null,
"model",
+22 -4
View File
@@ -1,3 +1,4 @@
import type { QueryClient } from "@tanstack/react-query";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
@@ -24,17 +25,24 @@ 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 [
...agentCommandsQueryRoot(input.serverId),
"draft",
...draftAgentCommandsCheckoutScope({ serverId: input.serverId, cwd: draftConfig.cwd }),
"provider",
draftConfig.provider,
"cwd",
normalizeAgentCommandsCwd(draftConfig.cwd),
"mode",
draftConfig.modeId ?? null,
"model",
@@ -46,6 +54,16 @@ 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,6 +44,7 @@ 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>
@@ -82,6 +83,14 @@ 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;
@@ -116,6 +125,11 @@ 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",
@@ -227,6 +241,25 @@ 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();
@@ -1115,6 +1148,41 @@ 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);
@@ -1182,9 +1250,19 @@ describe("DirectorySync session readiness", () => {
},
},
});
client.emit({
type: "workspace_update",
payload: {
kind: "upsert",
workspace: { ...createWorkspaceEntry("updated-workspace"), name: "Live name" },
},
});
completeFetch({
requestId: "workspaces",
entries: [],
entries: [
createWorkspaceEntry("removed-workspace"),
createWorkspaceEntry("updated-workspace"),
],
emptyProjects: [
{
projectId: "snapshot-project",
@@ -1207,6 +1285,11 @@ 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();
});
@@ -1278,6 +1361,8 @@ function createSqliteCache() {
return {
cache,
database,
storage,
reopen: () => new ReplicaCache(storage, { clearLegacyCache: async () => {} }),
holdRead: (operation: () => Promise<void>) => {
beforeRead = operation;
},
@@ -1473,3 +1558,198 @@ 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,6 +61,10 @@ 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;
@@ -133,7 +137,7 @@ export class DirectorySync {
AgentDirectoryDelta
>();
private readonly workspaceTransactions = new DirectoryTransactionOwner<
WorkspaceDirectorySnapshot,
WorkspaceRefreshSnapshot,
WorkspaceDirectoryDelta
>();
private readonly agents: AgentDirectoryReplica;
@@ -478,14 +482,20 @@ export class DirectorySync {
const onlineConnection = this.getOnlineConnection();
if (!onlineConnection) return;
const { client, source } = onlineConnection;
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(),
}));
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(),
};
});
try {
await this.waitForSessionMetadata(client, source);
const serverInfo = useSessionStore.getState().sessions[this.serverId]?.serverInfo;
@@ -517,7 +527,7 @@ export class DirectorySync {
private async fetchWorkspaceSnapshot(
client: DaemonClient,
source: DirectorySourceToken,
transaction: DirectoryTransaction<WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta>,
transaction: DirectoryTransaction<WorkspaceRefreshSnapshot, WorkspaceDirectoryDelta>,
initialSubscribe: boolean,
supportsDirectorySync: boolean,
): Promise<void> {
@@ -527,7 +537,9 @@ 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: this.readCursors().workspaces ?? {} } : {}),
...(supportsDirectorySync
? { sync: transaction.snapshot.requestCursors.workspaces ?? {} }
: {}),
};
let payload: FetchWorkspacesPayload;
if (subscribe) {
@@ -667,7 +679,7 @@ export class DirectorySync {
private assertWorkspaceTransactionCurrent(
client: DaemonClient,
source: DirectorySourceToken,
transaction: DirectoryTransaction<WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta>,
transaction: DirectoryTransaction<WorkspaceRefreshSnapshot, WorkspaceDirectoryDelta>,
): void {
if (!this.workspaceTransactions.isCurrent(transaction) || !this.isCurrent(client, source)) {
throw new DirectoryRefreshSupersededError("workspace fetch no longer current");
@@ -724,11 +736,13 @@ export class DirectorySync {
private async fetchProjectSnapshot(
client: DaemonClient,
source: DirectorySourceToken,
transaction: DirectoryTransaction<WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta>,
transaction: DirectoryTransaction<WorkspaceRefreshSnapshot, WorkspaceDirectoryDelta>,
supportsDirectorySync: boolean,
): Promise<void> {
const payload = await client.listProjects(
supportsDirectorySync ? { sync: this.readCursors().projects ?? {} } : undefined,
supportsDirectorySync
? { sync: transaction.snapshot.requestCursors.projects ?? {} }
: undefined,
);
this.assertWorkspaceTransactionCurrent(client, source, transaction);
if (payload.sync?.mode !== "changes") transaction.snapshot.projects.clear();
@@ -753,7 +767,7 @@ export class DirectorySync {
private completeWorkspaceRefresh(
client: DaemonClient,
source: DirectorySourceToken,
transaction: DirectoryTransaction<WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta>,
transaction: DirectoryTransaction<WorkspaceRefreshSnapshot, WorkspaceDirectoryDelta>,
): void {
if (!this.isCurrent(client, source) || !this.hasMatchingSession(client, source)) {
throw new DirectoryRefreshSupersededError("workspace completion no longer current");
@@ -256,6 +256,41 @@ 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,6 +378,21 @@ 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;
@@ -849,7 +864,7 @@ function applyDirectoryRow(
if (row.id !== REPLICA_SINGLETON_ROW_ID) {
throw new Error("Replica checkpoint row id mismatch");
}
result.checkpoint = parseStoredPayload(DirectoryCheckpointSchema, row.payload);
result.checkpoint = deserializeDirectoryCheckpoint(row.payload);
return;
default:
return;
@@ -1015,7 +1030,7 @@ export class ReplicaCache {
let checkpoint: DirectoryCheckpoint | undefined;
if (checkpointRow) {
try {
checkpoint = parseStoredPayload(DirectoryCheckpointSchema, checkpointRow.payload);
checkpoint = deserializeDirectoryCheckpoint(checkpointRow.payload);
checkpoint = { ...checkpoint };
delete checkpoint[invalidEntity];
} catch {
@@ -1031,7 +1046,7 @@ export class ReplicaCache {
serverId: row.serverId,
kind: "checkpoint",
id: REPLICA_SINGLETON_ROW_ID,
payload: JSON.stringify(checkpoint),
payload: JSON.stringify(serializeDirectoryCheckpoint(checkpoint)),
},
]
: [],
@@ -1057,7 +1072,7 @@ export class ReplicaCache {
serverId,
kind: "checkpoint",
id: REPLICA_SINGLETON_ROW_ID,
payload: JSON.stringify(checkpoint),
payload: JSON.stringify(serializeDirectoryCheckpoint(checkpoint)),
},
]
: [],
@@ -1323,7 +1338,7 @@ export class ReplicaCache {
if (!value) return null;
break;
case "checkpoint":
value = upsert.value;
value = serializeDirectoryCheckpoint(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}>
<DropdownMenuContent side="bottom" align="end" width={200} scrollable>
{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({ timeout: 1500 })),
...(await daemonClient.getDaemonStatus()),
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({ timeout: 1500 })),
...(await daemonClient.getDaemonStatus()),
serverId: daemonClient.getLastServerInfoMessage()?.serverId ?? "",
version: daemonClient.getLastServerInfoMessage()?.version ?? null,
}),
@@ -1,4 +1,4 @@
import { page } from "@vitest/browser/context";
import { page, userEvent } 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,6 +40,7 @@ interface MountedTerminal {
sizes: TerminalSize[];
terminalKeys: TerminalKeyRecord[];
inputModeChanges: TerminalInputModeState[];
openedUrls: string[];
}
const mountedTerminals: MountedTerminal[] = [];
@@ -95,6 +96,7 @@ function createTerminalHost(input: {
const inputs: string[] = [];
const terminalKeys: TerminalKeyRecord[] = [];
const inputModeChanges: TerminalInputModeState[] = [];
const openedUrls: string[] = [];
const runtime = new TerminalEmulatorRuntime();
runtime.setCallbacks({
callbacks: {
@@ -110,6 +112,9 @@ function createTerminalHost(input: {
onInputModeChange: (state) => {
inputModeChanges.push(state);
},
onOpenExternalUrl: (url) => {
openedUrls.push(url);
},
},
});
runtime.mount({
@@ -124,7 +129,16 @@ function createTerminalHost(input: {
},
});
const mounted = { host, root, runtime, inputs, sizes, terminalKeys, inputModeChanges };
const mounted = {
host,
root,
runtime,
inputs,
sizes,
terminalKeys,
inputModeChanges,
openedUrls,
};
mountedTerminals.push(mounted);
return mounted;
}
@@ -188,7 +202,43 @@ 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();
@@ -526,4 +576,23 @@ 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,6 +434,10 @@ 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,
@@ -441,6 +445,8 @@ 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,
@@ -457,12 +463,7 @@ export class TerminalEmulatorRuntime {
let imageAddon: ImageAddon | null = null;
terminal.loadAddon(fitAddon);
terminal.loadAddon(unicode11Addon);
terminal.loadAddon(
new WebLinksAddon((event, uri) => {
event.preventDefault();
void this.callbacks.onOpenExternalUrl?.(uri);
}),
);
terminal.loadAddon(new WebLinksAddon(openExternalLink));
const localFileLinkProvider = terminal.registerLinkProvider(
createTerminalLocalFileLinkProvider(terminal, {
resolveLink: async (source) => {
+11
View File
@@ -108,6 +108,17 @@ 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);
+13 -12
View File
@@ -91,12 +91,14 @@ export function formatCompactTimeAgo(date: Date, now: Date = new Date()): string
return describeCompactTimeAgo(date, now).label;
}
function isSameLocalDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
/**
* 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);
}
// Cached Intl formatter. Explicitly carrying `hourCycle` from the resolved
@@ -120,19 +122,18 @@ 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
* - Within ~6 days: "Wednesday 10:11 PM"
* - Older: "14 May 2026, 10:11 PM"
* - The previous 6 calendar days: "Wednesday 10:11 PM"
* - Older, including today's weekday last week: "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 (isSameLocalDay(date, now)) {
if (daysAgo === 0) {
return time;
}
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays >= 0 && diffDays < 7) {
if (daysAgo > 0 && daysAgo < 7) {
const weekday = date.toLocaleDateString(undefined, { weekday: "long" });
return `${weekday} ${time}`;
}
+43 -4
View File
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client";
import {
resolveExistingRunWorkspace,
resolveRunCallerAgentId,
@@ -8,13 +9,51 @@ 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("propagates a trimmed PASEO_AGENT_ID", () => {
expect(resolveRunCallerAgentId({ PASEO_AGENT_ID: " parent-agent " })).toBe("parent-agent");
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("omits blank caller ids", () => {
expect(resolveRunCallerAgentId({ PASEO_AGENT_ID: " " })).toBeUndefined();
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();
});
});
+29 -7
View File
@@ -4,6 +4,7 @@ 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,
@@ -526,7 +527,7 @@ export async function resolveExistingRunWorkspace(
// Workspace policy for `paseo run`. Precedence:
// 1. --workspace <id> -> run in that existing workspace
// 2. $PASEO_AGENT_ID -> daemon resolves the caller's workspace
// 2. caller agent -> 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
@@ -534,6 +535,7 @@ 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();
@@ -542,7 +544,7 @@ async function resolveRunWorkspace(
return resolveExistingRunWorkspace(client, explicit);
}
if (!newWorkspace && resolveRunCallerAgentId()) {
if (!newWorkspace && callerAgentId) {
return { cwd };
}
@@ -609,9 +611,9 @@ export async function runRunCommand(
const env = parseRunEnv(options.env);
const requestEnv = Object.keys(env).length > 0 ? env : undefined;
const workspace = await resolveRunWorkspace(client, options, cwd);
const callerAgentId = await resolveRunCallerAgentId(client);
const workspace = await resolveRunWorkspace(client, options, cwd, callerAgentId);
const workspaceId = workspace.id;
const callerAgentId = resolveRunCallerAgentId();
const runCwd = workspace.cwd;
if (outputSchema) {
@@ -743,8 +745,28 @@ export async function runRunCommand(
}
}
export function resolveRunCallerAgentId(
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,
env: { PASEO_AGENT_ID?: string } = process.env,
): string | undefined {
return env.PASEO_AGENT_ID?.trim() || undefined;
): 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;
}
@@ -57,6 +57,17 @@ 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)) {
@@ -112,7 +123,7 @@ export async function runSetPasswordCommand(
const promptPassword =
typeof options.promptPassword === "function"
? (options.promptPassword as PromptPassword)
: (message: string) => passwordPrompt({ message });
: terminalPasswordPrompt();
const newPassword = await promptForPassword(promptPassword);
const result = await setDaemonPasswordInConfig(newPassword, {
home: options.daemonTarget.kind === "instance" ? options.daemonTarget.home : undefined,
@@ -6,6 +6,7 @@ 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,
@@ -105,6 +106,27 @@ 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 });
}
+2 -1
View File
@@ -89,7 +89,7 @@
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
"test": "npm run test:unit && npm run test:integration",
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
"test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\"",
"test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\"",
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
"test:integration:all": "npm run test:e2e",
"test:integration:real": "vitest run real.e2e.test.ts",
@@ -115,6 +115,7 @@
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
"@opencode/client": "2.0.10",
"@xterm/headless": "^6.0.0",
"ai": "5.0.78",
"ajv": "^8.20.0",
@@ -4,22 +4,27 @@ import { fileURLToPath } from "node:url";
import { build } from "esbuild";
const sourcePath = fileURLToPath(
new URL("../src/server/agent/providers/opencode/bridge-plugin.mjs", import.meta.url),
);
const outputPath = fileURLToPath(
new URL(
"../dist/server/server/agent/providers/opencode/bridge-plugin.bundle.mjs",
import.meta.url,
),
);
await mkdir(path.dirname(outputPath), { recursive: true });
await build({
entryPoints: [sourcePath],
bundle: true,
format: "esm",
platform: "neutral",
target: "es2022",
outfile: outputPath,
});
for (const directory of ["", "v2/"]) {
const sourcePath = fileURLToPath(
new URL(
`../src/server/agent/providers/opencode/${directory}bridge-plugin.mjs`,
import.meta.url,
),
);
const outputPath = fileURLToPath(
new URL(
`../dist/server/server/agent/providers/opencode/${directory}bridge-plugin.bundle.mjs`,
import.meta.url,
),
);
await mkdir(path.dirname(outputPath), { recursive: true });
await build({
entryPoints: [sourcePath],
bundle: true,
format: "esm",
platform: "neutral",
mainFields: ["module", "main"],
target: "es2022",
outfile: outputPath,
});
}
@@ -1,4 +1,4 @@
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
@@ -23,6 +23,10 @@ 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;
@@ -36,6 +40,9 @@ 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}`,
@@ -62,7 +69,18 @@ async function runSupervisorFixture(options: {
);
const startedAt = Date.now();
const child = spawn(process.execPath, ["--import", "tsx", runnerPath], {
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, {
cwd: repoRoot,
env: { ...process.env },
stdio: ["ignore", "pipe", "pipe"],
@@ -348,4 +366,55 @@ 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"');
});
});
+60 -22
View File
@@ -1,7 +1,9 @@
import { fork, spawn, type ChildProcess } from "child_process";
import { mkdirSync } from "node:fs";
import path from "node:path";
import { createStream as createRotatingFileStream } from "rotating-file-stream";
import {
createStream as createRotatingFileStream,
type RotatingFileStream,
} from "rotating-file-stream";
import { signalProcessTree } from "../src/utils/tree-kill.js";
const WORKER_HEARTBEAT_INTERVAL_MS = 1_000;
@@ -110,17 +112,58 @@ function toRotatingFileStreamSize(size: string): string {
return `${value}${unit}`;
}
function createSupervisorLogStream(options: SupervisorLogFileOptions | undefined) {
if (!options) {
return null;
}
// 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;
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,
});
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());
});
},
};
}
export function runSupervisor(options: SupervisorOptions): SupervisorController {
@@ -137,10 +180,12 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
let lifecycleFailed = false;
let publication = Promise.resolve();
let forceKillTimer: NodeJS.Timeout | null = null;
const logStream = createSupervisorLogStream(options.logFile);
const durableLog = createDurableLog(options.logFile, (message) => {
process.stderr.write(`[${options.name}] ${message}\n`);
});
const writeDurableChunk = (chunk: string | Buffer): void => {
logStream?.write(chunk);
durableLog.write(chunk);
};
const writeLifecycleLog = (message: string, fields: Record<string, unknown> = {}): void => {
@@ -161,14 +206,7 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
writeLifecycleLog(message);
};
const closeLogStream = (): Promise<void> =>
new Promise((resolve) => {
if (!logStream) {
resolve();
return;
}
logStream.end(resolve);
});
const closeLogStream = (): Promise<void> => durableLog.close();
const exitSupervisor = (code: number): void => {
if (exiting) {
@@ -11244,3 +11244,129 @@ test("concurrent native restores run once before resuming the same agent", async
rmSync(workdir, { recursive: true, force: true });
}
});
test("commits startup notices once on create and after restored history", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-startup-notice-"));
const notice: AgentTimelineItem = { type: "notification", level: "info", message: "Runtime v2" };
const history: AgentTimelineItem = { type: "assistant_message", text: "Existing conversation" };
const toolOutput: AgentTimelineItem = {
type: "tool_call",
callId: "startup-output",
name: "shell",
status: "completed",
error: null,
detail: {
type: "shell",
command: "print output",
output: "x".repeat(1024 * 1024),
exitCode: 0,
},
};
const limitedToolOutput: AgentTimelineItem = {
...toolOutput,
detail: { type: "shell", command: "print output", output: "x".repeat(64 * 1024), exitCode: 0 },
};
class NotifyingSession extends TestAgentSession {
readonly initialTimeline = [{ item: notice, timestamp: "2026-09-22T00:00:00.000Z" }];
override async *streamHistory(): AsyncGenerator<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,11 +3488,31 @@ export class AgentManager {
options,
});
// Read history before publishing the agent: a provider failure must leave the
// session unregistered so the registration catch closes it.
const startupHistory: AgentStreamEvent[] = [];
if (session.initialTimeline?.length && !managed.historyPrimed) {
for await (const event of session.streamHistory()) {
startupHistory.push(limitAgentStreamEventContent(event));
}
}
this.assertAcceptingAgentRegistrations();
this.agents.set(resolvedAgentId, managed);
registered = true;
// Initialize previousStatus to track transitions
this.previousStatuses.set(resolvedAgentId, managed.lifecycle);
if (session.initialTimeline?.length) {
if (!managed.historyPrimed) {
// Legacy/imported chats need their existing history before startup rows.
await this.primeTimelineFromLegacyProviderHistory(managed, false, startupHistory);
} else {
for (const entry of session.initialTimeline) {
this.recordTimeline(managed.id, entry.item, { timestamp: entry.timestamp });
}
}
this.refreshSessionPersistence(managed);
}
await this.refreshRuntimeInfo(managed, { emit: false });
this.assertAgentRegistrationActive(managed);
await this.persistSnapshot(managed, {
@@ -4023,6 +4043,9 @@ export class AgentManager {
private async primeTimelineFromLegacyProviderHistory(
agent: ActiveManagedAgent,
broadcast: boolean | (() => boolean),
history:
| AsyncIterable<AgentStreamEvent>
| Iterable<AgentStreamEvent> = agent.session.streamHistory(),
): Promise<void> {
const deferredBroadcast = typeof broadcast === "function";
const historyEvents: Extract<AgentStreamEvent, { type: "timeline" }>[] = [];
@@ -4032,7 +4055,7 @@ export class AgentManager {
// Collect the whole replay before touching either store. A stream that fails
// halfway then leaves the committed timeline as it was, instead of a partial
// copy the next attempt would append to.
for await (const rawEvent of agent.session.streamHistory()) {
for await (const rawEvent of history) {
const event = limitAgentStreamEventContent(rawEvent);
if (event.type === "provider_subagent") {
historySubagentEvents.push(event);
@@ -425,6 +425,11 @@ 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,
@@ -440,10 +445,19 @@ 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,6 +665,9 @@ export interface AgentSession {
readonly id: string | null;
readonly capabilities: AgentCapabilityFlags;
readonly features?: AgentFeature[];
/** New provider-owned rows to commit on registration. streamHistory must also
* replay them at their original timestamps; restored sessions omit old rows. */
readonly initialTimeline?: ImportedTimelineEntry[];
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise<SteerResult>;
@@ -1,8 +1,9 @@
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, rmSync } from "node:fs";
import { realpathSync } 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";
@@ -13,7 +14,16 @@ 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 { AgentMode, AgentProvider, ProviderSnapshotEntry } from "./agent-sdk-types.js";
import type {
AgentClient,
AgentMode,
AgentPromptInput,
AgentProvider,
AgentRunResult,
AgentSession,
AgentStreamEvent,
ProviderSnapshotEntry,
} from "./agent-sdk-types.js";
import type { ProviderSnapshotManager } from "./provider-snapshot-manager.js";
import { createProviderSnapshotManagerStub } from "../test-utils/session-stubs.js";
import {
@@ -182,6 +192,16 @@ 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>;
@@ -3410,7 +3430,7 @@ describe("create_agent MCP tool", () => {
expect(storedChild?.workspaceId).toBe("wks_parent");
expect(storedChild?.labels[PARENT_AGENT_ID_LABEL]).toBe(parent.id);
} finally {
rmSync(workdir, { recursive: true, force: true });
await removeAgentStateDir(agentManager, storage, workdir);
}
});
@@ -3664,6 +3684,142 @@ 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();
@@ -3807,6 +3963,128 @@ 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,6 +86,11 @@ 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
@@ -98,7 +103,7 @@ export async function waitForAgentWithTimeout(
signal?: AbortSignal;
waitForActive?: boolean;
},
): Promise<WaitForAgentResult> {
): Promise<AgentWaitWithTimeoutResult> {
const timeoutController = new AbortController();
const combinedController = new AbortController();
@@ -133,7 +138,7 @@ export async function waitForAgentWithTimeout(
signal: combinedController.signal,
waitForActive: options?.waitForActive,
});
return result;
return { ...result, timedOut: false };
} catch (error) {
if (error instanceof Error && error.message === "wait timeout") {
const snapshot = agentManager.getAgent(agentId);
@@ -150,6 +155,7 @@ 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 { OpenCodeAgentClient } from "./providers/opencode-agent.js";
import { OpenCodeRuntimeClient } from "./providers/opencode/runtime-client.js";
import type { OpenCodeBridge } from "./providers/opencode/bridge.js";
import { OmpAgentClient } from "./providers/omp/agent.js";
import type { OmpRuntime } from "./providers/omp/runtime.js";
@@ -216,7 +216,7 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
env: runtimeSettings?.env,
}),
opencode: (logger, runtimeSettings, options) =>
new OpenCodeAgentClient(logger, runtimeSettings, {
new OpenCodeRuntimeClient(logger, runtimeSettings, {
managedProcesses: options?.managedProcesses,
bridge: options?.openCodeBridge,
}),
@@ -1,5 +1,8 @@
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,
@@ -3521,6 +3524,34 @@ 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,6 +136,17 @@ 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);
}
@@ -1354,12 +1365,7 @@ export class ACPAgentClient implements AgentClient {
stderrChunks.push(chunk.toString());
});
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 spawnErrorPromise = rejectOnSpawnError(child, stderrChunks);
const spawnReadyPromise = new Promise<void>((resolve) => {
child.once("spawn", () => {
resolve();
@@ -2732,6 +2738,7 @@ 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;
@@ -2759,14 +2766,17 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.child = child;
this.connection = connection;
const initialize = await this.runACPRequest(() =>
connection.initialize({
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: buildACPClientCapabilities(
this.clientCapabilityMeta,
this.clientCapabilities,
),
clientInfo: { name: "Paseo", version: "dev" },
}),
Promise.race([
connection.initialize({
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: buildACPClientCapabilities(
this.clientCapabilityMeta,
this.clientCapabilities,
),
clientInfo: { name: "Paseo", version: "dev" },
}),
spawnError,
]),
);
return { child, connection, initialize };
@@ -428,7 +428,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
logger,
resolveBinary: async () => "/test/claude/bin",
resolveVersion: async () => "2.1.219",
configDir: emptyConfigDir,
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: emptyConfigDir } },
});
const { models } = await client.fetchCatalog({
scope: "workspace",
@@ -475,7 +475,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
resolveVersion: async () => {
throw new Error("unrecognized version output");
},
configDir: emptyConfigDir,
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: 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",
configDir: emptyConfigDir,
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: emptyConfigDir } },
});
const { models } = await client.fetchCatalog({
scope: "workspace",
@@ -2,7 +2,6 @@ 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,
@@ -83,7 +82,7 @@ import {
type ClaudeRewindSdk,
} from "./rewind.js";
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
import { claudeProjectDirSync } from "./project-dir.js";
import { claudeConfigDir, claudeProjectDirSync } from "./project-dir.js";
import { THINKING_APPLIES_NEXT_TURN_NOTICE } from "../../provider-notices.js";
import {
isProviderImageMarkdown,
@@ -409,7 +408,6 @@ interface ClaudeAgentClientOptions {
queryFactory?: ClaudeQueryFactory;
resolveBinary?: () => Promise<string>;
resolveVersion?: (signal?: AbortSignal) => Promise<string>;
configDir?: string;
rewindSdk?: ClaudeRewindSdk;
}
@@ -1506,7 +1504,6 @@ 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) {
@@ -1518,7 +1515,6 @@ export class ClaudeAgentClient implements AgentClient {
this.resolveVersion =
options.resolveVersion ??
((signal) => resolveClaudeCodeVersion(this.runtimeSettings, signal));
this.configDir = options.configDir;
this.rewindSdk = options.rewindSdk ?? realClaudeRewindSdk;
}
@@ -1592,12 +1588,11 @@ 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, this.configDir, claudeCodeVersion),
);
const modeCatalog = claudeModeCatalog(
createProviderEnv({ baseEnv: process.env, runtimeSettings: this.runtimeSettings }),
getClaudeModelsWithSettings(this.logger, claudeConfigDir(env), claudeCodeVersion),
);
const modeCatalog = claudeModeCatalog(env);
return {
models,
...modeCatalog,
@@ -1605,12 +1600,15 @@ export class ClaudeAgentClient implements AgentClient {
}
async resolveDefaultModeId({ env: launchEnv }: ResolveAgentDefaultModeInput): Promise<string> {
const env = createProviderEnv({
return claudeModeCatalog(this.buildProviderEnv(launchEnv)).defaultModeId;
}
private buildProviderEnv(launchEnv?: Record<string, string>): NodeJS.ProcessEnv {
return createProviderEnv({
baseEnv: process.env,
runtimeSettings: this.runtimeSettings,
overlays: [launchEnv],
});
return claudeModeCatalog(env).defaultModeId;
}
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
@@ -1624,7 +1622,7 @@ export class ClaudeAgentClient implements AgentClient {
async listImportableSessions(
options?: ListImportableSessionsOptions,
): Promise<ImportableProviderSession[]> {
const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
const configDir = claudeConfigDir(this.buildProviderEnv());
const sessionsRoot = options?.cwd
? claudeProjectDirSync(options.cwd, { configDir })
: path.join(configDir, "projects");
@@ -5051,7 +5049,7 @@ class ClaudeAgentSession implements AgentSession {
private resolveHistoryPath(sessionId: string): string | null {
const cwd = this.config.cwd;
if (!cwd) return null;
const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
const configDir = claudeConfigDir(this.buildSdkEnv());
const candidates = [cwd];
try {
const realCwd = fs.realpathSync(cwd);
@@ -274,6 +274,25 @@ 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,5 +1,4 @@
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import type { Logger } from "pino";
@@ -51,7 +50,7 @@ export function findClaudeModel(
export async function getClaudeModelsWithSettings(
logger: Logger,
configDir?: string,
configDir: string,
claudeCodeVersion?: string,
): Promise<AgentModelDefinition[]> {
const hardcodedModels = getClaudeModels(claudeCodeVersion);
@@ -79,9 +78,9 @@ export async function getClaudeModelsWithSettings(
async function readClaudeSettingsModels(
logger: Logger,
configDir?: string,
configDir: string,
): Promise<AgentModelDefinition[]> {
const settingsPath = path.join(resolveClaudeConfigDir(configDir), "settings.json");
const settingsPath = path.join(configDir, "settings.json");
let parsed: unknown;
try {
@@ -116,10 +115,6 @@ 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,6 +66,11 @@ 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 ?? process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
return options?.configDir ?? claudeConfigDir(process.env);
}
@@ -171,8 +171,16 @@ function createSession(
return session;
}
function createProviderWithFakeAppServer(appServer: FakeCodexAppServer): CodexAppServerAgentClient {
const provider = new CodexAppServerAgentClient(createTestLogger());
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,
});
const internals = castInternals<{
goalsEnabledPromise: Promise<boolean> | null;
autoReviewEnabledPromise: Promise<boolean> | null;
@@ -234,6 +242,54 @@ 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" } }),
@@ -2225,6 +2281,59 @@ 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([
{
@@ -6212,6 +6321,55 @@ 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?: Record<string, unknown> | null;
customCodexConfig?: CodexCustomProviderConfig | 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 },
window: { limit: number; cwd?: string; modelProviders?: 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,6 +935,7 @@ 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 } : {}),
}),
);
@@ -3168,6 +3169,26 @@ 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 {
@@ -3278,10 +3299,15 @@ 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"],
): Record<string, unknown> | null {
): CodexCustomProviderConfig | null {
if (customProvider?.extends !== CODEX_PROVIDER) {
return null;
}
@@ -4057,6 +4083,18 @@ 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,
@@ -4260,7 +4298,7 @@ export class CodexAppServerAgentSession implements AgentSession {
const slashCommand = await this.resolveSlashCommandInvocation(prompt);
const effectivePrompt = slashCommand
? await this.buildCommandPromptInput(slashCommand.commandName, slashCommand.args)
: prompt;
: await this.attachMentionedSkills(prompt);
if (this.currentThreadId) {
await this.ensureThreadLoaded();
@@ -4327,7 +4365,7 @@ export class CodexAppServerAgentSession implements AgentSession {
if (!this.matchesSteerAdmission({ client, threadId, nativeTurnId, foregroundTurnId })) {
return { status: "unavailable" };
}
const input = await this.buildUserInput(prompt);
const input = await this.buildUserInput(await this.attachMentionedSkills(prompt));
if (!this.matchesSteerAdmission({ client, threadId, nativeTurnId, foregroundTurnId })) {
return { status: "unavailable" };
}
@@ -7021,13 +7059,14 @@ export class CodexAppServerAgentClient implements AgentClient {
private sessionDeps(): CodexAppServerAgentDeps {
return {
...this.deps,
customCodexConfig: buildCodexCustomProviderConfig(
this.runtimeSettings,
this.deps.customProvider,
),
customCodexConfig: this.customProviderConfig(),
};
}
private customProviderConfig(): CodexCustomProviderConfig | null {
return buildCodexCustomProviderConfig(this.runtimeSettings, this.deps.customProvider);
}
private resolveGoalsEnabled(): Promise<boolean> {
if (!this.goalsEnabledPromise) {
this.goalsEnabledPromise = (async () => {
@@ -7191,9 +7230,14 @@ 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) => {
@@ -0,0 +1,139 @@
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);
});
@@ -0,0 +1,108 @@
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,6 +62,7 @@ describe("GenericACPAgentClient", () => {
supportsRewindFiles: false,
supportsRewindBoth: false,
},
waitForInitialCommands: true,
},
]);
});
@@ -70,7 +70,9 @@ export class GenericACPAgentClient extends ACPAgentClient {
},
defaultCommand: options.command,
capabilities: buildGenericACPCapabilities(providerParams),
waitForInitialCommands: options.waitForInitialCommands,
// ACP agents advertise slash commands with available_commands_update after
// session/new, so the first listCommands() waits for that batch.
waitForInitialCommands: options.waitForInitialCommands ?? true,
initialCommandsWaitTimeoutMs: options.initialCommandsWaitTimeoutMs,
clientCapabilities: providerParams.clientCapabilities,
clientCapabilityMeta: options.clientCapabilityMeta,
@@ -7,6 +7,7 @@ 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"]>([
@@ -42,7 +43,6 @@ 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,6 +582,106 @@ 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,6 +568,24 @@ 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")
@@ -586,7 +604,7 @@ function mapExtensionUiRequestToPermission(
const label = options.label ?? "OMP";
switch (event.method) {
case "select": {
const selectOptions = readStringArray(event.options);
const selectOptions = readSelectOptions(event.options, event.optionDetails);
if (options.combineOptionalComment) {
return buildCombinedAskUserQuestionPermission(event, {
provider,
@@ -633,7 +651,7 @@ function mapExtensionUiRequestToPermission(
question: [optionalString(event.title), optionalString(event.message)]
.filter(Boolean)
.join("\n\n"),
options: ["Yes", "No"],
options: [{ label: "Yes" }, { label: "No" }],
multiSelect: false,
});
default:
@@ -678,7 +696,7 @@ function buildExtensionUiQuestionPermission(
provider: AgentProvider;
label: string;
question: string;
options: string[];
options: OmpSelectOption[];
multiSelect: boolean;
placeholder?: string;
allowEmpty?: boolean;
@@ -696,7 +714,10 @@ function buildExtensionUiQuestionPermission(
{
question: input.question,
header: QUESTION_RESPONSE_HEADER,
options: input.options.map((label) => ({ label })),
options: input.options.map((option) => ({
label: option.label,
...(option.description === undefined ? {} : { description: option.description }),
})),
multiSelect: input.multiSelect,
...(input.placeholder ? { placeholder: input.placeholder } : {}),
...(input.allowEmpty ? { allowEmpty: true } : {}),
@@ -717,11 +738,13 @@ function buildCombinedAskUserQuestionPermission(
provider: AgentProvider;
label: string;
question: string;
options: string[];
options: OmpSelectOption[];
allowFreeform: boolean;
},
): AgentPermissionRequest {
const visibleOptions = input.options.filter((option) => !isOmpAskUserFreeformOption(option));
const visibleOptions = input.options.filter(
(option) => !isOmpAskUserFreeformOption(option.label),
);
const allowOther = input.allowFreeform || visibleOptions.length !== input.options.length;
return {
id: event.id,
@@ -734,7 +757,10 @@ function buildCombinedAskUserQuestionPermission(
{
question: input.question,
header: QUESTION_RESPONSE_HEADER,
options: visibleOptions.map((label) => ({ label })),
options: visibleOptions.map((option) => ({
label: option.label,
...(option.description === undefined ? {} : { description: option.description }),
})),
multiSelect: false,
...(allowOther ? { allowOther: true } : {}),
},
@@ -753,7 +779,7 @@ function buildCombinedAskUserQuestionPermission(
answerHeader: QUESTION_RESPONSE_HEADER,
commentHeader: QUESTION_COMMENT_HEADER,
combinedAskUser: COMBINED_ASK_USER_METADATA,
selectOptions: visibleOptions,
selectOptions: visibleOptions.map((option) => option.label),
...(allowOther ? { freeformSentinel: OMP_ASK_USER_FREEFORM_SENTINEL } : {}),
},
};
@@ -444,6 +444,7 @@ 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 } from "../rpc-types.js";
import type { OmpAgentMessage, OmpRpcSlashCommand, OmpRuntimeEvent } from "../rpc-types.js";
import { FakeOmp } from "./fake-omp.js";
const CWD = "/tmp/paseo-omp-agent-test";
@@ -422,6 +422,10 @@ export class OmpHarness {
this.omp.latestSession().requestToolApproval(input);
}
emit(event: OmpRuntimeEvent): void {
this.omp.latestSession().emit(event);
}
pendingPermissions() {
return this.requireSession().getPendingPermissions();
}
@@ -169,6 +169,105 @@ 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 {
@@ -240,6 +339,7 @@ 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";
@@ -254,6 +354,11 @@ 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);
@@ -285,6 +390,7 @@ async function createRecoveryUpstream() {
return [...dispatchIds];
},
messageReads: () => messageReadCount,
mcpAdds: () => [...mcpAddNames],
setMessages(nextMessages: unknown[]) {
messages = nextMessages;
},
@@ -1944,6 +1944,75 @@ 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,7 +1318,10 @@ function buildOpenCodeReplayTimelineEvents(
}
if (info.role === "user") {
const text = parts
.filter((part): part is Extract<OpenCodePart, { type: "text" }> => part.type === "text")
.filter(
(part): part is Extract<OpenCodePart, { type: "text" }> =>
part.type === "text" && isUserAuthoredOpenCodeText(part),
)
.map((part) => part.text)
.join("");
@@ -1445,12 +1448,9 @@ export class OpenCodeAgentClient implements AgentClient {
options?: AgentCreateSessionOptions,
): Promise<AgentSession> {
const openCodeConfig = this.assertConfig(config);
const acquisition = await this.acquireServer(openCodeConfig, launchContext);
const { url } = acquisition.server;
const client = this.createOpenCodeClient({
baseUrl: url,
directory: openCodeConfig.cwd,
});
const connectServer = this.connectServer.bind(this, openCodeConfig, launchContext);
const connection = await connectServer();
const { client } = connection;
// 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,16 +1492,17 @@ export class OpenCodeAgentClient implements AgentClient {
session.id,
this.logger,
new Map(this.modelContextWindows),
acquisition.events,
acquisition.release,
connection.events,
connection.release,
options?.persistSession,
launchContext?.agentId,
url,
connection.url,
false,
unbindBridge,
connectServer,
);
} catch (error) {
await acquisition.release();
await connection.release();
throw error;
}
}
@@ -1528,13 +1529,11 @@ export class OpenCodeAgentClient implements AgentClient {
const registeredAcquisition = registeredServerUrl
? this.serverManager.acquireExisting(registeredServerUrl)
: null;
const acquisition =
registeredAcquisition ?? (await this.acquireServer(openCodeConfig, launchContext));
const { url } = acquisition.server;
const client = this.createOpenCodeClient({
baseUrl: url,
directory: openCodeConfig.cwd,
});
const connectServer = this.connectServer.bind(this, openCodeConfig, launchContext);
const connection = registeredAcquisition
? this.toServerConnection(registeredAcquisition, openCodeConfig.cwd)
: await connectServer();
const { client } = connection;
try {
await this.applySessionPermissionRules(client, openCodeConfig, handle.sessionId);
@@ -1547,16 +1546,17 @@ export class OpenCodeAgentClient implements AgentClient {
handle.sessionId,
this.logger,
new Map(this.modelContextWindows),
acquisition.events,
acquisition.release,
connection.events,
connection.release,
undefined,
launchContext?.agentId,
url,
connection.url,
registeredAcquisition !== null,
unbindBridge,
connectServer,
);
} catch (error) {
await acquisition.release();
await connection.release();
throw error;
}
}
@@ -1578,6 +1578,26 @@ 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,
@@ -2837,6 +2857,11 @@ 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"],
@@ -2847,7 +2872,11 @@ function appendOpenCodeTextPart(
events: AgentStreamEvent[],
): void {
if (messageRole === "user") {
if (!part.text || state.emittedUserMessageIds?.has(part.messageID)) {
if (
!part.text ||
!isUserAuthoredOpenCodeText(part) ||
state.emittedUserMessageIds?.has(part.messageID)
) {
return;
}
state.emittedUserMessageIds?.add(part.messageID);
@@ -3323,12 +3352,24 @@ 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 readonly client: OpencodeClient;
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 sessionId: string;
private readonly logger: Logger;
private readonly modelContextWindowsByModelKey: ReadonlyMap<string, number>;
@@ -3338,8 +3379,7 @@ class OpenCodeAgentSession implements AgentSession {
private abortController: AbortController | null = null;
private accumulatedUsage: AgentUsage = {};
private sessionTotalCostUsd: number | undefined;
private mcpConfigured = false;
private mcpSetupPromise: Promise<void> | null = null;
private mcpSetup: Promise<void> | null = null;
private messageRoles = new Map<string, OpenCodeMessageRole>();
private pendingUserMessageText: string | null = null;
private pendingClientMessageId: string | null = null;
@@ -3386,7 +3426,6 @@ 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;
@@ -3401,30 +3440,45 @@ class OpenCodeAgentSession implements AgentSession {
sessionId: string,
logger: Logger,
modelContextWindowsByModelKey: ReadonlyMap<string, number> = new Map(),
private readonly events: OpenCodeEventSource = EMPTY_OPENCODE_EVENT_SOURCE,
releaseServer?: () => Promise<void>,
events: OpenCodeEventSource = EMPTY_OPENCODE_EVENT_SOURCE,
releaseServer: () => Promise<void> = async () => undefined,
persistSession = true,
private readonly agentId?: string,
private readonly serverUrl?: string,
serverUrl?: string,
private readonly externallyDriven = false,
releaseBridge?: () => void,
connectServer?: () => Promise<OpenCodeServerConnection>,
) {
this.config = config;
this.client = client;
this.server = { client, events, url: serverUrl ?? "", release: releaseServer };
this.connectServer = connectServer ?? null;
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) => {
@@ -3436,6 +3490,47 @@ 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;
}
@@ -3601,6 +3696,7 @@ class OpenCodeAgentSession implements AgentSession {
}
async revertBoth(input: { messageId: string }): Promise<void> {
await this.reconnectIfServerExited();
await revertOpenCodeConversationAndFiles({
client: this.client,
sessionId: this.sessionId,
@@ -3725,6 +3821,7 @@ 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) {
@@ -4231,8 +4328,8 @@ class OpenCodeAgentSession implements AgentSession {
if (event.event.cwd) {
this.childSessionCwds.set(event.event.id, event.event.cwd);
}
if (this.serverUrl) {
registerOpenCodeChildSessionServerUrl(event.event.id, this.serverUrl);
if (this.server.url) {
registerOpenCodeChildSessionServerUrl(event.event.id, this.server.url);
}
} else if (event.event.type === "remove") {
unregisterOpenCodeChildSessionServerUrl(event.event.id);
@@ -4764,6 +4861,7 @@ 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,
@@ -4793,6 +4891,7 @@ class OpenCodeAgentSession implements AgentSession {
return this.availableModesCache;
}
await this.reconnectIfServerExited();
const response = await openCodeMetadataLimit(() =>
this.client.app.agents({
directory: this.config.cwd,
@@ -4811,6 +4910,7 @@ class OpenCodeAgentSession implements AgentSession {
}
async listCommands(): Promise<AgentSlashCommand[]> {
await this.reconnectIfServerExited();
return await listOpenCodeCommandsFromSdk(this.client, this.config.cwd);
}
@@ -4928,8 +5028,7 @@ class OpenCodeAgentSession implements AgentSession {
} finally {
this.releaseBridge?.();
this.releaseBridge = null;
await this.releaseServer?.();
this.releaseServer = null;
await this.server.release();
}
}
@@ -5005,25 +5104,18 @@ class OpenCodeAgentSession implements AgentSession {
}
private async ensureMcpServersConfigured(): Promise<void> {
if (this.mcpConfigured) {
return;
}
const mcpServers = this.config.mcpServers;
if (!mcpServers || Object.keys(mcpServers).length === 0) {
this.mcpConfigured = true;
return;
}
if (!this.mcpSetupPromise) {
this.mcpSetupPromise = this.configureMcpServers(mcpServers);
}
const setup = (this.mcpSetup ??= this.configureMcpServers(mcpServers));
try {
await this.mcpSetupPromise;
this.mcpConfigured = true;
await setup;
} catch (error) {
this.mcpSetupPromise = null;
if (this.mcpSetup === setup) {
this.mcpSetup = null;
}
throw error;
}
}
@@ -1,3 +1,4 @@
import { OpenCodeRuntimeClient } from "./opencode/runtime-client.js";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@@ -5,11 +6,18 @@ import path from "node:path";
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
import { expect, test } from "vitest";
import { execCommand } from "../../../utils/spawn.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import type { PaseoToolCatalog } from "../tools/types.js";
import { OpenCodeAgentClient } from "./opencode-agent.js";
import { OpenCodeV2AgentClient } from "./opencode/v2/agent.js";
import { OpenCodeBridge } from "./opencode/bridge.js";
import { OpenCodeServerManager } from "./opencode/server-manager.js";
import {
drainPersistedTimeline,
readAssistantText,
requireSessionId,
} from "./opencode/test-utils/v2-local-e2e-helpers.js";
test("real OpenCode server persists provider permissions across creation and resume", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-permissions-"));
@@ -86,7 +94,12 @@ test("real OpenCode server shares one process while shell.env stays session-scop
mkdir(secondCwd, { recursive: true }),
]);
first = await client.createSession(
{ provider: "opencode", cwd: firstCwd, model: "opencode/big-pickle", modeId: "build" },
{
provider: "opencode",
cwd: firstCwd,
model: process.env.OPENCODE_TEST_MODEL ?? "opencode/big-pickle",
modeId: "build",
},
{
agentId: "real-agent-one",
env: { PASEO_AGENT_ID: "real-agent-one", PASEO_AGENT_CWD: firstCwd },
@@ -127,6 +140,22 @@ test("real OpenCode server shares one process while shell.env stays session-scop
expect(JSON.stringify(secondShell.data)).toContain(`real-agent-two|${secondCwd}`);
expect(inspection.server.url).toMatch(/^http:\/\/127\.0\.0\.1:/);
// Keep the prompt assertions independent of asynchronous events from the direct shell API.
await first.close();
first = await client.createSession(
{
provider: "opencode",
cwd: firstCwd,
model: process.env.OPENCODE_TEST_MODEL ?? "opencode/big-pickle",
modeId: "build",
},
{
agentId: "real-agent-one",
env: { PASEO_AGENT_ID: "real-agent-one", PASEO_AGENT_CWD: firstCwd },
paseoTools: firstTools,
},
{ persistSession: false },
);
const agentResult = await first.run(
[
"Use the bash tool to run: env | grep -E '^(PASEO_AGENT_ID|PASEO_AGENT_CWD)='",
@@ -170,11 +199,6 @@ test("real OpenCode server shares one process while shell.env stays session-scop
}
}, 240_000);
function requireSessionId(session: { id: string | null }): string {
if (!session.id) throw new Error("OpenCode session has no id");
return session.id;
}
function createCallerCatalog(callerAgentId: string): PaseoToolCatalog {
const tool = {
name: "report_caller_agent_id",
@@ -197,8 +221,142 @@ function createCallerCatalog(callerAgentId: string): PaseoToolCatalog {
};
}
function readAssistantText(timeline: ReadonlyArray<{ type: string; text?: string }>): string {
return timeline
.flatMap((item) => (item.type === "assistant_message" ? [item.text ?? ""] : []))
.join("");
}
test("v2 native tool bridge preserves caller identity", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-v2-"));
const logger = createTestLogger();
const bridge = new OpenCodeBridge({ paseoHome: root, logger });
const catalog = createCallerCatalog("paseo-v2-caller");
bridge.setManifestCatalog(catalog);
await bridge.start();
const client = new OpenCodeV2AgentClient({ logger, bridge });
let session: Awaited<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,
);
@@ -393,8 +393,8 @@ describe.runIf(process.platform === "win32")(
expect(records).toHaveLength(1);
const record = records[0]!;
expect(path.extname(record.command).toLowerCase()).toBe(".exe");
expect(path.normalize(record.command).toLowerCase()).toContain(
path.normalize("node_modules/opencode-ai/bin/opencode.exe").toLowerCase(),
expect(path.normalize(record.command).toLowerCase()).toMatch(
/opencode-(?:ai|windows-(?:x64|arm64))[\\/]bin[\\/]opencode\.exe$/,
);
expect(record.command.toLowerCase()).not.toBe(detectedOpenCode!.toLowerCase());
expect(record.args).toEqual(["serve", "--port", String(acquisition.server.port)]);
@@ -0,0 +1,409 @@
import { V2Runtime } from "./opencode/v2/runtime.js";
import type { V2Api } from "./opencode/v2/api.js";
import { execCommand } from "../../../utils/spawn.js";
import { OpenCodeV2AgentClient } from "./opencode/v2/agent.js";
import { mkdir, mkdtemp, rm, writeFile, readFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { expect, test } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import {
autoRespondToPermissions,
collectPermissions,
collectProviderSubagents,
collectRunningToolCalls,
collectStreamedAssistantText,
drainPersistedAssistantText,
drainPersistedTimeline,
findSkillCommand,
requireNativeUserMessageId,
} from "./opencode/test-utils/v2-local-e2e-helpers.js";
test("v2 shares a helper across agent identities and keeps the remaining session alive", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-v2-shared-"));
const runtime = new V2Runtime({ logger: createTestLogger() });
const clients = new Set<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,4 +1,5 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import type { SessionMessageInfo } from "@opencode/client";
import { mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
@@ -8,7 +9,11 @@ import { afterEach, describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import type { PaseoToolCatalog } from "../../tools/types.js";
import { OpenCodeBridge, loadOpenCodeBridgePluginArtifact } from "./bridge.js";
import {
OpenCodeBridge,
loadOpenCodeBridgePluginArtifact,
materializeOpenCodeV2Plugin,
} from "./bridge.js";
const temporaryDirectories: string[] = [];
@@ -196,6 +201,182 @@ describe("OpenCodeBridge", () => {
}
});
test("v2 plugin filters caller tools and inherits child session bindings", async () => {
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-opencode-v2-scope-"));
temporaryDirectories.push(paseoHome);
const catalog = createCatalog();
const bridge = new OpenCodeBridge({ paseoHome, logger: createTestLogger() });
bridge.setManifestCatalog(catalog);
await bridge.start();
const release = bridge.bindSession({ sessionId: "parent", env: {}, tools: catalog });
const releaseDisabled = bridge.bindSession({ sessionId: "disabled", env: {} });
try {
const env = await bridge.decorateV2ServerEnv({});
const config = z
.object({
plugins: z.array(
z.object({
package: z.string(),
options: z.object({ baseUrl: z.string(), token: z.string() }),
}),
),
})
.parse(JSON.parse(env.OPENCODE_CONFIG_CONTENT));
expect(await bridge.decorateV2ServerEnv(env)).toEqual(env);
const plugin = config.plugins[0]!;
const tools = new Map<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);
@@ -224,3 +405,29 @@ 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,6 +48,7 @@ export class OpenCodeBridge {
private server: Server | null = null;
private baseUrl: string | null = null;
private pluginUrl: string | null = null;
private v2PluginUrl: string | null = null;
private manifestCatalog: PaseoToolCatalog | null = null;
constructor(options: OpenCodeBridgeOptions) {
@@ -115,6 +116,14 @@ export class OpenCodeBridge {
};
}
async decorateV2ServerEnv(env: Record<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;
@@ -123,7 +132,8 @@ export class OpenCodeBridge {
if (server) await closeServer(server);
}
private async materializePlugin(): Promise<string> {
private async materializePlugin(version: 1 | 2 = 1): Promise<string> {
if (version === 2) return materializeOpenCodeV2Plugin(this.paseoHome);
const artifact = await loadOpenCodeBridgePluginArtifact(import.meta.url);
const digest = createHash("sha256").update(artifact).digest("hex");
const destination = path.join(this.paseoHome, "runtime", "opencode", `paseo-${digest}.mjs`);
@@ -152,7 +162,12 @@ export class OpenCodeBridge {
sendJson(response, 404, { error: "OpenCode session is not bound to a Paseo agent" });
return;
}
sendJson(response, 200, { env: binding.env });
sendJson(response, 200, {
env: binding.env,
...(url.searchParams.has("tools")
? { tools: [...(binding.tools?.tools.keys() ?? [])] }
: {}),
});
return;
}
@@ -231,13 +246,63 @@ export class OpenCodeBridge {
}
}
export async function materializeOpenCodeV2Plugin(paseoHome: string): Promise<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 bundleUrl = new URL("./bridge-plugin.bundle.mjs", moduleUrl);
const directory = version === 2 ? "./v2/" : "./";
const bundleUrl = new URL(`${directory}bridge-plugin.bundle.mjs`, moduleUrl);
try {
return await readFile(bundleUrl);
} catch (error) {
@@ -245,7 +310,7 @@ export async function loadOpenCodeBridgePluginArtifact(
throw new Error("Bundled OpenCode bridge plugin artifact is missing", { cause: error });
}
}
return compileSource(fileURLToPath(new URL("./bridge-plugin.mjs", moduleUrl)));
return compileSource(fileURLToPath(new URL(`${directory}bridge-plugin.mjs`, moduleUrl)));
}
async function compileOpenCodeBridgePlugin(sourcePath: string): Promise<Uint8Array> {
@@ -260,6 +325,7 @@ async function compileOpenCodeBridgePlugin(sourcePath: string): Promise<Uint8Arr
bundle: true,
format: "esm",
platform: "neutral",
mainFields: ["module", "main"],
target: "es2022",
write: false,
});
@@ -1,3 +1,5 @@
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";
@@ -1276,6 +1278,44 @@ 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();
@@ -1518,3 +1558,90 @@ 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([]);
});
});
@@ -0,0 +1,16 @@
export class OpenCodeHttpError extends Error {
readonly name = "OpenCodeHttpError";
constructor(
readonly operation: string,
readonly status: number | undefined,
readonly html: boolean,
) {
const statusLabel = status === undefined ? "" : ` (HTTP ${status})`;
super(
html
? `OpenCode ${operation} returned HTML instead of JSON; incompatible OpenCode API${statusLabel}`
: `OpenCode ${operation} failed${statusLabel}`,
);
}
}
@@ -1,3 +1,5 @@
import { V2Harness } from "./test-utils/v2-harness.js";
import { OpenCodeV2AgentClient } from "./v2/agent.js";
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
@@ -67,3 +69,37 @@ describe("OpenCode rewind", () => {
});
});
});
test("OpenCode v2 commits conversation and file rewind only after staging succeeds", async () => {
const harness = new V2Harness();
const operations: string[] = [];
harness.interrupt = async () => {
operations.push("interrupt");
return { interrupted: true };
};
harness.api.session.revert.stage = async (input) => {
expect(input).toEqual({ sessionID: "session", messageID: "user-message", files: true });
operations.push("stage");
return harness.info;
};
harness.api.session.revert.commit = async () => {
operations.push("commit");
return harness.info;
};
const client = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: harness.runtime,
});
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
try {
await session.revertBoth!({ messageId: "user-message" });
expect(operations).toEqual(["interrupt", "stage", "commit"]);
harness.api.session.revert.stage = async () => {
throw new Error("snapshot missing");
};
await expect(session.revertBoth!({ messageId: "missing" })).rejects.toThrow("snapshot missing");
expect(operations).toEqual(["interrupt", "stage", "commit", "interrupt"]);
} finally {
await session.close();
}
});
@@ -0,0 +1,211 @@
import { expect, test } from "vitest";
import { openCodeMajorVersion } from "./runtime-client.js";
test.each([
["1.14.46", 1],
["unexpected wrapper output", 1],
["", 1],
["opencode v2.0.10\n", 2],
["v2.0.10-beta.1", 2],
])("identifies the OpenCode runtime version %s", (output, major) => {
expect(openCodeMajorVersion(String(output))).toBe(major);
});
test.each(["3.0.0", "2.0.9", "2.0.7", "2.0.4", "2.0.3", "v2.0.1"])(
"rejects unsupported OpenCode version output %s",
(output) => {
expect(() => openCodeMajorVersion(output)).toThrow();
},
);
test("records the runtime once and retains its position across resume", async () => {
const { withOpenCodeRuntimeNotice } = await import("./runtime-notice.js");
const { V2Harness } = await import("./test-utils/v2-harness.js");
const { OpenCodeV2AgentClient } = await import("./v2/agent.js");
const { createTestLogger } = await import("../../../../test-utils/test-logger.js");
const harness = new V2Harness();
const client = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: harness.runtime,
});
const original = withOpenCodeRuntimeNotice(
await client.createSession({ provider: "opencode", cwd: "/tmp/project" }),
2,
);
const notice = { type: "notification", level: "info", message: "This chat uses OpenCode v2." };
try {
expect(original.initialTimeline?.map((entry) => entry.item)).toEqual([notice]);
const handle = original.describePersistence()!;
await original.close();
const resumed = withOpenCodeRuntimeNotice(await client.resumeSession(handle), 2, handle);
try {
expect(resumed.initialTimeline).toEqual([]);
const history = [];
for await (const event of resumed.streamHistory()) history.push(event);
expect(history).toEqual([
{ type: "timeline", provider: "opencode", ...original.initialTimeline![0] },
]);
expect(resumed.describePersistence()?.metadata?.openCodeRuntimeNotices).toEqual(
handle.metadata?.openCodeRuntimeNotices,
);
} finally {
await resumed.close();
}
} finally {
await client.shutdown();
}
});
test("retries a rejected version probe after the configured binary changes", async () => {
const { mkdtemp, writeFile, rm } = await import("node:fs/promises");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const { OpenCodeRuntimeClient } = await import("./runtime-client.js");
const { createTestLogger } = await import("../../../../test-utils/test-logger.js");
const root = await mkdtemp(join(tmpdir(), "opencode-version-probe-"));
const script = join(root, "version.cjs");
const client = new OpenCodeRuntimeClient(createTestLogger(), {
command: { mode: "replace", argv: [process.execPath, script] },
});
try {
await writeFile(script, 'console.log("opencode v2.0.3")');
await expect(client.fetchCatalog({ scope: "global", force: true })).rejects.toThrow(
"Update OpenCode to 2.0.10 or newer, then refresh the provider in Paseo",
);
await writeFile(script, 'console.log("opencode v3.0.0")');
await expect(client.fetchCatalog({ scope: "global", force: true })).rejects.toThrow(
"Unsupported OpenCode major version 3",
);
} finally {
await client.shutdown();
await rm(root, { recursive: true, force: true });
}
});
test.each([
["console.log('wrapper output')", "unrecognized output"],
["process.exit(1)", "failed command"],
])("retries a %s version probe after updating to v2 (%s)", async (initialSource) => {
const { mkdtemp, writeFile, rm } = await import("node:fs/promises");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const { OpenCodeRuntimeClient } = await import("./runtime-client.js");
const { createTestLogger } = await import("../../../../test-utils/test-logger.js");
const root = await mkdtemp(join(tmpdir(), "opencode-version-retry-"));
const script = join(root, "version.cjs");
const client = new OpenCodeRuntimeClient(createTestLogger(), {
command: { mode: "replace", argv: [process.execPath, script] },
});
try {
await writeFile(script, initialSource);
expect((await client.listFeatures({ provider: "opencode", cwd: root }))[0]?.label).toBe(
"Auto Accept",
);
await writeFile(script, 'console.log("opencode v2.0.10")');
expect((await client.listFeatures({ provider: "opencode", cwd: root }))[0]?.label).toBe(
"Auto-accept",
);
} finally {
await client.shutdown();
await rm(root, { recursive: true, force: true });
}
});
test("appends a first notice after old history and records a changed major version", async () => {
const { withOpenCodeRuntimeNotice } = await import("./runtime-notice.js");
const { V2Harness } = await import("./test-utils/v2-harness.js");
const { OpenCodeV2AgentClient } = await import("./v2/agent.js");
const { createTestLogger } = await import("../../../../test-utils/test-logger.js");
const harness = new V2Harness();
harness.history.push({ id: "old", type: "user", text: "Earlier prompt", time: { created: 1 } });
const client = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: harness.runtime,
});
const previous = {
provider: "opencode",
sessionId: "session",
metadata: { cwd: "/tmp/project" },
};
const session = withOpenCodeRuntimeNotice(await client.resumeSession(previous), 2, previous);
try {
const history = [];
for await (const event of session.streamHistory()) history.push(event);
expect(history).toEqual([
expect.objectContaining({
type: "timeline",
item: { type: "user_message", text: "Earlier prompt", messageId: "old" },
}),
expect.objectContaining({
type: "timeline",
item: { type: "notification", level: "info", message: "This chat uses OpenCode v2." },
}),
]);
await session.close();
const v1Handle = {
...previous,
metadata: {
...previous.metadata,
openCodeRuntimeNotices: [{ major: 1, timestamp: "2026-01-01T00:00:00.000Z" }],
},
};
const changed = withOpenCodeRuntimeNotice(await client.resumeSession(v1Handle), 2, v1Handle);
try {
expect(changed.initialTimeline?.map((entry) => entry.item)).toEqual([
{ type: "notification", level: "info", message: "This chat uses OpenCode v2." },
]);
expect(changed.describePersistence()?.metadata?.openCodeRuntimeNotices).toHaveLength(2);
} finally {
await changed.close();
}
} finally {
await session.close();
await client.shutdown();
}
});
test.each([
"process.exit(1)",
'console.log("custom wrapper output")',
"setTimeout(() => {}, 30000)",
])(
"preserves legacy operations when the version probe is inconclusive: %s",
async (source) => {
const { mkdtemp, writeFile, rm } = await import("node:fs/promises");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const { OpenCodeRuntimeClient } = await import("./runtime-client.js");
const { OpenCodeAgentClient } = await import("../opencode-agent.js");
const { createTestLogger } = await import("../../../../test-utils/test-logger.js");
const { OpenCodeServerManager } = await import("./server-manager.js");
const root = await mkdtemp(join(tmpdir(), "opencode-legacy-probe-"));
const script = join(root, "version.cjs");
await writeFile(script, source);
const logger = createTestLogger();
let legacyShutdown = false;
class TrackedServerManager extends OpenCodeServerManager {
override async shutdown() {
legacyShutdown = true;
await super.shutdown();
}
}
const client = new OpenCodeRuntimeClient(
logger,
{
command: { mode: "replace", argv: [process.execPath, script] },
},
{ serverManager: new TrackedServerManager({ logger }) },
);
const legacy = new OpenCodeAgentClient(logger);
const config = { provider: "opencode", cwd: root };
try {
expect(await client.listFeatures(config)).toEqual(await legacy.listFeatures(config));
} finally {
await client.shutdown();
await legacy.shutdown();
await rm(root, { recursive: true, force: true });
}
expect(legacyShutdown).toBe(true);
},
10000,
);
@@ -0,0 +1,164 @@
import type { Logger } from "pino";
import type {
AgentClient,
AgentSessionConfig,
AgentLaunchContext,
AgentCreateSessionOptions,
AgentPersistenceHandle,
FetchCatalogOptions,
ProviderRefreshContext,
ListImportableSessionsOptions,
ImportProviderSessionInput,
ImportProviderSessionContext,
} from "../../agent-sdk-types.js";
import {
createProviderEnvSpec,
resolveProviderLaunch,
type ProviderRuntimeSettings,
} from "../../provider-launch-config.js";
import { execCommand } from "../../../../utils/spawn.js";
import { OpenCodeAgentClient } from "../opencode-agent.js";
import type { OpenCodeV2AgentClient } from "./v2/agent.js";
import { withOpenCodeRuntimeNotice } from "./runtime-notice.js";
// Keep the minimum aligned with the SDK and binary exercised by CI.
const MINIMUM_V2: readonly [number, number] = [0, 10];
const VERSION_PATTERN = /^(?:opencode\s+)?v?(\d+)\.(\d+)\.(\d+)(?:[-+][\w.-]+)?$/i;
export function openCodeMajorVersion(output: string): 1 | 2 {
const version = output.trim().match(VERSION_PATTERN);
if (!version) return 1;
if (version[1] === "1") return 1;
if (version[1] === "2") {
const [minimumMinor, minimumPatch] = MINIMUM_V2;
const minor = Number(version[2]);
const patch = Number(version[3]);
if (minor < minimumMinor || (minor === minimumMinor && patch < minimumPatch))
throw new Error(
`OpenCode ${version[1]}.${version[2]}.${version[3]} is too old for this Paseo integration. Update OpenCode to 2.${minimumMinor}.${minimumPatch} or newer, then refresh the provider in Paseo.`,
);
return 2;
}
throw new Error(
`Unsupported OpenCode major version ${version[1]}; supported versions are 1 and 2`,
);
}
export type OpenCodeRuntimeClientOptions = NonNullable<
ConstructorParameters<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();
}
}
@@ -0,0 +1,62 @@
import { z } from "zod";
import type {
AgentPersistenceHandle,
AgentSession,
AgentStreamEvent,
ImportedTimelineEntry,
} from "../../agent-sdk-types.js";
const Notices = z.array(
z.object({ major: z.union([z.literal(1), z.literal(2)]), timestamp: z.string().datetime() }),
);
// These rows belong to Paseo, not OpenCode's conversation. Keep their original
// timestamps in the persistence handle so history rebuilds preserve placement.
export function withOpenCodeRuntimeNotice(
session: AgentSession,
major: 1 | 2,
previous?: AgentPersistenceHandle,
): AgentSession {
const notices = Notices.parse(previous?.metadata?.openCodeRuntimeNotices ?? []);
const added = notices.at(-1)?.major !== major;
if (added) notices.push({ major, timestamp: new Date().toISOString() });
const entries: ImportedTimelineEntry[] = notices.map(({ major: recordedMajor, timestamp }) => ({
timestamp,
item: {
type: "notification",
level: "info",
message: `This chat uses OpenCode v${recordedMajor}.`,
},
}));
const describePersistence = session.describePersistence.bind(session);
const streamHistory = session.streamHistory.bind(session);
return Object.assign(session, {
initialTimeline: added ? entries.slice(-1) : [],
describePersistence() {
const handle = describePersistence();
return (
handle && { ...handle, metadata: { ...handle.metadata, openCodeRuntimeNotices: notices } }
);
},
async *streamHistory(): AsyncGenerator<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,6 +2,7 @@ import type { ChildProcess } from "node:child_process";
import { mkdirSync } from "node:fs";
import { stat } from "node:fs/promises";
import net from "node:net";
import { createRequire } from "node:module";
import path from "node:path";
import type { Logger } from "pino";
@@ -622,25 +623,23 @@ async function resolveOpenCodeBinary(): Promise<string> {
}
if (process.platform === "win32" && path.extname(found).toLowerCase() === ".cmd") {
// 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;
const packageDirectories = [
path.join(path.dirname(found), "node_modules", "opencode-ai"),
path.join(path.dirname(found), "..", "opencode-ai"),
];
for (const packageDirectory of packageDirectories) {
const bundledBinary = path.join(packageDirectory, "bin", "opencode.exe");
if (await pathExists(bundledBinary)) return bundledBinary;
// Local/pnpm: <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;
// Newer npm releases keep the executable in a platform dependency.
// Resolve from the CLI package so nested installs and pnpm both work.
try {
const require = createRequire(path.join(packageDirectory, "package.json"));
return require.resolve(`opencode-windows-${process.arch}/bin/opencode.exe`);
} catch {
// Try the other npm layout before retaining the original command.
}
}
console.warn(
"[opencode-server] Found opencode.cmd but could not resolve the real opencode.exe. " +
@@ -0,0 +1,142 @@
import type {
OpenCodeEvent,
SessionInfo,
SessionMessageInfo,
SessionCreateInput,
} from "@opencode/client";
import type { V2Api } from "../v2/api.js";
import type { V2Connection } from "../v2/runtime.js";
function unexpected(): never {
throw new Error("Unexpected OpenCode v2 test operation");
}
export class V2Harness {
readonly info: SessionInfo = {
id: "session",
projectID: "project",
location: { directory: "/tmp/project" },
agent: "build",
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
cost: 0,
time: { created: 1, updated: 1 },
outcome: "succeeded",
};
readonly history: SessionMessageInfo[] = [];
readonly creates: SessionCreateInput[] = [];
readonly prompts: string[] = [];
readonly environments: Array<{ sessionID: string; variables: Record<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);
}
}
}
@@ -0,0 +1,152 @@
import { z } from "zod";
import type {
AgentPermissionRequest,
AgentSession,
AgentSlashCommand,
AgentStreamEvent,
AgentTimelineItem,
} from "../../../agent-sdk-types.js";
export function requireSessionId(session: { id: string | null }): string {
if (!session.id) throw new Error("OpenCode session has no id");
return session.id;
}
export function readAssistantText(
timeline: ReadonlyArray<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,
};
}
@@ -0,0 +1,404 @@
import type { SessionMessageInfo } from "@opencode/client";
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../../../../test-utils/test-logger.js";
import type { AgentStreamEvent } from "../../../agent-sdk-types.js";
import { OpenCodeV2AgentClient } from "./agent.js";
import { V2Harness } from "../test-utils/v2-harness.js";
describe("OpenCode v2 session lifecycle", () => {
test("fails initialization when the event stream ends before connecting", async () => {
const harness = new V2Harness();
harness.api.event.subscribe = async function* () {};
const client = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: harness.runtime,
});
await expect(
client.createSession({ provider: "opencode", cwd: "/tmp/project" }),
).rejects.toThrow("event stream ended before connecting");
expect(harness.releases).toBeGreaterThan(0);
});
test("returns only validated structured output and preserves it in history", async () => {
const harness = new V2Harness();
harness.prompt = async (input) => {
harness.history.push({
id: "user",
type: "user",
text: input.text,
metadata: input.metadata,
time: { created: 1 },
});
harness.history.push({
id: "answer",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "model" },
time: { created: 2 },
content: [
{ type: "text", text: "Here is the answer" },
{
type: "tool",
id: "output",
name: "paseo_structured_output",
time: { created: 2 },
state: {
status: "completed",
input: { value: { answer: 42 } },
content: [{ type: "text", text: "accepted" }],
metadata: { paseoStructuredOutput: { answer: 42 } },
},
},
],
});
};
const client = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: harness.runtime,
});
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
try {
const result = await session.run("answer", {
outputSchema: {
type: "object",
properties: { answer: { const: 42 } },
required: ["answer"],
},
});
expect(result.finalText).toBe('{"answer":42}');
const history = [];
for await (const event of session.streamHistory!()) history.push(event);
expect(history).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: "timeline",
item: expect.objectContaining({ type: "assistant_message", text: result.finalText }),
}),
]),
);
} finally {
await session.close();
}
});
test("fails a structured turn when the model omits the validated output tool", async () => {
const harness = new V2Harness();
const client = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: harness.runtime,
});
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
try {
await expect(session.run("answer", { outputSchema: { type: "object" } })).rejects.toThrow(
"without submitting the required structured output",
);
} finally {
await session.close();
}
});
test("resumes the native session ID and never replaces a missing session", async () => {
const harness = new V2Harness();
const client = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: harness.runtime,
});
const handle = {
provider: "opencode",
sessionId: "session",
nativeHandle: "session",
metadata: { cwd: "/tmp/project" },
} as const;
harness.api.session.switchAgent = async ({ agent }) => {
harness.info.agent = agent;
};
harness.api.session.switchModel = async ({ model }) => {
harness.info.model = model;
};
const session = await client.resumeSession(handle, {
modeId: "plan",
model: "synthetic/test",
thinkingOptionId: "low",
});
try {
expect(await session.getRuntimeInfo!()).toMatchObject({
modeId: "plan",
model: "synthetic/test",
thinkingOptionId: "low",
});
expect(await session.describePersistence()).toMatchObject({
sessionId: "session",
nativeHandle: "session",
});
expect(harness.creates).toEqual([]);
} finally {
await session.close();
}
harness.api.session.get = async () => {
throw new Error("Session not found");
};
await expect(client.resumeSession(handle)).rejects.toThrow("Session not found");
expect(harness.creates).toEqual([]);
});
test("reconciles reconnect snapshots without replaying text or losing repeated chunks", async () => {
const harness = new V2Harness();
let finish!: () => void;
harness.wait = () =>
new Promise<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();
}
});
});
@@ -0,0 +1,264 @@
import { OpenCodeV2Session } from "./session.js";
import { V2_CAPABILITIES } from "./capabilities.js";
import {
features,
permissionRules,
applyResumeOverrides,
requiresDedicatedV2Server,
} from "./configuration.js";
import { commands } from "./commands.js";
import { waitForLocationReady, awaitPaseoPlugin } from "./readiness.js";
import type { SessionInfo } from "@opencode/client";
import type { Logger } from "pino";
import type {
AgentCapabilityFlags,
AgentClient,
AgentCreateSessionOptions,
AgentFeature,
AgentLaunchContext,
AgentPersistenceHandle,
AgentSession,
AgentSessionConfig,
FetchCatalogOptions,
ImportableProviderSession,
ImportProviderSessionInput,
ImportProviderSessionContext,
ListImportableSessionsOptions,
ProviderRefreshContext,
} from "../../../agent-sdk-types.js";
import type { ProviderRuntimeSettings } from "../../../provider-launch-config.js";
import type { ManagedProcessRegistry } from "../../../../managed-processes/managed-processes.js";
import { importSessionFromPersistence } from "../../../provider-session-import.js";
import type { OpenCodeBridge } from "../bridge.js";
import { resolveOpenCodeHomeDir } from "../paths.js";
import { V2Runtime, type V2Connection } from "./runtime.js";
import { modelRef, modesFromV2, modelsFromV2 } from "./mapping.js";
interface V2AgentOptions {
logger: Logger;
settings?: ProviderRuntimeSettings;
managedProcesses?: ManagedProcessRegistry;
bridge?: OpenCodeBridge;
runtime?: Pick<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();
}
}
}
@@ -0,0 +1,36 @@
import type { OpenCodeClient } from "@opencode/client";
export interface V2Api {
server: OpenCodeClient["server"];
session: Pick<
OpenCodeClient["session"],
| "create"
| "get"
| "list"
| "active"
| "remove"
| "switchAgent"
| "switchModel"
| "environment"
| "instructions"
| "prompt"
| "command"
| "compact"
| "wait"
| "interrupt"
| "revert"
| "log"
> & {
form: Pick<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"];
}
@@ -0,0 +1,67 @@
import { registerStructuredOutput, STRUCTURED_OUTPUT_TOOL } from "./structured-output-plugin.mjs";
export default {
id: "paseo",
async setup(context) {
const disposeStructuredOutput = await registerStructuredOutput(context);
const { baseUrl, token } = context.options;
if (!baseUrl || !token) return disposeStructuredOutput;
async function request(pathname, body, allowMissing = false) {
const response = await fetch(new URL(`/_internal/opencode${pathname}`, baseUrl), {
method: body === undefined ? "GET" : "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
if (allowMissing && response.status === 404) return null;
if (!response.ok) throw new Error(`Paseo tool bridge returned HTTP ${response.status}`);
return response.json();
}
async function scope(sessionID) {
const visited = new Set();
while (sessionID && !visited.has(sessionID)) {
visited.add(sessionID);
const binding = await request(
`/sessions/${encodeURIComponent(sessionID)}/context?tools`,
undefined,
true,
);
if (binding) return { ...binding, sessionID };
const session = await context.session.get({ sessionID });
sessionID = session.parentID;
}
throw new Error("OpenCode session is not bound to a Paseo agent");
}
const manifest = await request("/tools");
const registration = await context.tool.transform((editor) => {
for (const definition of manifest.tools) {
editor.add({
name: `paseo_${definition.name}`,
description: definition.description,
input: definition.inputSchema,
options: { codemode: false },
async execute(input, call) {
const binding = await scope(call.sessionID);
const result = await request(
`/sessions/${encodeURIComponent(binding.sessionID)}/tools/${encodeURIComponent(definition.name)}`,
input,
);
return { content: result.content, metadata: { paseoTool: definition.name } };
},
});
}
});
const filtering = await context.session.hook("context", async (input) => {
const binding = await scope(input.sessionID);
const allowed = new Set(binding.tools.map((name) => `paseo_${name}`));
for (const name of Object.keys(input.tools)) {
if (name !== STRUCTURED_OUTPUT_TOOL && name.startsWith("paseo_") && !allowed.has(name))
delete input.tools[name];
}
});
return async () => {
await disposeStructuredOutput();
await filtering.dispose();
await registration.dispose();
};
},
};
@@ -0,0 +1,13 @@
import type { AgentCapabilityFlags } from "../../../agent-sdk-types.js";
export const V2_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsSessionListing: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsRewindBoth: true,
supportsRewindConversation: false,
supportsRewindFiles: false,
};
@@ -0,0 +1,91 @@
import { V2Timeline } from "./timeline.js";
import type { SessionInfo } from "@opencode/client";
import type { AgentStreamEvent } from "../../../agent-sdk-types.js";
import type { ProviderSubagentInputEvent } from "../../../provider-subagents/store.js";
import { messages } from "./history.js";
import type { V2Api } from "./api.js";
interface ChildrenOptions {
client: V2Api;
id: string;
emit(event: AgentStreamEvent): void;
reconcilePermissions(id: string): Promise<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 },
});
}
}
}
@@ -0,0 +1,39 @@
import { waitForLocationReady } from "./readiness.js";
import type { V2Api } from "./api.js";
import type { AgentSlashCommand } from "../../../agent-sdk-types.js";
export async function commands(client: V2Api, directory: string): Promise<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()];
}
@@ -0,0 +1,65 @@
import { describe, expect, test } from "vitest";
import { features, permissionRules, requiresDedicatedV2Server } from "./configuration.js";
describe("OpenCode v2 permission rules", () => {
test("maps authored native permissions and exact injected MCP grants", () => {
expect(
permissionRules({
provider: "opencode",
cwd: "/tmp/project",
providerOptions: { permission: { bash: { pwd: "allow" }, task: "ask" } },
toolPolicy: { preapproved: [{ server: "paseo.host", tool: "read/info" }] },
}),
).toEqual([
{ action: "paseo_host_read_info", resource: "*", effect: "allow" },
{ action: "shell", resource: "pwd", effect: "allow" },
{ action: "subagent", resource: "*", effect: "ask" },
]);
});
});
describe("OpenCode v2 helper isolation", () => {
test("shares the helper for agent-identity environment only", () => {
expect(
requiresDedicatedV2Server(
{ provider: "opencode", cwd: "/tmp/project" },
{ agentId: "agent", env: { PASEO_AGENT_ID: "agent", PASEO_AGENT_CWD: "/tmp/project" } },
),
).toBe(false);
});
test("dedicates a helper for any other process environment", () => {
expect(
requiresDedicatedV2Server(
{ provider: "opencode", cwd: "/tmp/project" },
{ agentId: "agent", env: { CUSTOM_FLAG: "1" } },
),
).toBe(true);
});
test("dedicates a helper when the session carries custom MCP", () => {
expect(
requiresDedicatedV2Server({
provider: "opencode",
cwd: "/tmp/project",
mcpServers: { custom: { type: "stdio", command: "custom", args: [] } },
}),
).toBe(true);
});
});
describe("OpenCode v2 features", () => {
test("reports the auto-accept toggle from the session value", () => {
expect(features({ provider: "opencode", cwd: "/tmp/project" })).toEqual([
{ type: "toggle", id: "auto_accept", label: "Auto-accept", value: false },
]);
expect(
features({
provider: "opencode",
cwd: "/tmp/project",
featureValues: { auto_accept: true },
}),
).toEqual([{ type: "toggle", id: "auto_accept", label: "Auto-accept", value: true }]);
});
});
@@ -0,0 +1,83 @@
import type { SessionInfo, PermissionRuleset } from "@opencode/client";
import type { V2Api } from "./api.js";
import type {
AgentFeature,
AgentLaunchContext,
AgentSessionConfig,
} from "../../../agent-sdk-types.js";
import { OpenCodeProviderOptionsSchema, buildOpenCodePermissionRules } from "../options.js";
import { modelRef } from "./mapping.js";
export function features(config: AgentSessionConfig): AgentFeature[] {
return [
{
type: "toggle",
id: "auto_accept",
label: "Auto-accept",
value: config.featureValues?.["auto_accept"] === true,
},
];
}
export function permissionRules(config: AgentSessionConfig): PermissionRuleset {
const rules =
buildOpenCodePermissionRules(
OpenCodeProviderOptionsSchema.parse(config.providerOptions ?? {}),
undefined,
) ?? [];
const grants: PermissionRuleset = (config.toolPolicy?.preapproved ?? []).map((grant) => ({
action: `${grant.server.replace(/[^a-zA-Z0-9_-]/g, "_")}_${grant.tool.replace(/[^a-zA-Z0-9_-]/g, "_")}`,
resource: "*",
effect: "allow",
}));
return [
...grants,
...rules.map((rule) => ({
action: nativePermissionAction(rule.permission),
resource: rule.pattern,
effect: rule.action,
})),
];
}
function nativePermissionAction(action: string): string {
if (action === "bash") return "shell";
if (action === "task") return "subagent";
return action;
}
export async function applyResumeOverrides(
client: V2Api,
info: SessionInfo,
overrides?: Partial<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));
}
@@ -0,0 +1,17 @@
import type { SessionMessageInfo } from "@opencode/client";
import type { V2Api } from "./api.js";
export async function messages(client: V2Api, sessionID: string): Promise<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;
}
@@ -0,0 +1,50 @@
import type { AgentInfo, ModelInfo } from "@opencode/client";
import { expect, test } from "vitest";
import { modelsFromV2, modesFromV2 } from "./mapping.js";
test("normalizes v2 model capabilities, variants, and visible primary modes", () => {
const model: ModelInfo = {
id: "model",
modelID: "native-model",
providerID: "provider",
name: "Model",
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
variants: [{ id: "high" }],
time: { released: 1 },
cost: [],
status: "active",
enabled: true,
limit: { context: 200000, output: 10000 },
};
expect(modelsFromV2([model, { ...model, id: "disabled", enabled: false }])).toEqual([
{
provider: "opencode",
id: "provider/model",
label: "Model",
contextWindowMaxTokens: 200000,
metadata: {
providerId: "provider",
modelId: "model",
supportsAttachments: true,
supportsToolCall: true,
contextWindowMaxTokens: 200000,
},
thinkingOptions: [{ id: "high", label: "high" }],
},
]);
const agent: AgentInfo = {
id: "build",
name: "Build",
mode: "primary",
hidden: false,
request: { settings: {}, headers: {}, body: {} },
permissions: [],
};
expect(
modesFromV2([
agent,
{ ...agent, id: "hidden", hidden: true },
{ ...agent, id: "child", mode: "subagent" },
]),
).toEqual([{ id: "build", label: "Build", description: undefined }]);
});
@@ -0,0 +1,47 @@
import type { AgentInfo, ModelInfo, ModelRef, SessionInfo } from "@opencode/client";
import type { AgentMode, AgentModelDefinition, AgentUsage } from "../../../agent-sdk-types.js";
export function modelRef(id: string, variant?: string | null): ModelRef {
const slash = id.indexOf("/");
if (slash < 1 || slash === id.length - 1)
throw new Error("OpenCode model must be provider/model");
return {
providerID: id.slice(0, slash),
id: id.slice(slash + 1),
...(variant ? { variant } : {}),
};
}
export function modelsFromV2(models: ModelInfo[]): AgentModelDefinition[] {
return models
.filter((model) => model.enabled)
.map((model) => ({
provider: "opencode",
id: `${model.providerID}/${model.id}`,
label: model.name,
contextWindowMaxTokens: model.limit.context,
metadata: {
providerId: model.providerID,
modelId: model.id,
supportsAttachments: model.capabilities.input.includes("image"),
supportsToolCall: model.capabilities.tools,
contextWindowMaxTokens: model.limit.context,
},
thinkingOptions: model.variants.map((variant) => ({ id: variant.id, label: variant.id })),
}));
}
export function modesFromV2(agents: AgentInfo[]): AgentMode[] {
return agents
.filter((agent) => !agent.hidden && agent.mode !== "subagent")
.map((agent) => ({ id: agent.id, label: agent.name, description: agent.description }));
}
export function usageFromV2(session: SessionInfo): AgentUsage {
return {
inputTokens: session.tokens.input,
outputTokens: session.tokens.output,
cachedInputTokens: session.tokens.cache.read,
totalCostUsd: session.cost,
};
}
@@ -0,0 +1,128 @@
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../../../../test-utils/test-logger.js";
import type { V2Api } from "./api.js";
import { OpenCodeV2AgentClient } from "./agent.js";
import { V2Harness } from "../test-utils/v2-harness.js";
describe("OpenCode v2 questions", () => {
test("maps selected labels to native values and parses numeric and boolean answers", async () => {
const harness = new V2Harness();
harness.api.session.form.list = async () => [
{
id: "question",
sessionID: "session",
title: "Preferences",
fields: [
{ key: "color", type: "string", options: [{ label: "Blue", value: "blue-id" }] },
{
key: "features",
type: "multiselect",
options: [{ label: "Search", value: "search-id" }],
},
{ key: "count", type: "integer" },
{ key: "enabled", type: "boolean" },
],
},
];
const answers: Parameters<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();
}
});
});
@@ -0,0 +1,157 @@
import type { FormInfo, FormValue } from "@opencode/client";
import type { V2Api } from "./api.js";
import type {
AgentPermissionRequest,
AgentPermissionResponse,
AgentSessionConfig,
AgentStreamEvent,
} from "../../../agent-sdk-types.js";
export class SessionPermissions {
private readonly pending = new Map<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}`);
}
@@ -0,0 +1,40 @@
import { describe, expect, test } from "vitest";
import { OpenCodeLocationReadyTimeoutError, waitForLocationReady } from "./readiness.js";
describe("OpenCode v2 location readiness", () => {
test("fails location readiness when the plugin inventory never populates", async () => {
await expect(
waitForLocationReady({
client: { plugin: { list: async () => ({ location: { directory: "/tmp" }, data: [] }) } },
location: { directory: "/tmp" },
timeoutMs: 25,
}),
).rejects.toBeInstanceOf(OpenCodeLocationReadyTimeoutError);
});
test("bounds location readiness even when the inventory request never settles", async () => {
await expect(
waitForLocationReady({
client: { plugin: { list: () => new Promise(() => undefined) } },
location: { directory: "/tmp" },
timeoutMs: 25,
}),
).rejects.toBeInstanceOf(OpenCodeLocationReadyTimeoutError);
});
test("preserves inventory errors instead of reporting a readiness timeout", async () => {
const error = new Error("inventory unavailable");
await expect(
waitForLocationReady({
client: {
plugin: {
list: async () => {
throw error;
},
},
},
location: { directory: "/tmp" },
}),
).rejects.toBe(error);
});
});
@@ -0,0 +1,66 @@
import { setTimeout as delay } from "node:timers/promises";
import { raceProviderRefreshAbort } from "../../../provider-refresh-deadline.js";
import type { V2Api } from "./api.js";
interface ReadinessInput {
client: Pick<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;
}
}
@@ -0,0 +1,262 @@
import { decorateOpenCodeV2Env, materializeOpenCodeV2Plugin } from "../bridge.js";
import { resolvePaseoHome } from "../../../../paseo-home.js";
import { OpenCode } from "@opencode/client";
import type { V2Api } from "./api.js";
import { randomBytes } from "node:crypto";
import { mkdir } from "node:fs/promises";
import type { Logger } from "pino";
import { spawnProcess } from "../../../../../utils/spawn.js";
import { terminateWithTreeKill } from "../../../../../utils/tree-kill.js";
import {
createProviderEnvSpec,
resolveProviderLaunch,
type ProviderRuntimeSettings,
} from "../../../provider-launch-config.js";
import type { ManagedProcessRegistry } from "../../../../managed-processes/managed-processes.js";
import { resolveOpenCodeHomeDir } from "../paths.js";
import { OpenCodeHttpError } from "../http-error.js";
import { raceProviderRefreshAbort } from "../../../provider-refresh-deadline.js";
export interface V2Connection {
client: V2Api;
release(): Promise<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;
}
}
}
@@ -0,0 +1,424 @@
import { SessionChildren } from "./children.js";
import { SessionTurns } from "./turns.js";
import { V2Timeline } from "./timeline.js";
import { waitForLocationReady, awaitPaseoPlugin } from "./readiness.js";
import type { SessionInfo, SessionMessageInfo } from "@opencode/client";
import { setTimeout as delay } from "node:timers/promises";
import type { Logger } from "pino";
import type {
AgentLaunchContext,
AgentMode,
AgentPermissionResponse,
AgentPersistenceHandle,
AgentPromptInput,
AgentRunOptions,
AgentSession,
AgentSessionConfig,
AgentStreamEvent,
SteerActiveTurnOptions,
SteerResult,
} from "../../../agent-sdk-types.js";
import { toDiagnosticErrorMessage } from "../../diagnostic-utils.js";
import { runProviderTurn } from "../../provider-runner.js";
import { composeSystemPromptParts } from "../../../system-prompt.js";
import { raceProviderRefreshAbort } from "../../../provider-refresh-deadline.js";
import { type V2Connection } from "./runtime.js";
import { modelRef, modesFromV2 } from "./mapping.js";
import { V2_CAPABILITIES } from "./capabilities.js";
import { features } from "./configuration.js";
import { commands } from "./commands.js";
import { messages } from "./history.js";
import { SessionPermissions } from "./permissions.js";
export class OpenCodeV2Session implements AgentSession {
readonly provider = "opencode";
readonly capabilities = V2_CAPABILITIES;
private readonly listeners = new Set<(event: AgentStreamEvent) => void>();
private readonly timeline = new V2Timeline();
private readonly abort = new AbortController();
private readonly permissions: SessionPermissions;
private readonly turns: SessionTurns;
private readonly children: SessionChildren;
private stream: Promise<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();
}
}
@@ -0,0 +1,208 @@
import { V2Timeline } from "./timeline.js";
import type { SessionMessageAssistant } from "@opencode/client";
import { describe, expect, test } from "vitest";
import { OpenCodeV2AgentClient } from "./agent.js";
import { V2Harness } from "../test-utils/v2-harness.js";
import { createTestLogger } from "../../../../../test-utils/test-logger.js";
import type { AgentStreamEvent } from "../../../agent-sdk-types.js";
function collectAssistantText(session: {
subscribe: (cb: (e: AgentStreamEvent) => void) => () => void;
}) {
const chunks: string[] = [];
session.subscribe((event) => {
if (event.type === "timeline" && event.item.type === "assistant_message")
chunks.push(event.item.text);
});
return chunks;
}
function assistant(content: SessionMessageAssistant["content"]): SessionMessageAssistant {
return {
id: "answer",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "model" },
time: { created: 2 },
content,
};
}
describe("OpenCode v2 token streaming", () => {
test("emits text and reasoning deltas as they arrive", async () => {
const harness = new V2Harness();
const client = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: harness.runtime,
});
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
const text = collectAssistantText(session);
const reasoning: string[] = [];
session.subscribe((event) => {
if (event.type === "timeline" && event.item.type === "reasoning")
reasoning.push(event.item.text);
});
try {
harness.push({
id: "rs",
created: 2,
type: "session.reasoning.started",
data: { sessionID: "session", assistantMessageID: "answer", ordinal: 0 },
});
harness.push({
id: "r1",
created: 3,
type: "session.reasoning.delta",
data: { sessionID: "session", assistantMessageID: "answer", ordinal: 0, delta: "think" },
});
harness.push({
id: "ts",
created: 2,
type: "session.text.started",
data: { sessionID: "session", assistantMessageID: "answer", ordinal: 0 },
});
harness.push({
id: "t1",
created: 4,
type: "session.text.delta",
data: { sessionID: "session", assistantMessageID: "answer", ordinal: 0, delta: "Hel" },
});
harness.push({
id: "t2",
created: 5,
type: "session.text.delta",
data: { sessionID: "session", assistantMessageID: "answer", ordinal: 0, delta: "lo" },
});
await expect.poll(() => text).toEqual(["Hel", "lo"]);
expect(reasoning).toEqual(["think"]);
} finally {
await session.close();
}
});
test("deduplicates snapshots before and after deltas using per-type ordinals", () => {
const timeline = new V2Timeline();
const text = { assistantMessageID: "answer", type: "text", ordinal: 0 } as const;
const reasoning = { ...text, type: "reasoning" } as const;
timeline.startPart(text);
timeline.startPart(reasoning);
expect(timeline.delta({ ...text, delta: "Hel" })).toMatchObject({ item: { text: "Hel" } });
expect(timeline.delta({ ...reasoning, delta: "think" })).toMatchObject({
item: { text: "think" },
});
const snapshot = assistant([
{
type: "reasoning",
text: "think",
state: { reasoningField: "reasoning_content" },
time: { created: 2, completed: 2 },
},
{ type: "text", text: "Hello" },
]);
expect(timeline.messages([snapshot])).toMatchObject([{ item: { text: "lo" } }]);
expect(timeline.delta({ ...text, delta: "lo" })).toBeNull();
expect(timeline.messages([snapshot])).toEqual([]);
expect(timeline.delta({ ...text, delta: "!" })).toMatchObject({ item: { text: "!" } });
expect(timeline.messages([snapshot])).toEqual([]);
});
test("recovers missed fragments from snapshots after reconnect", () => {
const timeline = new V2Timeline();
const part = { assistantMessageID: "answer", type: "text", ordinal: 0 } as const;
timeline.startPart(part);
expect(timeline.delta({ ...part, delta: "Hel" })).toMatchObject({ item: { text: "Hel" } });
timeline.resetStreams();
expect(timeline.delta({ ...part, delta: "!" })).toBeNull();
expect(timeline.messages([assistant([{ type: "text", text: "Hello!" }])])).toMatchObject([
{ item: { text: "lo!" } },
]);
expect(timeline.messages([assistant([{ type: "text", text: "Hello!" }])])).toEqual([]);
});
test("withholds streamed prose during a structured-output turn", async () => {
const harness = new V2Harness();
let settle!: () => void;
harness.wait = () =>
new Promise<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();
}
});
});
@@ -0,0 +1,60 @@
import Ajv from "ajv";
export const STRUCTURED_OUTPUT_TOOL = "paseo_structured_output";
export async function registerStructuredOutput(context) {
const ajv = new Ajv({ strict: false, allErrors: true });
async function schemaFor(sessionID) {
const history = await context.session.context({ sessionID });
const prompt = history.findLast((message) => message.type === "user");
return prompt?.metadata?.paseoOutputSchema;
}
const tools = await context.tool.transform((editor) => {
editor.add({
name: STRUCTURED_OUTPUT_TOOL,
description: "Submit the final answer in the required structured format.",
input: {
type: "object",
properties: { value: {} },
required: ["value"],
additionalProperties: false,
},
options: { codemode: false },
async execute(input, call) {
const schema = await schemaFor(call.sessionID);
if (schema === undefined) throw new Error("This turn has no structured-output request");
const validate = ajv.compile(schema);
if (!validate(input.value))
throw new Error(`Invalid structured output: ${ajv.errorsText(validate.errors)}`);
return {
content:
"Final structured answer accepted. End this turn without additional text or tool calls.",
metadata: { paseoStructuredOutput: input.value },
};
},
});
});
const hook = await context.session.hook("context", async (input) => {
const schema = await schemaFor(input.sessionID);
if (schema === undefined) {
delete input.tools[STRUCTURED_OUTPUT_TOOL];
return;
}
const tool = input.tools[STRUCTURED_OUTPUT_TOOL];
if (!tool) throw new Error("Structured-output tool is unavailable");
tool.input = {
type: "object",
properties: { value: schema },
required: ["value"],
additionalProperties: false,
};
input.system.push({
type: "text",
text: `Complete the user's task, then submit the final answer using ${STRUCTURED_OUTPUT_TOOL}. Its value must satisfy the tool schema. Do not finish with an ordinary text answer. After the tool accepts the value, end the turn without additional text or tool calls.`,
});
});
return async () => {
await hook.dispose();
await tools.dispose();
};
}
@@ -0,0 +1,36 @@
import { Ajv } from "ajv";
import { z } from "zod";
import type { SessionMessageInfo } from "@opencode/client";
export const STRUCTURED_OUTPUT_TOOL = "paseo_structured_output";
const schemas = new Ajv({ strict: false, allErrors: true });
export function structuredOutput(input: unknown) {
const schema = z.union([z.boolean(), z.record(z.string(), z.json())]).parse(input);
const validate = schemas.compile(schema);
return {
schema,
assert(history: SessionMessageInfo[]) {
const start = history.findLastIndex((message) => message.type === "user");
for (const message of history.slice(start + 1)) {
if (message.type !== "assistant") continue;
for (const part of message.content) {
if (
part.type !== "tool" ||
part.name !== STRUCTURED_OUTPUT_TOOL ||
part.state.status !== "completed"
)
continue;
const result = part.state.metadata?.paseoStructuredOutput;
if (result === undefined) continue;
if (!validate(result))
throw new Error(
`Invalid OpenCode structured output: ${schemas.errorsText(validate.errors)}`,
);
return;
}
}
throw new Error("OpenCode finished without submitting the required structured output");
},
};
}
@@ -0,0 +1,205 @@
import { mapOpencodeToolCall } from "../tool-call-mapper.js";
import type { SessionMessageAssistantTool } from "@opencode/client";
import { STRUCTURED_OUTPUT_TOOL } from "./structured-output.js";
import type { SessionMessageInfo } from "@opencode/client";
import type { AgentStreamEvent, AgentTimelineItem } from "../../../agent-sdk-types.js";
function textPartKey(messageID: string, part: "text" | "reasoning", ordinal: number): string {
return `${messageID}:${part}:${ordinal}`;
}
// Text and reasoning each number parts from zero, independent of snapshot position.
export class V2Timeline {
private readonly content = new Map<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,
});
}
@@ -0,0 +1,261 @@
import { structuredOutput } from "./structured-output.js";
import type { SessionInfo, SessionMessageInfo } from "@opencode/client";
import { randomUUID } from "node:crypto";
import type {
AgentPromptInput,
AgentRunOptions,
AgentStreamEvent,
SteerActiveTurnOptions,
SteerResult,
} from "../../../agent-sdk-types.js";
import { toDiagnosticErrorMessage } from "../../diagnostic-utils.js";
import { renderPromptAttachmentAsText } from "../../../prompt-attachments.js";
import { usageFromV2 } from "./mapping.js";
import { commands } from "./commands.js";
import type { V2Api } from "./api.js";
interface TurnSnapshot {
info: SessionInfo;
history: SessionMessageInfo[];
}
interface TurnOptions {
client: V2Api;
id: string;
cwd: string;
signal: AbortSignal;
emit(event: AgentStreamEvent): void;
reconcile(): Promise<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,6 +120,42 @@ 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>> {
@@ -921,7 +957,8 @@ describe("PiRpcAgentSession", () => {
})) as PiRpcAgentSession;
const events = new SessionEvents(session);
const fakeSession = pi.latestSession();
fakeSession.capturedUserEntries = [{ id: "entry-old", parentId: null, text: "old prompt" }];
fakeSession.treeUserEntries = [{ id: "entry-old", parentId: null, text: "old prompt" }];
fakeSession.contextUserEntries = fakeSession.treeUserEntries;
await session.startTurn("new prompt", { clientMessageId: "client-new" });
fakeSession.finishSubmittedUserMessage({
@@ -1511,6 +1548,47 @@ 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);
@@ -2505,6 +2583,26 @@ 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) => {
@@ -2836,10 +2934,11 @@ describe("PiRpcAgentClient", () => {
test("rewinds conversation through the Pi tree navigation bridge", async () => {
const { pi, session, events } = await createSession();
pi.latestSession().capturedUserEntries = [
pi.latestSession().treeUserEntries = [
{ 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: [] });
@@ -2855,6 +2954,23 @@ 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,6 +94,7 @@ 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";
@@ -622,9 +623,8 @@ function createPiPaseoExtensionFile(systemPrompt?: string): PiTempFile {
.join("\\n\\n");
}
function getCapturedUserEntries(ctx) {
return ctx.sessionManager
.getEntries()
function toCapturedUserEntries(entries) {
return entries
.filter((entry) => entry.type === "message" && entry.message?.role === "user")
.map(toCapturedUserEntry);
}
@@ -640,7 +640,14 @@ function createPiPaseoExtensionFile(systemPrompt?: string): PiTempFile {
function emitEntryCapture(ctx, reason, requestId) {
ctx.ui.notify(
"${PASEO_PI_ENTRY_CAPTURE_MARKER} " +
JSON.stringify({ reason, requestId, entries: getCapturedUserEntries(ctx) }),
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()),
}),
"info",
);
}
@@ -720,6 +727,8 @@ 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) {
@@ -1265,8 +1274,8 @@ export class PiRpcAgentSession implements AgentSession {
private readonly pendingPromptResults = new Map<string, boolean>();
private readonly pendingSteerSubmissions: PiPendingSteerSubmission[] = [];
currentLeafOverrideId: string | null | undefined;
private readonly capturedUserEntries: PiCapturedEntry[] = [];
private readonly capturedUserEntriesById = new Map<string, PiCapturedEntry>();
private readonly contextUserEntries: PiCapturedEntry[] = [];
private readonly treeUserEntriesById = new Map<string, PiCapturedEntry>();
private readonly pendingExtensionResults = new Map<string, PendingExtensionResult>();
private outOfBandCompactionEmit: ((event: AgentStreamEvent) => void) | null = null;
private outOfBandCompactionStarted = false;
@@ -1475,7 +1484,7 @@ export class PiRpcAgentSession implements AgentSession {
yield* streamPiHistory(
this.provider,
await this.runtimeSession.getMessages(),
this.capturedUserEntries,
this.contextUserEntries,
);
}
@@ -1619,7 +1628,7 @@ export class PiRpcAgentSession implements AgentSession {
}
await this.refreshState().catch(() => undefined);
await this.requestEntryCapture("rewind");
const targetEntry = this.capturedUserEntriesById.get(input.messageId);
const targetEntry = this.treeUserEntriesById.get(input.messageId);
if (!targetEntry) {
throw new Error(`Pi rewind target ${input.messageId} was not found in captured tree entries`);
}
@@ -1965,11 +1974,14 @@ export class PiRpcAgentSession implements AgentSession {
}
}
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);
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);
}
}
@@ -2005,10 +2017,12 @@ export class PiRpcAgentSession implements AgentSession {
if (!payload) {
return false;
}
const entries = parseCapturedEntries(payload.entries);
this.recordCapturedUserEntries(entries);
this.recordCapturedUserEntries({
treeEntries: parseCapturedEntries(payload.treeEntries),
contextEntries: parseCapturedEntries(payload.contextEntries),
});
if (typeof payload.requestId === "string") {
this.resolveExtensionResult(payload.requestId, entries);
this.resolveExtensionResult(payload.requestId, undefined);
}
return true;
}
@@ -2664,12 +2678,23 @@ 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(
(
await runProviderRefreshActivity(context, "get_available_models", () =>
catalogSession.getAvailableModels(null),
)
).map((model) => mapPiModel(model, PI_PROVIDER)),
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;
}),
);
return { models, modes: [] };
} finally {
@@ -108,7 +108,10 @@ export class FakePiSession implements PiRuntimeSession {
readonly handoffRequests: Array<{ customInstructions?: string }> = [];
readonly sessionNameRequests: string[] = [];
readonly rawFrames: Array<object & { type: string }> = [];
capturedUserEntries: Array<{ id: string; parentId: string | null; text: 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[] = [];
abortRequested = false;
readonly canceledExtensionUiRequests: string[] = [];
readonly extensionUiResponses: Array<{
@@ -454,7 +457,8 @@ export class FakePiSession implements PiRuntimeSession {
message: `PASEO_ENTRY_CAPTURE ${JSON.stringify({
reason,
requestId,
entries: this.capturedUserEntries,
treeEntries: this.treeUserEntries,
contextEntries: this.contextUserEntries,
})}`,
});
}
@@ -1885,6 +1885,9 @@ 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",
{
@@ -1907,7 +1910,19 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
background = Boolean(callerAgentId),
notifyOnFinish = Boolean(callerAgentId),
}) => {
const shouldNotifyOnFinish = Boolean(callerAgentId && notifyOnFinish && background);
function armFinishNotification(): boolean {
if (!callerAgentId || !notifyOnFinish) {
return false;
}
setupFinishNotification({
agentManager,
agentStorage,
childAgentId: agentId,
callerAgentId,
logger: childLogger,
});
return true;
}
await sendPromptToAgent({
agentManager,
@@ -1918,27 +1933,24 @@ 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);
@@ -1949,6 +1961,8 @@ 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);
@@ -1958,12 +1972,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
status: currentSnapshot?.lifecycle ?? "idle",
lastMessage: null,
permission: null,
...(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.",
}
: {}),
...(notifying ? { guidance: PROMPTED_AGENT_NOTIFICATION_GUIDANCE } : {}),
};
const validJson = ensureValidJson(responseData);

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