Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] e26f0339d1 test(acp): start hung-session deadlines after initialization
Wait for the fake agent to receive session/new before advancing its diagnostic deadline. Slow process startup must not turn a session-timeout assertion into an initialization timeout.
2026-10-17 18:56:26 +02:00
paseo-bot[bot] 02216d601c fix(pi): settle foreground workflow tools when the process exits
Deliver a failed terminal mapping for calls still owned by the Pi process. Detached runs have already returned their tool result, so their adapter-owned status is preserved.
2026-10-17 18:20:11 +02:00
paseo-bot[bot] e5f8c7ac25 fix(pi): keep workflow lifecycle in the provider adapter
Remove the shared manager sweep and poll marker from the workflow fix. Turn-end timing and history replay do not prove that a Pi child was canceled; preserve its provider status until the adapter supplies an outcome.
2026-10-17 18:13:17 +02:00
paseo-bot[bot] b38dbf8ae6 fix(pi): restore async workflow transcripts on replay
Project saved async status when replaying a launch as well as during live polling. Retain each terminal verdict when a late transcript is attached so the extension host can finish its reader immediately.
2026-10-17 16:41:32 +02:00
paseo-bot[bot]andKushal Bairagya 1ea820ca17 test(pi): verify poll lifecycle without replacing availability
The lifecycle fixture injects its Pi runtime, so its executable probe should use a real configured executable rather than depend on an installed provider or replace the availability method.

Co-authored-by: Kushal Bairagya <bairagyakushal4@gmail.com>
2026-10-17 16:26:50 +02:00
Kushal Bairagya 9ae7a64aa7 fix(pi): surface workflowScript subagents and finalize stuck rows 2026-10-17 18:46:18 +05:30
Mohamed Boudra a0aeca967c fix(app): open explicit file links regardless of extension (#7233)
Authored Markdown destinations were filtered by the filename heuristics intended for inferred text, silently ignoring screenshot links. Resolve explicit destinations directly through the existing workspace opener so reads and failures reach the file pane.
2026-10-17 19:59:49 +07:00
paseo-bot[bot] 5384abdbaa fix(app): open Markdown preview folder links in Files (#7229)
Preview links bypassed the directory-aware opener and opened folder destinations as file tabs. Use the existing opener and explorer reveal actions, retaining preferred file placement and canceling pending checks when leaving the source tab.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-17 14:49:56 +02:00
paseo-bot[bot] 06cdc5a0b8 fix(app): open nested Markdown folder links without dot prefixes (#7210)
Explicit Markdown hrefs were rejected by the filename heuristic used for inferred text. Let authored nested paths resolve without a recognized extension while preserving inline-code, automatic-link, and domain handling.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-17 12:55:19 +02:00
Mohamed Boudra 4a32f95848 fix(app): soften the compact composer shadow in light mode (#7214)
The compact composer used a hardcoded box shadow shared by both themes,
so it read as a heavy band on light backgrounds. It now uses the shared
sm shadow token, whose light value is raised from 0.02 to 0.08 opacity
so the lift stays visible. Dark sm is unchanged.
2026-10-17 17:20:06 +07:00
sulthan zahran 1b450e487e fix(claude): don't treat a wrapper command's --version output as Claude Code's version (#7209)
resolveClaudeCodeVersion() probes <configured command> --version and parsed any output with the generic fallback in parseClaudeCodeVersion. With a wrapper command (e.g. cux), that output is the wrapper's own version, which then failed every minimumClaudeCodeVersion gate and silently hid Opus 5.5, Opus 5, Fable 5, Sonnet 5.5 and Haiku 5.5. Only trust output that identifies itself as Claude Code (x.y.z (Claude Code)); anything else falls into the existing unknown-version path that does not gate models.
2026-10-17 11:51:24 +02:00
HelloThereEveryone 74c8172f8b Wait for a plugin reload to finish instead of failing at the request deadline (#6306)
* Wait for plugin reload completion instead of the request deadline

The daemon answers plugin.reload.request only once the plugin is running
again or has failed, and a reload queues behind other plugin lifecycle
work such as an install with a preparation command. The shared client
applied its sixty-second session RPC deadline to that request, so a slow
reload failed with "Timeout waiting for message" while the daemon kept
loading the plugin, inviting a reload the daemon was still running.

Reload now waits for the daemon's own answer. Enable and disable keep the
session deadline, and a real startup failure still reaches the caller.

* Cover a slow plugin reload end to end

The CLI fixture now holds its own git build on a gate file, starts a
reload of an installed plugin behind that queued install, observes the
reload request at the proxy, keeps the gate closed past the client's
former one-minute deadline, and then asserts the reload's real result:
CLI exit 0, the plugin running, and exactly one reactivation. Listing the
plugin during the wait stays readable and does not restart it. A plugin
that cannot start still fails its reload with the daemon's error.

The request proxy that recorded install sources becomes a small observer
that records any session request, so the wait is measured from the
request boundary rather than from process start.

Verified: npx tsx packages/cli/tests/e2e/plugin-lifecycle.test.ts exits 0
in 135 s; the same fixture without the new scenario takes 66 s.

* Document how a reload reports its result

The CLI docs now say `paseo plugin reload` waits for the daemon to stop
and start the plugin, that cancelling the command or losing the
connection does not stop the daemon's work, and that `plugin ls` shows
the previous state until the reload finishes.

The plugin reference records the lifecycle fact behind it: a reload
queues behind the daemon's other plugin work, the daemon answers when
the plugin is running again or has failed, and status reads, Settings and
the logs are not the reload's outcome.

* Cover the Settings reload wait end to end

Adds a browser scenario for the ticket's user-facing half: with a gated
git install holding the daemon's plugin lifecycle queue, the row's Reload
goes out, the test keeps the gate closed past sixty seconds, asserts the
row stays pending with no error feedback, then releases the build and
asserts the daemon's plugin.reload.response and the app's "Reloaded
e2e-plugin" result.

The scenario turns plugins on through the daemon instead of the global
switch, whose transient "Plugins enabled" confirmation is not its
subject. The wait is measured from the reload request frame, which is an
outbound frame, not from the response stream.

Verified: npx playwright test --project=browser
e2e/browser/plugin-management.spec.ts -g "keeps Reload pending past a
minute" -> 1 passed (1.5m); plugin-reload-pending.png,
plugin-reload-past-deadline.png and plugin-reload-completed.png show the
pending row, the surface past the former deadline with no error, and the
finished reload.

* Correct the status guidance for a cancelled reload

Review finding: the note said a listing keeps the plugin's previous
state until the reload finishes, but that only holds while the reload is
still queued. Once it executes, the daemon removes the plugin from the
runtime catalog before stopping it (plugins/runtime.ts:377-382) and
resolvePluginStatus maps an enabled plugin that is not running to
"failed" (plugins/index.ts:71-78), so a listing read in that window
shows failed for a healthy reload.

Both passages now separate the two windows and say that neither a
listing nor the logs report the outcome of the outstanding reload.
The interim "failed" itself is TUC-1401.

* Harden the reload coverage against failures and lost connections

Review findings folded into the tests:

The Settings scenario releases its gated build in a finally block. Before
this, a failing assertion left the build polling forever, the daemon's
plugin lifecycle queue stayed blocked, and the fixture teardown's own
removePlugin timed out behind it. It also asserts the row's actions are
enabled again once the reload settles, next to the result feedback.

The connection-close regression now reconnects and asserts nothing
resends the lost reload; the no-replay invariant already covered for
legacy requests now holds for a pending reload too.

Verified: npx vitest run packages/client/src/daemon-client.test.ts -> 161
passed. npx playwright test --project=browser
e2e/browser/plugin-management.spec.ts -g "keeps Reload pending past a
minute" -> 1 passed (1.4m).

* Let the gated build finish before its files go

Review finding on packages/cli/tests/e2e/plugin-lifecycle.test.ts:450:
the finally block released the gate and deleted the gate directory
without waiting for the install and the reload it had started. The build
polls for the gate file, so cleanup could delete the release before the
child saw it and leave the build waiting until daemon shutdown.

The fixture now keeps both promises, releases the gate, and awaits them
before deleting their files.

Verified: npx tsx packages/cli/tests/e2e/plugin-lifecycle.test.ts -> exit
0, 161 s.

* Read the reload scenario as intent, not transport code

Review finding: the new browser scenario kept frame parsing, selectors,
polling, deadline arithmetic, screenshots and gate cleanup in one body.
docs/testing.md asks for complex setup in reusable helpers.

The wire observation, the gated queue hold, the row-state assertions, the
deadline wait and the surface captures are helpers now, and the body
reads as the flow: open Settings, hold the queue, reload, still pending
past the deadline, release, read the daemon's result.

Verified: npx playwright test --project=browser
e2e/browser/plugin-management.spec.ts -g "keeps Reload pending past a
minute" -> 1 passed (1.4m).

* Keep the reload lifecycle fact in one document

Review finding: the cli.md paragraph repeated the queue and interim-state
explanation that the plugin reference already owns. CLAUDE.md: "One fact,
one doc. Every other mention is a link."

The reference keeps the full explanation; the CLI page states what the
command does and links there.

* Release the gated build when its setup fails

Review finding: holdPluginLifecycleQueue started the install, then
waited up to 30 seconds for the build's started marker. A preparation
that took longer made the helper throw before it returned its cleanup
handle and before the test entered its try block, so nothing wrote the
release file: the build polled forever, the daemon's plugin lifecycle
queue stayed blocked, and the fixture's own removePlugin timed out
behind it.

The helper now owns that failure path: it releases the gate and drains
the install before rethrowing, so a queue it hands out is always
settleable.

Verified on Linux (Chromium, Playwright):
- With the started marker suppressed in a temporary local patch, the run
  fails at the 30-second marker wait inside the helper and the teardown
  still completes (both attempts ~33 seconds, no removePlugin request
  timeout).
- With the marker in place, the scenario passes: 1 passed (1.4m).

* Keep an early install failure out of the unhandled path

Review finding: the marker wait in holdPluginLifecycleQueue ran before
the install promise had a rejection handler, so a preparation that failed
or a connection that closed before the build wrote its marker surfaced as
an unhandled rejection, which fails the run before the helper's own catch
can drain the queue.

The helper attaches a handler the moment the install starts and races the
marker wait against the install, so an early failure reaches the caller
as the daemon's own error, a build that never writes its marker still
fails as a 30-second marker timeout, and every path releases the gate and
drains the install.

Verified on Linux (Chromium, Playwright), patching the build command
locally for each failure branch:
- Early failure (build exits 1): fails in 4.2 s with the daemon's
  DaemonRpcError for plugin.source.install.request, no unhandled
  rejection, teardown completes.
- No marker (build waits for the gate): fails at the 30-second marker
  wait, no unhandled rejection, teardown completes with no removePlugin
  request timeout.
- Unpatched: 1 passed (1.5m).

* Keep a failed launch from ending the run before its cleanup
2026-10-17 10:57:10 +02:00
paseo-bot[bot]andMoy fa70b215a8 Open chat folder links in Files with their contents visible (#7205)
* fix(app): open folders from inline path links in the file explorer

Clicking a folder path in an assistant message opened a file tab that
failed with "Requested path is not a file". Extension-less
workspace-relative paths without a line number are now probed with a
directory listing before opening: directories open the Files explorer
navigated to that folder, everything else opens as a file like before.
Lexical directories (trailing slash, workspace root) now navigate the
explorer to the folder instead of only preloading its listing.

* fix(app): reveal linked folders and their contents

Resolve workspace folder links from directory metadata and expand their ancestor paths in Files. Preserve file and line-reference opening, including extensionless files.

Co-authored-by: Moy <moyfoxther@gmail.com>

* fix(app): cancel stale path opens and reveal hidden folders

Keep pending directory checks inside the path-opening model so a later click or navigation supersedes them. Explicitly revealing a hidden directory also enables the existing hidden-file visibility preference.

---------

Co-authored-by: Moy <moyfoxther@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-17 09:53:28 +02:00
paseo-bot[bot]andFelipe Fidelix 30799fda19 Return actionable errors to OpenCode agents for invalid tool calls (#7183)
* fix(server): return actionable OpenCode bridge errors

* fix(server): surface OpenCode tool input errors

* fix(server): surface OpenCode v2 tool errors

* test(server): cover OpenCode v2 bridge errors

* fix(server): classify missing schedules as tool input

* fix(server): surface schedule state conflicts

* fix(server): validate schedule tool inputs

* fix(server): keep tool contracts while reporting OpenCode input errors

Preserve the removal of create_agent.background and keep legacy schedule fields hidden. Verify client errors through the real bridge, catalog, and schedule service rather than mocked schedule failures.

Co-authored-by: Felipe Fidelix <felipe@fdx.is>

* fix(server): distinguish caller errors from stored data failures

Tag validation failures where tool arguments are parsed, so schema errors from persisted state remain internal failures. Preserve the missing-agent error at its producer and translate it for prompt delivery. Real HTTP regressions fail for both misclassifications before this change.

* fix(server): preserve messages for unclassified tool failures

Keep existing error messages on HTTP 500 responses so agents can correct failed tool calls. Exercise missing workspaces through the real script service and bridge route.

* fix(server): preserve clearing schedule names with blank strings

---------

Co-authored-by: Felipe Fidelix <felipe@fdx.is>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-17 07:57:22 +02:00
Mohamed Boudra 6e1ec21c6a Keep Android dictation running through audio interruptions (#7200)
* fix(android): preserve dictation across audio interruptions

Playback focus loss does not mean microphone capture is unavailable. Keep dictation alive across media takeovers and wait for Android input silencing to clear, while preserving conversation interruption protection.

* test(android): verify dictation clears its selected audio route
2026-10-17 12:53:06 +07:00
paseo-bot[bot]andboeto f472c9d1f4 Correct workspace source parameters in the Paseo skill (#7188)
* docs(skills): align workspace source parameters

* docs(skills): clarify workspace source inputs

* test(skills): check workspace guidance against MCP inputs

Keep the bundled workspace guidance aligned with the tool schema agents receive, so removed source parameters do not remain in the instructions.

Co-authored-by: boeto <bo@eto.me>

---------

Co-authored-by: boeto <bo@eto.me>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-17 06:47:31 +02:00
Mohamed Boudra ec9817ed27 fix(app): make home actions reachable on short screens (#7167)
* fix(app): make home actions reachable on short screens

Keep the home cards and community links in one scrollable flow so overflow stays reachable and the footer cannot overlap the cards.

* fix(app): keep home scrolling clear of window drag regions
2026-10-17 11:29:58 +07:00
Mohamed Boudra 8555e8abd9 fix(release): distribute stable iOS builds to TestFlight testers (#7189)
Replace the upload-only submission with the existing TestFlight distribution job shape so external testers receive stable releases. Keep App Store review targeting the same version and build without a second binary upload.
2026-10-17 11:02:23 +07:00
paseo-bot[bot] 88a7aec1b8 fix(claude): keep active turns running through settings changes (#7179)
A pending thinking change retired the live query when mode, model, or command controls requested it. Reuse the query owned by the current turn and apply pending launch settings after the turn finishes.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-17 03:54:00 +02:00
paseo-bot[bot]andHung Le cd77ecd837 Start idle OMP agents when they receive follow-ups (#7169)
* fix(server): run slash commands sent by another agent out of band again

* fix(omp): send /steer and /follow-up as prompts so an idle agent starts a run

* test(omp): verify idle follow-ups and parent notifications

Cover real daemon and MCP delivery, and keep the fake runtime tests independent of an installed OMP executable.

Co-authored-by: Hung Le <22406703+nhthung@users.noreply.github.com>

* fix(omp): report idle follow-up admission failures

Use the existing prompt lifecycle for an idle command so acknowledgements and late rejections settle the child and notify its caller. Keep busy commands on the streaming prompt path, and accept one runtime prompt input object.

---------

Co-authored-by: Hung Le <22406703+nhthung@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-17 01:10:35 +02:00
paseo-bot[bot] 30122b1ae1 Show a background Claude subagent's action log on its card (#6242)
* fix(claude): show a background subagent's action log on its card

Claude Code answers a background Agent call at launch and streams the
child's frames afterwards. Since #6067 the sidechain tracker stopped
emitting the parent card once the call had its result, so the card's
log stayed empty. The tracker now rebuilds the card from the settled
call, keeping its status, output, and label, and adds the child's
action log.

Closes #6157

* fix(claude): release a settled subagent call when the child finishes

* fix(claude): keep a background subagent's card in the turn that settled it

A background child often keeps streaming after the parent's turn ends. Its
frames then rebuilt the settled card in the next turn with an empty log, and
the projection showed it as a second card for the same call. The settled call
now rebuilds the card only within the turn that answered it.

* test(claude): model the turn boundary in background card coverage

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-16 23:53:46 +02:00
xzthemeofsss d152f1529f fix(app): recover failed question responses (#7098)
* test: reproduce failed question response recovery

* fix: show question response failures and allow retry
2026-10-16 22:12:05 +02:00
paseo-ai[bot] 05b3745dcc fix: update lockfile signatures and Nix hash [skip ci] 2026-10-16 19:10:33 +00:00
95 changed files with 5318 additions and 590 deletions
+1 -1
View File
@@ -210,7 +210,7 @@ The rows combine two kinds of children:
parentAgentId === thisAgent.id AND !archivedAt
```
- **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track. A descriptor's optional `parentSubagentId` identifies its direct provider-subagent parent; an absent value identifies a direct child of the managed agent.
- **Provider subagents** are child executions owned by Claude, Codex, OpenCode, or Pi. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track. A descriptor's optional `parentSubagentId` identifies its direct provider-subagent parent; an absent value identifies a direct child of the managed agent.
Clicking either kind opens a workspace tab. On compact layouts it opens in a [sheet](explorer-sidebar.md#sheets) over the subagents list instead; the sheet hides the composer and shows the agent's model and thinking as one line in its place (`PaneSubtitle`, the row provider subagents already use), and a Paseo subagent's sheet offers **Open as tab** (the full-screen glyph in the header) to get the composer back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
+3 -3
View File
@@ -205,12 +205,12 @@ adb exec-out screencap -p > screenshot.png
Stable tag pushes like `v0.1.0` trigger:
- The EAS GitHub app on Expo servers (iOS + Android production builds + store submit). There is no workflow file in this repo for it.
- The EAS GitHub app on Expo servers, using `packages/app/.eas/workflows/release-mobile.yml` (iOS + Android production builds and distribution).
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release).
iOS auto-submits to App Store review via a Fastlane lane after EAS uploads to TestFlight. Android auto-submits to the Play Store via EAS-managed credentials.
iOS distributes the same build to external TestFlight testers and App Store review; see [Mobile builds (EAS)](release.md#mobile-builds-eas) for the review paths. Android auto-submits to the Play Store via EAS-managed credentials.
Beta tags like `v0.1.1-beta.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
Beta tags like `v0.1.1-beta.1` trigger the GitHub APK workflow and EAS `Release iOS Beta`. They publish a GitHub prerelease APK and distribute iOS through TestFlight without submitting to production stores.
`android-v*` tags also trigger only the GitHub APK workflow — useful when you want to ship an APK without going through stores. The GitHub APK workflow supports `workflow_dispatch` with an existing `tag` input so you can rebuild without cutting a new tag.
+22 -5
View File
@@ -300,16 +300,33 @@ done
### Android audio focus interruptions
Voice mode uses the custom `expo-two-way-audio` Android module, so incoming calls and other system audio owners must be tested with emulator/system commands, not a JS-only test. To verify that voice resume handles denied audio focus without crashing:
Use native instrumentation for audio interruptions. JS-only tests cannot prove Android focus,
input silencing, or route recovery. The module's `android/src/androidTest` suite runs through
Gradle's `connectedDebugAndroidTest` task and uses real `AudioManager` and `AudioRecord` instances.
Run the microphone-privacy cases on Android 12 or newer.
Verify these distinct outcomes:
- Dictation continues when another media owner takes permanent, temporary, or ducking focus.
- Disabling microphone access pauses audio delivery without discarding the dictation. Enabling
access resumes it. Starting dictation while access is disabled waits for the same recovery.
- Returning from music controls resumes dictation without pausing music again. Cancelling a
backgrounded recording must prevent the lifecycle callback from restarting it.
- Two-way voice still stops coherently on focus loss. Keep the incoming-call check below: it
exercises denied focus during lifecycle resume, beyond an ordinary media takeover.
```bash
adb shell am start -n sh.paseo/.MainActivity
# Start voice mode in an existing composer, then background Paseo with Home.
# Start voice mode in an existing composer, then background Paseo.
adb emu gsm call 5551234
# Foreground Paseo while the call is still ringing.
# Foreground Paseo while the call is still ringing, then end the simulated call.
adb emu gsm cancel 5551234
```
Expected result: Paseo does not throw `RuntimeException: Audio focus request failed`; native audio reports an interruption and voice mode stops or pauses coherently.
Expected result: no `RuntimeException: Audio focus request failed`, and voice mode stops or pauses
coherently. Repeat with dictation: it must retain captured audio and recover microphone access.
Use browser/device automation to operate the app; the commands above only control emulator telephony.
This fix requires a rebuilt native app. An OTA update on an older binary retains legacy capture.
### Releasing the audio session when idle
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+13 -10
View File
@@ -305,12 +305,14 @@ macOS 13 maps to Darwin 22. The two values use different version domains; do not
iOS and Android store builds are not in `.github/workflows`. They are triggered by the EAS GitHub app the moment the `v*` tag is pushed:
- **Android (Play Store)** — EAS builds with profile `production` and auto-submits to the Play Store via `eas submit` (EAS-managed credentials, no Fastlane).
- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads to TestFlight, and a Fastlane lane submits the build for App Store review.
- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads once through a `testflight` job, distributes the build to the `Paseo Beta` external group with Beta App Review submission, and a Fastlane lane submits that same build for App Store review.
- **Android APK (GitHub Release asset)** — separate, via `.github/workflows/android-apk-release.yml`. This is the only Android-related workflow that lives in this repo.
EAS uses the local app version source. `packages/app/app.config.js` derives the native version from the package version. Android `versionCode` is `major * 1_000_000 + minor * 1_000 + patch`. iOS reserves 1,000 build slots per app version: beta `N` uses slot `N`, and stable uses slot `999`. For example, `0.2.6-beta.2` appears in App Store Connect as version `0.2.6` build `2006002`; stable uses build `2006999`. Rebuilding the same tag produces the same native build number; if a store has already accepted a binary and you need a different binary, cut the next beta or patch instead of relying on EAS remote auto-increment.
Beta tags run `Release iOS Beta`. The workflow uploads the build to TestFlight, distributes it to the persistent `Paseo Beta` external group, and submits it for Beta App Review. Testers and the group are managed once in App Store Connect; releases require no dashboard action.
Stable tags run `Release Mobile`; beta tags run `Release iOS Beta`. Both distribute to the persistent `Paseo Beta` external group and submit for Beta App Review, so testers receive stable releases as well as betas. Testers, the group, and required TestFlight test information are managed once in App Store Connect.
Stable iOS releases use one binary for two Apple review paths: Beta App Review for external TestFlight distribution and App Store review. [Apple says](https://developer.apple.com/help/app-store-connect/test-a-beta-version/invite-external-testers) the first submitted build requires a full TestFlight review; later builds for the same version might not. Do not assume a stable patch skips review.
There is no mobile-release workflow under `.github/workflows`. The EAS GitHub app reads the workflows under `packages/app/.eas/workflows` and handles tag triggering directly.
@@ -334,7 +336,7 @@ npx eas build:list --platform android --limit 5 --non-interactive --json
# Inspect a specific build.
npx eas build:view <build-id>
# Inspect the full release workflow, including submit_ios, submit_android,
# Inspect the full release workflow, including distribute_ios, submit_android,
# and submit_ios_for_review.
npx eas workflow:view <workflow-run-id> --json
@@ -347,17 +349,17 @@ npx eas build:view <build-id> --json | jq '.logFiles[]'
A build's `gitCommitHash` must match the release tag commit. `status` walks through `NEW` → `IN_QUEUE` → `IN_PROGRESS` → `FINISHED` (or `ERRORED`/`CANCELED`). The EAS workflow run's `gitCommitHash` and `trigger` must also match the release tag.
Once a build is `FINISHED`, EAS still has release-critical work to do: Android must submit to the Play Store, and iOS must upload to TestFlight **and** submit the build for App Store review. The release is not done until all platforms are on their way through the stores.
Once a build is `FINISHED`, EAS still has release-critical work to do: Android must submit to the Play Store, and iOS must distribute to the external TestFlight group with Beta App Review submission **and** submit the same build for App Store review. The release is not done until all platforms are on their way through the stores.
For the `Release Mobile` EAS workflow, these jobs must pass:
- `build_ios` — iOS binary built
- `submit_ios` — iOS binary uploaded to App Store Connect/TestFlight
- `distribute_ios` — iOS binary uploaded once, added to `Paseo Beta`, and submitted for Beta App Review
- `submit_ios_for_review` — iOS build submitted for App Store review via Fastlane
- `build_android` — Android store binary built
- `submit_android` — Android binary submitted to the Play Store
Do not treat `build_ios: SUCCESS` or `submit_ios: SUCCESS` as a completed iOS release. `submit_ios_for_review: FAILURE` means the iOS release is blocked even if the build is visible in TestFlight.
Do not treat `build_ios: SUCCESS` or `distribute_ios: SUCCESS` as a completed iOS release. `submit_ios_for_review: FAILURE` means the iOS release is blocked even if the build is visible in TestFlight.
To confirm the submission landed, inspect the EAS workflow with `npx eas workflow:view <workflow-run-id> --json`. App Store Connect (review state for the matching version/build) and the Play Console track are the final ground truth.
@@ -380,8 +382,9 @@ Windows, and Android APK assets are present along with the channel manifests
(`latest-mac.yml`, `latest-linux.yml`, and `latest.yml` for stable;
`beta-mac.yml`, `beta-linux.yml`, and `beta.yml` for beta).
For stable releases, also confirm every required mobile build, upload, store
submission, and review-submission job for the release commit. For betas, confirm
For stable releases, also confirm every required mobile build, external
TestFlight distribution, Beta App Review submission, and store submission job
for the release commit. For betas, confirm
the beta EAS workflow completed its TestFlight distribution and Beta App Review
path. Delete the heartbeat only after every applicable checklist item passes,
then report the release as shipped.
@@ -396,7 +399,7 @@ Pattern:
"timezone": "UTC",
"maxRuns": 120,
"expiresIn": "24h",
"prompt": "Resume the vX.Y.Z release babysit for commit <sha>. Check npm tags; every GitHub Actions run for the release branch and tag; the published GitHub Release body, expected desktop/APK assets, and channel manifests; the Docker image; and the matching EAS workflow. Completion requires every applicable checklist item. For stable, require build_ios, submit_ios, submit_ios_for_review, build_android, and submit_android to succeed. For beta, require the beta TestFlight distribution and Beta App Review path. If work is pending, wait for the next heartbeat. If a failure can be retried safely for the same version, follow the failed-release procedure; otherwise report the blocker. When every applicable completion-checklist item passes, delete THIS heartbeat, report shipped, and stop.",
"prompt": "Resume the vX.Y.Z release babysit for commit <sha>. Check npm tags; every GitHub Actions run for the release branch and tag; the published GitHub Release body, expected desktop/APK assets, and channel manifests; the Docker image; and the matching EAS workflow. Completion requires every applicable checklist item. For stable, require build_ios, distribute_ios (Paseo Beta external distribution and Beta App Review submission), submit_ios_for_review, build_android, and submit_android to succeed. For beta, require the beta TestFlight distribution and Beta App Review path. If work is pending, wait for the next heartbeat. If a failure can be retried safely for the same version, follow the failed-release procedure; otherwise report the blocker. When every applicable completion-checklist item passes, delete THIS heartbeat, report shipped, and stop.",
}
```
@@ -680,7 +683,7 @@ Every entry covers `previous stable release → release source`: first beta, lat
- [ ] GitHub `Release Notes Sync` is green and the release body matches the stable changelog entry
- [ ] EAS `Release Mobile` workflow for the same tag is green
- [ ] EAS iOS `build_ios` completes for the same tag
- [ ] EAS iOS `submit_ios` succeeds, uploading the build to App Store Connect/TestFlight
- [ ] EAS iOS `distribute_ios` succeeds, uploading the binary once, distributing it to `Paseo Beta`, and submitting it for Beta App Review
- [ ] EAS iOS `submit_ios_for_review` succeeds, putting the build into App Store review
- [ ] EAS Android `build_android` completes for the same tag
- [ ] EAS Android `submit_android` succeeds, putting the build on its Play Store track
+1 -1
View File
@@ -1 +1 @@
sha256-Sw9WSOnvebpXgty0WACJrRq+pkKuynLMxJg6D/SnyRk=
sha256-1WmrHvE68Lzyil4On7xtsPMQTODXZ3Jb3/juUg3nHi4=
@@ -23,13 +23,16 @@ jobs:
platform: android
profile: production
submit_ios:
name: Submit iOS
distribute_ios:
name: Distribute iOS to TestFlight
needs: [build_ios]
type: submit
type: testflight
params:
build_id: ${{ needs.build_ios.outputs.build_id }}
profile: production
external_groups:
- Paseo Beta
submit_beta_review: true
submit_android:
name: Submit Android
@@ -41,7 +44,7 @@ jobs:
submit_ios_for_review:
name: Submit iOS for App Store review
needs: [build_ios, submit_ios]
needs: [build_ios, distribute_ios]
environment: production
runs_on: macos-medium
steps:
@@ -105,6 +105,37 @@ async function seedAgentWithFileLink(input: LinkedFile) {
}
test.describe("CodeMirror workspace file editing", () => {
test("opens explicit screenshot links and explains missing files", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "screenshot-markdown-link-",
title: "Screenshot file links",
initialPrompt: "Show screenshots",
featureValues: {
mockAssistantResponse:
"[Capture](.dev/tablet-review/capture.png) [Missing capture](.dev/tablet-review/missing.png)",
},
});
try {
await mkdir(path.join(session.cwd, ".dev/tablet-review"), { recursive: true });
await writeFile(path.join(session.cwd, ".dev/tablet-review/capture.png"), RED_PIXEL);
await openAgentRoute(page, session);
await test.step("Open the linked screenshot", async () => {
await openAssistantMarkdownLink(page, "Capture");
await expectFileTabOpen(page, ".dev/tablet-review/capture.png");
await expect(page.getByTestId("image-file-preview-canvas")).toBeVisible();
});
await test.step("Explain why the missing screenshot cannot open", async () => {
await returnToAssistant(page, session.agentId);
await openAssistantMarkdownLink(page, "Missing capture");
await expectFileTabOpen(page, ".dev/tablet-review/missing.png");
await expect(page.getByText(/ENOENT/)).toBeVisible();
await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
});
} finally {
await session.cleanup();
}
});
test("opens the non-ASCII file named by an assistant markdown link", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "encoded-markdown-link-",
@@ -0,0 +1,110 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import {
expectFileTabOpen,
openFileExplorer,
openFileFromExplorer,
} from "../support/helpers/file-explorer";
import { ensureWorkspaceAgentPaneVisible } from "../support/helpers/workspace-tabs";
async function openMarkdownPreview(page: Page) {
await openFileExplorer(page);
await openFileFromExplorer(page, "README.md");
await expectFileTabOpen(page, "README.md");
await page
.getByTestId("file-panel-bar")
.getByRole("button", { name: "Preview", exact: true })
.click();
await ensureWorkspaceAgentPaneVisible(page);
}
async function followPreviewLink(page: Page, name: string) {
await page
.getByTestId("workspace-file-pane")
.filter({ visible: true })
.getByRole("link", { name, exact: true })
.click();
}
test("Markdown preview folder links reveal Files and file links still open a file", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "preview-folder-links-" });
await mkdir(path.join(workspace.repoPath, "docs/guides"), { recursive: true });
await writeFile(
path.join(workspace.repoPath, "docs/guides/guide.md"),
"# Folder link destination\n",
);
await writeFile(
path.join(workspace.repoPath, "README.md"),
"[Dot](./docs/guides)\n\n[Bare](docs/guides)\n\n[Guide](docs/guides/guide.md)\n",
);
await workspace.navigateTo();
await openMarkdownPreview(page);
await followPreviewLink(page, "Guide");
await expect(page.getByText("Folder link destination", { exact: true })).toBeVisible();
await openMarkdownPreview(page);
await followPreviewLink(page, "Dot");
await expectFolderContents(page);
await expect(page.getByTestId("workspace-tab-file_docs/guides")).toHaveCount(0);
await openMarkdownPreview(page);
await followPreviewLink(page, "Bare");
await expectFolderContents(page);
await expect(page.getByTestId("workspace-tab-file_docs/guides")).toHaveCount(0);
await page.screenshot({ path: test.info().outputPath("preview-folder-links.png") });
});
async function openReportedFolderLink(page: Page, name: string) {
const link = page.getByTestId("assistant-message").getByRole("link", { name, exact: true });
await link.click();
}
async function expectFolderContents(page: Page) {
await expect(page.getByText("Requested path is not a file", { exact: true })).toHaveCount(0);
await expect(page.getByTestId("file-explorer-tree-scroll")).toBeVisible();
await expect(
page.getByTestId("file-explorer-tree-scroll").getByText("guide.md", { exact: true }),
).toBeVisible();
}
for (const folder of [
"./docs/guides",
"./docs/guides/",
"./docs/reports.v1",
"./.github/workflows",
"docs/guides",
"docs/guides/",
"docs/指南",
]) {
test(`chat folder link opens folder contents: ${folder}`, async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "folder-link-repro-",
title: "Folder link reproduction",
initialPrompt: "Show the folder link",
featureValues: { mockAssistantResponse: `[Open folder](${folder})` },
});
try {
await mkdir(path.join(session.cwd, folder), { recursive: true });
await writeFile(path.join(session.cwd, folder, "guide.md"), "# Folder link destination\n");
await session.client.waitForFinish(session.agentId, 30_000);
await page.addInitScript(() => {
localStorage.setItem(
"panel-state",
JSON.stringify({ state: { explorerShowHiddenFiles: false }, version: 0 }),
);
});
await openAgentRoute(page, session);
await openReportedFolderLink(page, "Open folder");
await expectFolderContents(page);
} finally {
await page.screenshot({ path: test.info().outputPath("folder-link-result.png") });
await session.cleanup();
}
});
}
@@ -0,0 +1,42 @@
import { type Page } from "@playwright/test";
import { test, expect } from "../support/fixtures";
async function openProjectHome(page: Page) {
await page.goto("/open-project");
await expect(page.getByText("Add a project", { exact: true })).toBeVisible();
}
async function scrollToHomeFooter(page: Page) {
await page.mouse.move(160, 350);
await page.mouse.wheel(0, 1500);
}
async function expectHomeActionsReachable(page: Page) {
const providers = page.getByTestId("open-project-setup-providers");
const community = page.getByRole("button", { name: "Community", exact: true });
await expect(providers).toBeInViewport({ ratio: 1 });
await expect(community).toBeInViewport({ ratio: 1 });
const providerBounds = await providers.boundingBox();
const communityBounds = await community.boundingBox();
expect(providerBounds).not.toBeNull();
expect(communityBounds).not.toBeNull();
expect(communityBounds!.y).toBeGreaterThanOrEqual(providerBounds!.y + providerBounds!.height);
}
test("home actions remain reachable on a short phone screen", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 320, height: 568 });
await openProjectHome(page);
await scrollToHomeFooter(page);
await expectHomeActionsReachable(page);
await page.screenshot({ path: testInfo.outputPath("home-scrolled.png") });
});
test("home actions fit on a desktop screen without overlap", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await openProjectHome(page);
await expectHomeActionsReachable(page);
await expect(page.getByTestId("open-project-setup-providers")).toHaveCSS(
"-webkit-app-region",
"none",
);
});
@@ -1,3 +1,4 @@
import { existsSync } from "node:fs";
import overviewCorpus from "../../../protocol/tests/fixtures/plugin-overview.json";
import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { execFileSync } from "node:child_process";
@@ -316,6 +317,47 @@ export default function contribute(plugin) {
return directory;
}
/** Build command that holds the plugin lifecycle queue until the test writes the release file. */
const gatedBuildCommand = [
'const fs = require("node:fs");',
'fs.writeFileSync(process.argv[1], "started");',
"const wait = () => {",
" if (fs.existsSync(process.argv[2])) return;",
" setTimeout(wait, 50);",
"};",
"wait();",
].join("\n");
async function createGatedBuildRepository(
root: string,
gate: { started: string; release: string },
): Promise<string> {
const repository = path.join(root, "gated-repository");
await mkdir(repository);
await writeFile(
path.join(repository, "paseo-plugin.json"),
JSON.stringify({
id: "gated-build-plugin",
description: "Holds the plugin lifecycle queue while its build waits",
requirements: pluginRequirements,
build: [[process.execPath, "-e", gatedBuildCommand, gate.started, gate.release]],
}),
);
await writeFile(path.join(repository, "index.client.tsx"), pluginSource("Gated build plugin"));
execFileSync("git", ["init", "-b", "main"], { cwd: repository, stdio: "ignore" });
execFileSync("git", ["config", "user.name", "Paseo Tests"], {
cwd: repository,
stdio: "ignore",
});
execFileSync("git", ["config", "user.email", "paseo@example.test"], {
cwd: repository,
stdio: "ignore",
});
execFileSync("git", ["add", "-A"], { cwd: repository, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "initial"], { cwd: repository, stdio: "ignore" });
return repository;
}
async function installFailedPlugin(
page: Page,
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>,
@@ -680,3 +722,155 @@ async function installPluginWithUntrustedDescription(
);
await environment.client.installPluginSource({ source: directory });
}
/** Waits for the reload request and the daemon's answer on the wire, so a scenario can measure it. */
function observePluginReload(page: Page) {
const requests: number[] = [];
const responses: number[] = [];
const isSessionMessage = (payload: unknown, type: string): boolean => {
if (typeof payload !== "string") return false;
try {
const envelope = JSON.parse(payload) as { type?: unknown; message?: { type?: unknown } };
const message = envelope.type === "session" ? envelope.message : envelope;
return message?.type === type;
} catch {
return false;
}
};
page.on("websocket", (socket) => {
// The reload request travels client -> daemon (outbound); the answer comes back inbound.
socket.on("framesent", ({ payload }) => {
if (isSessionMessage(payload, "plugin.reload.request")) requests.push(Date.now());
});
socket.on("framereceived", ({ payload }) => {
if (isSessionMessage(payload, "plugin.reload.response")) responses.push(Date.now());
});
});
return {
waitForRequest: () => expect.poll(() => requests.length, { timeout: 30_000 }).toBe(1),
waitForResponse: () => expect.poll(() => responses.length, { timeout: 120_000 }).toBe(1),
requestedAt: () => requests[0]!,
requestCount: () => requests.length,
};
}
/** Starts a gated git install that holds the daemon's plugin lifecycle queue until it is released. */
async function holdPluginLifecycleQueue(
client: PluginEnvironment["client"],
root: string,
): Promise<{ release: () => Promise<void>; settle: () => Promise<void> }> {
const gate = {
started: path.join(root, "build-started"),
release: path.join(root, "build-release"),
};
const repository = await createGatedBuildRepository(root, gate);
const install = client.installPluginSource({ source: `git:${pathToFileURL(repository).href}` });
// The marker wait below can outlive an early failure: a refused preparation, a dropped
// connection, or a build command that exits before writing its marker. Handle that rejection now
// so it never surfaces as an unhandled one, and drain the same promise in finish().
const installDrained = install.catch(() => undefined);
// Every path releases the gate and drains the install: a build still polling for its gate holds
// the daemon's plugin lifecycle queue open, and the fixture's own plugin removal would stall.
const finish = async () => {
await writeFile(gate.release, "go").catch(() => undefined);
await installDrained;
};
try {
// Whichever comes first decides the scenario: the build reports that it started, or the setup
// fails with the daemon's own error instead of a bare marker timeout.
await Promise.race([
expect.poll(() => existsSync(gate.started), { timeout: 30_000 }).toBe(true),
install,
]);
} catch (error) {
// Setup that never reached the build must not hand the caller a queue it cannot settle.
await finish();
throw error;
}
return {
release: () => writeFile(gate.release, "go"),
settle: finish,
};
}
function pluginRowActions(page: Page, pluginId: string) {
return page.getByRole("button", { name: `Actions for ${pluginId}`, exact: true });
}
async function expectPluginRowPending(page: Page, pluginId: string): Promise<void> {
await expect(pluginRowActions(page, pluginId)).toBeDisabled();
}
async function expectPluginRowSettled(page: Page, pluginId: string): Promise<void> {
await expect(pluginRowActions(page, pluginId)).toBeEnabled();
await expect(page.getByLabel(`${pluginId} running`)).toBeVisible();
}
async function capturePluginSurface(page: Page, testInfo: TestInfo, name: string): Promise<void> {
await page.screenshot({
path: testInfo.outputPath(name),
fullPage: true,
animations: "disabled",
});
}
/** The wait under test is the daemon's lifecycle queue, so the scenario has to spend real time. */
async function waitPastFormerReloadDeadline(page: Page, requestedAt: number): Promise<void> {
const remaining = 66_000 - (Date.now() - requestedAt);
if (remaining > 0) await page.waitForTimeout(remaining);
}
test("keeps Reload pending past a minute while the plugin queue is busy", async ({
page,
pluginEnvironment,
}, testInfo) => {
test.setTimeout(180_000);
const { client, directory } = pluginEnvironment;
const pluginDirectory = await createDirectoryPlugin(
directory,
"e2e-plugin",
"Exercises the complete local plugin lifecycle",
"Plugin v1",
);
// Observe before the app loads: its daemon socket comes up with the page.
const reload = observePluginReload(page);
await gotoAppShell(page);
await openPluginSettings(page);
// Plugins on is a precondition here, not the subject: set it through the daemon instead of the
// switch, whose transient "Plugins enabled" confirmation this scenario must not depend on.
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installPluginSource({ source: pluginDirectory });
await expectPluginRowSettled(page, "e2e-plugin");
const queue = await holdPluginLifecycleQueue(client, directory);
try {
await selectPluginAction(page, "e2e-plugin", "Reload");
await reload.waitForRequest();
// Pending: the row's actions stay disabled, so a second reload cannot be submitted.
await expectPluginRowPending(page, "e2e-plugin");
await capturePluginSurface(page, testInfo, "plugin-reload-pending.png");
await waitPastFormerReloadDeadline(page, reload.requestedAt());
// Past the former deadline the reload is still pending, the row shows its previous state, and
// no timeout error reached the surface.
await expectPluginRowPending(page, "e2e-plugin");
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
await expect(page.getByTestId("plugin-management-feedback")).toHaveCount(0);
await capturePluginSurface(page, testInfo, "plugin-reload-past-deadline.png");
await queue.release();
// The daemon's answer only arrives once the plugin really started again; a request timeout
// would have shown up as feedback during the wait above.
await reload.waitForResponse();
expect(reload.requestCount()).toBe(1);
await expect(page.getByTestId("plugin-management-feedback")).toContainText(
"Reloaded e2e-plugin",
);
await expectPluginRowSettled(page, "e2e-plugin");
await capturePluginSurface(page, testInfo, "plugin-reload-completed.png");
} finally {
await queue.settle();
}
});
@@ -1,5 +1,16 @@
import { fork } from "node:child_process";
import path from "node:path";
import { expect } from "@playwright/test";
import { z } from "zod";
import { test } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { expectVisibleAgentSurfacesIdle } from "../support/helpers/agent-stream";
import { buildSeededHost } from "../support/helpers/daemon-registry";
import { killProcessTree } from "../support/helpers/spawn-node";
import {
buildAgentRoute,
openAgentRoute,
seedMockAgentWorkspace,
} from "../support/helpers/mock-agent";
import {
chooseQuestionOption,
continueToNextQuestion,
@@ -130,3 +141,204 @@ test.describe("Question prompt pagination", () => {
}
});
});
const questionDaemonMessage = z.discriminatedUnion("type", [
z.object({ type: z.literal("ready"), port: z.number(), serverId: z.string() }),
z.object({
type: z.literal("attempt"),
requestId: z.string(),
response: z.unknown(),
sessionId: z.string(),
}),
z.object({
type: z.literal("resolved"),
requestId: z.string(),
response: z.unknown(),
sessionId: z.string(),
pendingRequestIds: z.array(z.string()),
}),
z.object({ type: z.literal("error"), error: z.string() }),
]);
type QuestionResponseAttempt = Extract<z.infer<typeof questionDaemonMessage>, { type: "attempt" }>;
type QuestionResolution = Extract<z.infer<typeof questionDaemonMessage>, { type: "resolved" }>;
interface QuestionRetryControl {
attempts: QuestionResponseAttempt[];
resolutions: QuestionResolution[];
releaseFirstResponse(): void;
}
const retryTest = test.extend<{
questionClient: QuestionRetryControl;
}>({
questionClient: async ({ page }, provide, testInfo) => {
const serverDir = path.resolve(__dirname, "../../../server");
const child = fork(path.join(serverDir, "src/server/test-utils/question-response-daemon.ts"), {
cwd: serverDir,
execArgv: ["--import", "tsx"],
stdio: ["ignore", "pipe", "pipe", "ipc"],
});
const output: string[] = [];
child.stdout?.on("data", (data: Buffer) => output.push(data.toString()));
child.stderr?.on("data", (data: Buffer) => output.push(data.toString()));
const ready = Promise.withResolvers<{ port: number; serverId: string }>();
const attempts: QuestionResponseAttempt[] = [];
const resolutions: QuestionResolution[] = [];
child.on("message", (message: unknown) => {
const parsed = questionDaemonMessage.parse(message);
switch (parsed.type) {
case "ready":
ready.resolve(parsed);
break;
case "attempt":
attempts.push(parsed);
break;
case "resolved":
resolutions.push(parsed);
break;
case "error":
ready.reject(new Error(parsed.error));
break;
}
});
child.once("exit", (code) => ready.reject(new Error(`Question daemon exited with ${code}`)));
const timeout = setTimeout(
() => ready.reject(new Error("Question daemon startup timed out")),
20_000,
);
try {
const daemon = await ready.promise;
clearTimeout(timeout);
const session = await seedMockAgentWorkspace({
port: daemon.port,
repoPrefix: "question-retry-",
title: "Question response retry",
initialPrompt: "Emit synthetic questions.",
});
try {
await page.goto("/", { waitUntil: "commit" });
const serverId = daemon.serverId;
const host = buildSeededHost({
serverId,
endpoint: `127.0.0.1:${daemon.port}`,
label: "Question retry host",
nowIso: new Date().toISOString(),
});
await page.evaluate((extraHost) => {
localStorage.setItem("@paseo:e2e-extra-hosts", JSON.stringify([extraHost]));
}, host);
await page.goto(buildAgentRoute(session.workspaceId, session.agentId, serverId), {
waitUntil: "commit",
});
await waitForQuestionPrompt(page, 120_000);
await provide({ attempts, resolutions, releaseFirstResponse: () => child.send("release") });
} finally {
await session.cleanup();
}
} finally {
clearTimeout(timeout);
await killProcessTree(child);
if (testInfo.status !== testInfo.expectedStatus) {
await testInfo.attach("question-daemon", {
body: output.join(""),
contentType: "text/plain",
});
}
}
},
});
retryTest(
"failed question submission preserves answers and allows retry",
async ({ page, questionClient }, testInfo) => {
retryTest.setTimeout(180_000);
const card = page.getByTestId("question-form-card");
await chooseQuestionOption(page, "App");
await chooseQuestionOption(page, "Behind feature flag");
await fillQuestionAnswer(page, {
question: SUCCESS_QUESTION,
answer: "Preserve my answers after a failed submission.",
});
await card.getByRole("button", { name: "Submit" }).click();
await expect(card.getByRole("progressbar")).toBeVisible();
await expectQuestionPrimaryActionDisabled(page, "Submit");
await expect(card.getByRole("button", { name: "Dismiss" })).toBeDisabled();
await expect(card.getByRole("tab").first()).toBeDisabled();
await expect(card.getByRole("textbox")).not.toBeEditable();
questionClient.releaseFirstResponse();
await expectQuestionPrimaryActionEnabled(page, "Submit");
await card.screenshot({ path: testInfo.outputPath("submission-failed.png") });
await expect(card.getByRole("alert")).toHaveText("Could not send your response. Try again.");
await expect(card.getByRole("progressbar")).toHaveCount(0);
await expect(card.getByRole("button", { name: "Submit" })).toHaveText("Submit");
await expectQuestionDismissEnabled(page);
await openQuestion(page, { index: 1, total: TOTAL_QUESTIONS });
await expectQuestionOptionSelected(page, "App");
await openQuestion(page, { index: 2, total: TOTAL_QUESTIONS });
await expectQuestionOptionSelected(page, "Behind feature flag");
await openQuestion(page, { index: 3, total: TOTAL_QUESTIONS });
await expect(card.getByRole("textbox")).toHaveValue(
"Preserve my answers after a failed submission.",
);
await submitQuestionAnswers(page);
await expectVisibleAgentSurfacesIdle(page);
await expect.poll(() => questionClient.attempts.length).toBe(2);
expect(questionClient.attempts[1]).toEqual(questionClient.attempts[0]);
await expect
.poll(() => questionClient.resolutions)
.toEqual([{ ...questionClient.attempts[1], type: "resolved", pendingRequestIds: [] }]);
expect(questionClient.attempts[1].response).toMatchObject({
behavior: "allow",
updatedInput: {
answers: {
surface: "App",
rollout: "Behind feature flag",
success: "Preserve my answers after a failed submission.",
},
},
});
await page.screenshot({ path: testInfo.outputPath("submission-retried.png") });
},
);
retryTest(
"failed question dismissal allows an immediate retry",
async ({ page, questionClient }, testInfo) => {
retryTest.setTimeout(180_000);
const card = page.getByTestId("question-form-card");
questionClient.releaseFirstResponse();
await chooseQuestionOption(page, "App");
await chooseQuestionOption(page, "Behind feature flag");
await fillQuestionAnswer(page, {
question: SUCCESS_QUESTION,
answer: "Start with the internal team.",
});
await card.getByRole("button", { name: "Dismiss" }).click();
await expect(card.getByRole("alert")).toHaveText("Could not send your response. Try again.");
await expect(card.getByRole("progressbar")).toHaveCount(0);
await expect(card.getByRole("button", { name: "Dismiss" })).toHaveText("Dismiss");
await expectQuestionDismissEnabled(page);
await expectQuestionPrimaryActionEnabled(page, "Submit");
await card.screenshot({ path: testInfo.outputPath("dismissal-failed.png") });
await openQuestion(page, { index: 1, total: TOTAL_QUESTIONS });
await expectQuestionOptionSelected(page, "App");
await openQuestion(page, { index: 3, total: TOTAL_QUESTIONS });
await expect(card.getByRole("textbox")).toHaveValue("Start with the internal team.");
await expect(card.getByRole("alert")).toBeVisible();
await card.getByRole("button", { name: "Dismiss" }).click();
await expect(card).toHaveCount(0);
await expectVisibleAgentSurfacesIdle(page);
await expect.poll(() => questionClient.attempts.length).toBe(2);
expect(questionClient.attempts[1]).toEqual(questionClient.attempts[0]);
await expect
.poll(() => questionClient.resolutions)
.toEqual([{ ...questionClient.attempts[1], type: "resolved", pendingRequestIds: [] }]);
expect(questionClient.attempts[1].response).toEqual({
behavior: "deny",
message: "Dismissed by user",
});
await page.screenshot({ path: testInfo.outputPath("dismissal-retried.png") });
},
);
+54 -62
View File
@@ -87,13 +87,10 @@ import {
} from "./bottom-anchor-controller";
import { createAssistantImageOccurrenceKey } from "@/assistant-image/acquisition-cache";
import { AssistantSelectionCopySurface } from "@/assistant-selection-copy/surface";
import {
AssistantFileLinkResolverProvider,
normalizeInlinePathTarget,
} from "@/assistant-file-links";
import { AssistantFileLinkResolverProvider, createInlinePathOpener } from "@/assistant-file-links";
import {
createWorkspaceFileTabTarget,
normalizeWorkspaceFileLocation,
type WorkspaceFileLocation,
type OpenFileDisposition,
type WorkspaceFileOpenRequest,
} from "@/workspace/file-open";
@@ -387,7 +384,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
);
const workspaceRoot = context.cwd?.trim() || "";
const { requestDirectoryListing } = useFileExplorerActions({
const { revealDirectory } = useFileExplorerActions({
serverId: resolvedServerId,
workspaceId: context.workspaceId,
workspaceRoot,
@@ -426,63 +423,53 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
setExpandedToolCallGroupIds(new Set());
}, [agentId]);
const isActive = useRetainedPanelActive();
const openInlineFile = useStableEvent(
(location: WorkspaceFileLocation, disposition: OpenFileDisposition) => {
if (onOpenWorkspaceFile) {
onOpenWorkspaceFile({ location, disposition });
} else if (context.workspaceId) {
navigateToWorkspace({
serverId: resolvedServerId,
workspaceId: context.workspaceId,
target: createWorkspaceFileTabTarget(location),
});
}
},
);
const openInlineDirectory = useStableEvent((directoryPath: string) => {
void revealDirectory(directoryPath);
openExplorerSidebarView({
isCompact: isMobile,
workspaceKey: buildWorkspaceTabPersistenceKey({
serverId: resolvedServerId,
workspaceId: context.workspaceId ?? "",
}),
checkout: {
serverId: resolvedServerId,
cwd: context.cwd,
isGit: context.projectPlacement?.checkout?.isGit ?? true,
},
view: "files",
});
});
const inlinePathOpener = useMemo(
() =>
createInlinePathOpener({
workspaceRoot,
client,
openFile: openInlineFile,
openDirectory: openInlineDirectory,
}),
[workspaceRoot, client, openInlineFile, openInlineDirectory],
);
useEffect(() => {
if (!isActive) inlinePathOpener.cancel();
return () => inlinePathOpener.cancel();
}, [inlinePathOpener, isActive, agentId]);
const handleInlinePathPress = useStableEvent(
(target: InlinePathTarget, disposition: OpenFileDisposition) => {
if (!target.path) {
return;
}
const normalized = normalizeInlinePathTarget(target.path, context.cwd);
if (!normalized) {
return;
}
if (normalized.file) {
const location = normalizeWorkspaceFileLocation({
path: normalized.file,
lineStart: target.lineStart,
lineEnd: target.lineEnd,
});
if (!location) {
return;
}
if (onOpenWorkspaceFile) {
onOpenWorkspaceFile({
location,
disposition,
});
return;
}
if (context.workspaceId) {
navigateToWorkspace({
serverId: resolvedServerId,
workspaceId: context.workspaceId,
target: createWorkspaceFileTabTarget(location),
});
}
return;
}
void requestDirectoryListing(normalized.directory, {
recordHistory: false,
setCurrentPath: false,
});
openExplorerSidebarView({
isCompact: isMobile,
workspaceKey: buildWorkspaceTabPersistenceKey({
serverId: resolvedServerId,
workspaceId: context.workspaceId ?? "",
}),
checkout: {
serverId: resolvedServerId,
cwd: context.cwd,
isGit: context.projectPlacement?.checkout?.isGit ?? true,
},
view: "files",
});
if (isActive) void inlinePathOpener.open(target, disposition);
},
);
@@ -519,7 +506,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
// cell-window and turn-lifecycle renders from background agents.
// When isActive flips back to true, the context change triggers a re-render and
// the component reads the current (fresh) streamItems/streamHead from props.
const isActive = useRetainedPanelActive();
const effectiveStreamItems = useRetainedValue(streamItems, isActive);
const effectiveStreamHead = useRetainedValue(streamHead, isActive);
const effectiveTurnPresentation = useRetainedValue(turnPresentation, isActive);
@@ -1531,11 +1517,17 @@ function PermissionRequestCard({
);
if (request.kind === "question") {
const responseFailed =
permissionMutation.isError &&
permissionMutation.variables?.agentId === permission.agentId &&
permissionMutation.variables?.requestId === permission.request.id;
const responseError = responseFailed ? t("message.question.responseFailed") : null;
return (
<QuestionFormCard
permission={permission}
onRespond={handleResponse}
isResponding={isResponding}
responseError={responseError}
/>
);
}
@@ -10,3 +10,5 @@ export {
} from "./provider";
export type { AssistantFileLinkSource } from "./resolver";
export { useAssistantFileLinkActions, useFileLink } from "./use-file-link";
export { createInlinePathOpener } from "./path-open";
@@ -23,6 +23,8 @@ const INLINE_PAREN_LINE_SUFFIX = /^(.+?)\(([0-9]+)(?:,[0-9]+)?(?:-([0-9]+)(?:,[0
const INLINE_WORD_LINE_SUFFIX = /^(.+?)\s+lines?\s+([0-9]+)(?:-([0-9]+))?$/i;
export interface AssistantHrefParseOptions {
workspaceRoot?: string;
/** Authored links identify a file; only inferred text needs filename heuristics. */
explicitLink?: boolean;
/**
* True only when `value` is a markdown href — the renderer percent-encodes
* non-ASCII and space characters, so the path must be decoded. Raw path
@@ -234,7 +236,7 @@ export function classifyAssistantFileLink(
return null;
}
if (isAmbiguousWorkspaceCandidate(trimmed, target, options)) {
if (!options.explicitLink && isAmbiguousWorkspaceCandidate(trimmed, target, options)) {
return {
kind: "ambiguousFileCandidate",
target,
@@ -278,6 +280,7 @@ export function parseAssistantFileLink(
const relativeTarget = parseWorkspaceRelativeFileLink(trimmed, {
workspaceRoot: options.workspaceRoot,
decodeHref: options.decodeHref,
explicitLink: options.explicitLink,
});
if (relativeTarget) {
return relativeTarget;
@@ -411,7 +414,7 @@ function parseWorkspaceRelativeFileLink(
function parseLocalPathParts(
value: string,
options: Pick<AssistantHrefParseOptions, "decodeHref"> = {},
options: Pick<AssistantHrefParseOptions, "decodeHref" | "explicitLink"> = {},
): {
path: string;
lines: Pick<InlinePathTarget, "lineStart" | "lineEnd">;
@@ -437,7 +440,7 @@ function parseLocalPathParts(
if (options.decodeHref) {
pathPart = safeDecodeURIComponent(pathPart);
}
if (!isPlausibleAssistantLocalPath(pathPart)) {
if (!options.explicitLink && !isPlausibleAssistantLocalPath(pathPart)) {
return null;
}
return {
@@ -458,7 +461,7 @@ function parseLocalPathParts(
decodedBeforeHash = safeDecodeURIComponent(beforeHash);
}
const plausiblePath = parseInlinePathToken(decodedBeforeHash)?.path ?? decodedBeforeHash;
if (!isPlausibleAssistantLocalPath(plausiblePath)) {
if (!options.explicitLink && !isPlausibleAssistantLocalPath(plausiblePath)) {
return null;
}
@@ -0,0 +1,104 @@
import { mkdtemp, mkdir, readdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, expect, it } from "vitest";
import { resolveInlinePathOpen } from "./path-open";
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
it("distinguishes dotted and extensionless folders from files using the filesystem", async () => {
const workspaceRoot = await mkdtemp(path.join(tmpdir(), "folder-link-"));
roots.push(workspaceRoot);
await mkdir(path.join(workspaceRoot, "docs/guides"), { recursive: true });
await mkdir(path.join(workspaceRoot, "docs/reports.v1"));
await writeFile(path.join(workspaceRoot, "Makefile"), "all:\n");
await writeFile(path.join(workspaceRoot, "README.md"), "# Readme\n");
const client = {
listDirectory: (root: string, file: string) => readdir(path.resolve(root, file)),
};
for (const file of ["docs/guides", "docs/reports.v1"]) {
await expect(
resolveInlinePathOpen({ file, directory: "docs", workspaceRoot, client }),
).resolves.toEqual({ kind: "directory", directoryPath: file });
}
for (const file of ["Makefile", "README.md", "missing.md"]) {
await expect(
resolveInlinePathOpen({ file, directory: ".", workspaceRoot, client }),
).resolves.toEqual({ kind: "file" });
}
});
it("preserves explicit directory destinations and file line references", async () => {
const workspaceRoot = "/repo";
await expect(
resolveInlinePathOpen({ directory: "docs/guides", workspaceRoot, client: null }),
).resolves.toEqual({ kind: "directory", directoryPath: "docs/guides" });
await expect(
resolveInlinePathOpen({
file: "docs/guides",
directory: "docs",
lineStart: 12,
workspaceRoot,
client: null,
}),
).resolves.toEqual({ kind: "file" });
});
it("keeps unavailable probes and external paths on the existing file-open path", async () => {
for (const file of ["Makefile", "/external/folder", "C:/external/folder"]) {
await expect(
resolveInlinePathOpen({ file, directory: ".", workspaceRoot: "/repo", client: null }),
).resolves.toEqual({ kind: "file" });
}
});
it("ignores an older probe after a newer file link opens", async () => {
const { createInlinePathOpener } = await import("./path-open");
let rejectProbe!: (error: Error) => void;
const pendingProbe = new Promise<never>((_, reject) => {
rejectProbe = reject;
});
const opened: string[] = [];
const opener = createInlinePathOpener({
workspaceRoot: "/repo",
client: { listDirectory: () => pendingProbe },
openFile: (location) => {
opened.push(location.path);
},
openDirectory: (directory) => {
opened.push(directory);
},
});
const first = opener.open({ raw: "./first.md", path: "/repo/first.md" }, "preferred");
await opener.open({ raw: "./second.md:2", path: "/repo/second.md", lineStart: 2 }, "preferred");
rejectProbe(new Error("Not a directory"));
await first;
expect(opened).toEqual(["second.md"]);
});
it("ignores a pending probe when its view is left", async () => {
const { createInlinePathOpener } = await import("./path-open");
let resolveProbe!: (value: unknown) => void;
const pendingProbe = new Promise((resolve) => {
resolveProbe = resolve;
});
const opened: string[] = [];
const opener = createInlinePathOpener({
workspaceRoot: "/repo",
client: { listDirectory: () => pendingProbe },
openFile: (location) => {
opened.push(location.path);
},
openDirectory: (directory) => {
opened.push(directory);
},
});
const pending = opener.open({ raw: "./docs", path: "/repo/docs" }, "preferred");
opener.cancel();
resolveProbe({});
await pending;
expect(opened).toEqual([]);
});
@@ -0,0 +1,74 @@
import { normalizeInlinePathTarget, type InlinePathTarget } from "./parse";
import {
normalizeWorkspaceFileLocation,
type WorkspaceFileLocation,
type OpenFileDisposition,
} from "@/workspace/file-open";
import { isAbsolutePath } from "@/utils/path";
interface PathOpenInput {
file?: string;
directory: string;
lineStart?: number;
workspaceRoot: string;
client: { listDirectory(root: string, path: string): Promise<unknown> } | null;
}
type PathOpenTarget = { kind: "file" } | { kind: "directory"; directoryPath: string };
/** Resolve workspace folders from filesystem facts, including names that contain dots. */
export async function resolveInlinePathOpen(input: PathOpenInput): Promise<PathOpenTarget> {
if (!input.file) {
return { kind: "directory", directoryPath: input.directory };
}
if (
input.lineStart !== undefined ||
isAbsolutePath(input.file) ||
!input.workspaceRoot.trim() ||
!input.client
) {
return { kind: "file" };
}
try {
await input.client.listDirectory(input.workspaceRoot, input.file);
} catch {
return { kind: "file" };
}
return { kind: "directory", directoryPath: input.file };
}
export function createInlinePathOpener(input: {
workspaceRoot: string;
client: PathOpenInput["client"];
openFile: (location: WorkspaceFileLocation, disposition: OpenFileDisposition) => void;
openDirectory: (path: string) => void;
}) {
let generation = 0;
return {
async open(target: InlinePathTarget, disposition: OpenFileDisposition) {
const ownGeneration = ++generation;
const normalized = normalizeInlinePathTarget(target.path, input.workspaceRoot);
if (!normalized) return;
const destination = await resolveInlinePathOpen({
...normalized,
lineStart: target.lineStart,
workspaceRoot: input.workspaceRoot,
client: input.client,
});
if (ownGeneration !== generation) return;
if (destination.kind === "directory") {
input.openDirectory(destination.directoryPath);
} else if (normalized.file) {
const location = normalizeWorkspaceFileLocation({
path: normalized.file,
lineStart: target.lineStart,
lineEnd: target.lineEnd,
});
if (location) input.openFile(location, disposition);
}
},
cancel() {
generation += 1;
},
};
}
@@ -18,6 +18,63 @@ const CONTEXT: AssistantFileLinkContext = {
workspaceRoot: "/Users/test/project",
};
it.each(["docs/guides", "docs/guides/", "docs/%E6%8C%87%E5%8D%97"])(
"resolves an explicit extensionless folder href: %s",
(href) => {
expect(classifyForResolution({ href }, CONTEXT)).toMatchObject({
kind: "resolved",
value: {
kind: "file",
target: { path: `${CONTEXT.workspaceRoot}/${decodeURIComponent(href).replace(/\/$/, "")}` },
},
});
},
);
it.each(["docs/guides", "origin/main"])("leaves extensionless inline code alone: %s", (text) => {
expect(classifyForResolution({ href: text, text, sourceType: "inline-code" }, CONTEXT)).toEqual({
kind: "resolved",
value: { kind: "ignored" },
});
});
it.each([{ markup: "linkify" }, { sourceInfo: "auto" }])(
"keeps auto-recognized extensionless text ignored: %j",
(origin) => {
expect(
classifyForResolution({ href: "docs/guides", text: "docs/guides", ...origin }, CONTEXT),
).toEqual({
kind: "resolved",
value: { kind: "ignored" },
});
},
);
it.each(["docs/guides?view=all"])(
"does not turn URL-like hrefs into workspace paths: %s",
(href) => {
expect(classifyForResolution({ href }, CONTEXT)).toEqual({
kind: "resolved",
value: { kind: "ignored" },
});
},
);
it.each([
".dev/tablet-review/tablet-agent-draft.png",
"images/capture.jpg",
"downloads/archive.custom",
"assets/LICENSE",
"example.com/guides",
"capture.png",
"missing.png",
])("opens explicit file link %s without filename guessing", (href) => {
expect(classifyForResolution({ href }, CONTEXT)).toMatchObject({
kind: "resolved",
value: { kind: "file", target: { path: `${CONTEXT.workspaceRoot}/${href}` } },
});
});
it("opens the existing non-ASCII file named by a rendered markdown link", async () => {
const workspaceRoot = await mkdtemp(path.join(tmpdir(), "assistant-file-link-"));
try {
@@ -117,6 +117,7 @@ export function classifyForResolution(
const classification = classifyAssistantFileLink(token, {
workspaceRoot: context.workspaceRoot,
decodeHref: tokenInfo.fromHref,
explicitLink: source.sourceType !== "inline-code" && !isLinkifiedSource(source),
});
if (!classification) {
return { kind: "resolved", value: { kind: "ignored" } };
@@ -2,6 +2,7 @@ export interface AudioEngineCallbacks {
onCaptureData(pcm: Uint8Array): void;
onVolumeLevel(level: number): void;
onInterruption?(): void;
onCaptureAvailabilityChange?(available: boolean): void;
onError?(error: Error): void;
}
@@ -25,3 +26,8 @@ export interface AudioEngine {
clearQueue(): void;
isPlaying(): boolean;
}
export interface AudioEngineOptions {
traceLabel?: string;
captureMode?: "conversation" | "dictation";
}
+18 -3
View File
@@ -1,4 +1,9 @@
import type { AudioEngine, AudioEngineCallbacks, AudioPlaybackSource } from "./audio-engine-types";
import type {
AudioEngine,
AudioEngineCallbacks,
AudioEngineOptions,
AudioPlaybackSource,
} from "./audio-engine-types";
import { createAudioPlayer, setAudioModeAsync } from "expo-audio";
import { File, Paths } from "expo-file-system";
@@ -8,7 +13,7 @@ import { playPcm16 } from "./pcm";
export function createAudioEngine(
callbacks: AudioEngineCallbacks,
_options?: { traceLabel?: string },
options?: AudioEngineOptions,
): AudioEngine {
const native = require("@getpaseo/expo-two-way-audio");
@@ -47,6 +52,12 @@ export function createAudioEngine(
const interruptionSubscription = native.addExpoTwoWayAudioEventListener(
"onAudioInterruption",
(event: { data: string }) => {
if (event.data === "capture_paused" || event.data === "capture_resumed") {
if (refs.captureActive) {
callbacks.onCaptureAvailabilityChange?.(event.data === "capture_resumed");
}
return;
}
if (event.data !== "blocked") {
return;
}
@@ -180,7 +191,10 @@ export function createAudioEngine(
try {
await ensureMicrophonePermission();
await ensureInitialized();
const isRecording = native.toggleRecording(true);
const isRecording =
options?.captureMode === "dictation"
? native.startDictation()
: native.toggleRecording(true);
if (!isRecording) {
throw new Error(
"Microphone capture could not start because Android audio focus is unavailable.",
@@ -201,6 +215,7 @@ export function createAudioEngine(
refs.captureActive = false;
refs.muted = false;
callbacks.onVolumeLevel(0);
callbacks.onCaptureAvailabilityChange?.(true);
releaseSessionIfIdle();
},
+7 -2
View File
@@ -1,6 +1,11 @@
import { parsePcmSampleRate, resampleToPcm16 } from "./pcm";
import { isElectronRuntime } from "@/desktop/host";
import type { AudioEngine, AudioEngineCallbacks, AudioPlaybackSource } from "./audio-engine-types";
import type {
AudioEngine,
AudioEngineCallbacks,
AudioEngineOptions,
AudioPlaybackSource,
} from "./audio-engine-types";
import { createPlaybackQueue } from "./playback";
@@ -46,7 +51,7 @@ async function decodeAudioData(context: AudioContext, buffer: ArrayBuffer): Prom
export function createAudioEngine(
callbacks: AudioEngineCallbacks,
_options?: { traceLabel?: string },
_options?: AudioEngineOptions,
): AudioEngine {
const refs: {
playbackContext: AudioContext | null;
@@ -87,7 +87,9 @@ export function DictationControls({
<View style={styles.meterWrapper}>
<VolumeMeter volume={volume} isMuted={false} isSpeaking={false} orientation="horizontal" />
</View>
<Text style={timerTextStyle}>{formatDuration(duration)}</Text>
<Text style={timerTextStyle}>
{status === "paused" ? t("message.dictation.paused") : formatDuration(duration)}
</Text>
<View style={styles.actionGroup}>
<Pressable
onPress={handleCancel}
@@ -210,6 +212,11 @@ export function DictationOverlay({
/>
<Text style={overlayTimerTextStyle}>{formatDuration(duration)}</Text>
</View>
{status === "paused" ? (
<Text numberOfLines={2} style={overlayTranscriptTextStyle}>
{t("message.dictation.paused")}
</Text>
) : null}
{isFailed ? (
<Text numberOfLines={2} style={overlayTranscriptTextStyle}>
{errorText
@@ -58,6 +58,7 @@ function mountCard(question: Record<string, unknown>) {
permission={buildPermission(question)}
onRespond={onRespond}
isResponding={false}
responseError={null}
/>,
),
);
@@ -1,3 +1,4 @@
import { Alert } from "@/components/ui/alert";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useState, useCallback, useMemo, useRef, type RefObject } from "react";
import { View, Text, Pressable, type PressableStateCallbackType } from "react-native";
@@ -27,6 +28,7 @@ interface QuestionFormCardProps {
permission: PendingPermission;
onRespond: (response: AgentPermissionResponse) => void;
isResponding: boolean;
responseError: string | null;
}
const IS_WEB = isWeb;
@@ -339,7 +341,12 @@ function QuestionOtherInput({
);
}
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
export function QuestionFormCard({
permission,
onRespond,
isResponding,
responseError,
}: QuestionFormCardProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor();
@@ -438,7 +445,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
]);
const handleDeny = useCallback(() => {
if (!questions) return;
if (!questions || isResponding) return;
setRespondingAction("dismiss");
if (shouldSubmitEmptyOnDismiss(questions)) {
onRespond({
@@ -454,7 +461,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
behavior: "deny",
message: "Dismissed by user",
});
}, [questions, onRespond, otherTexts, permission.request.input, selections]);
}, [questions, isResponding, onRespond, otherTexts, permission.request.input, selections]);
const handleSelectQuestion = useCallback((index: number) => {
setActiveQuestionIndex(index);
@@ -487,6 +494,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
[theme.colors.surface2, theme.colors.surface1, theme.colors.borderAccent],
);
const pendingAction = isResponding ? respondingAction : null;
const primaryDisabled = isResponding || (isLastQuestion ? !allAnswered : !activeQuestionAnswered);
const primaryActionLabel = isLastQuestion
? t("message.question.submit")
@@ -550,7 +558,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
const dismissLabel = resolveDismissLabel(questions, t("common.actions.dismiss"));
const selected = selections[resolvedActiveQuestionIndex] ?? new Set<number>();
const otherText = otherTexts[resolvedActiveQuestionIndex] ?? "";
const showTextInput = activeQuestion ? questionShowsTextInput(activeQuestion) : false;
const showTextInput = questionShowsTextInput(questions[resolvedActiveQuestionIndex]);
return (
<View style={containerStyle} testID="question-form-card">
@@ -604,6 +612,8 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
</View>
) : null}
{responseError ? <Alert variant="error" description={responseError} size="sm" /> : null}
<View style={actionsContainerStyle}>
<Pressable
style={dismissButtonStyle}
@@ -613,7 +623,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
accessibilityLabel={dismissLabel}
testID="question-form-dismiss"
>
{respondingAction === "dismiss" ? (
{pendingAction === "dismiss" ? (
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
) : (
<View style={styles.actionContent}>
@@ -631,7 +641,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
accessibilityLabel={primaryActionLabel}
testID="question-form-primary-action"
>
{respondingAction === "submit" ? (
{pendingAction === "submit" ? (
<LoadingSpinner size="small" color={theme.colors.accentForeground} />
) : (
<View style={styles.actionContent}>
+1 -1
View File
@@ -2226,7 +2226,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
},
// Lifts the composer off the chat it sits under on phones.
inputWrapperCompact: {
boxShadow: "0px 4px 16px rgba(0, 0, 0, 0.2)",
...theme.shadow.sm,
},
// Dotted says "this surface is the same box, but there is nothing to type
// into it" without swapping the border colour, which reads as an error state.
+22 -1
View File
@@ -1,6 +1,6 @@
import type { ExplorerDirectory, ExplorerEntry } from "@/stores/session-store";
import type { SortOption } from "@/stores/panel-store/state";
import { filterVisibleExplorerEntries } from "./visibility";
import { filterVisibleExplorerEntries, isHiddenExplorerPath } from "./visibility";
export const MAX_AUTO_EXPANDED_DIRECTORY_DEPTH = 5;
@@ -201,3 +201,24 @@ function sortExplorerEntries(entries: ExplorerEntry[], sortOption: SortOption):
});
return sorted;
}
/** Load and expand the path to a directory selected outside the tree. */
export async function revealExplorerDirectory(input: {
directoryPath: string;
requestDirectoryListing: (path: string) => Promise<ExplorerDirectory | null>;
expandPaths: (paths: string[]) => void;
showHiddenFiles: () => void;
}): Promise<void> {
if (isHiddenExplorerPath(input.directoryPath)) input.showHiddenFiles();
const paths = ["."];
if (input.directoryPath !== ".") {
const segments = input.directoryPath.split("/").filter(Boolean);
for (let length = 1; length <= segments.length; length += 1) {
paths.push(segments.slice(0, length).join("/"));
}
}
for (const path of paths) {
if (!(await input.requestDirectoryListing(path))) return;
}
input.expandPaths(paths);
}
@@ -14,6 +14,7 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
const onErrorRef = useRef(config.onError);
const onInterruptionRef = useRef(config.onInterruption);
const [volume, setVolume] = useState(0);
const [isPaused, setIsPaused] = useState(false);
const engineRef = useRef<ReturnType<typeof createAudioEngine> | null>(null);
const getOrCreateEngine = useCallback(() => {
@@ -21,20 +22,24 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
return engineRef.current;
}
engineRef.current = createAudioEngine({
onCaptureData: (pcm) => {
onPcmSegmentRef.current(Buffer.from(pcm).toString("base64"));
engineRef.current = createAudioEngine(
{
onCaptureData: (pcm) => {
onPcmSegmentRef.current(Buffer.from(pcm).toString("base64"));
},
onVolumeLevel: (level) => {
setVolume(level);
},
onError: (error) => {
onErrorRef.current?.(error);
},
onInterruption: () => {
onInterruptionRef.current?.();
},
onCaptureAvailabilityChange: (available) => setIsPaused(!available),
},
onVolumeLevel: (level) => {
setVolume(level);
},
onError: (error) => {
onErrorRef.current?.(error);
},
onInterruption: () => {
onInterruptionRef.current?.();
},
});
{ captureMode: "dictation" },
);
return engineRef.current;
}, []);
@@ -67,5 +72,6 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
start,
stop,
volume,
isPaused,
};
}
@@ -8,4 +8,5 @@ export interface DictationAudioSource {
start: () => Promise<void>;
stop: () => Promise<void>;
volume: number;
isPaused: boolean;
}
@@ -442,6 +442,7 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
},
stop,
volume,
isPaused: false,
}),
[start, stop, volume],
);
@@ -1,6 +1,6 @@
import { i18n } from "@/i18n/i18next";
export type DictationStatus = "idle" | "recording" | "uploading" | "failed";
export type DictationStatus = "idle" | "recording" | "paused" | "uploading" | "failed";
export interface UseDictationOptions {
client: import("@getpaseo/client/internal/daemon-client").DaemonClient | null;
+1 -1
View File
@@ -461,7 +461,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
volume: audio.volume,
duration,
error,
status,
status: status === "recording" && audio.isPaused ? "paused" : status,
startDictation,
cancelDictation,
confirmDictation,
@@ -6,6 +6,8 @@ import {
type ExplorerDirectory,
} from "@/stores/session-store";
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
import { usePanelStore } from "@/stores/panel-store";
import { revealExplorerDirectory } from "@/file-explorer/tree";
import { parentExplorerPath } from "@/utils/explorer-paths";
function createExplorerState(): AgentFileExplorerState {
@@ -172,6 +174,27 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
[client, normalizedWorkspaceRoot, t, updateExplorerState, workspaceStateKey],
);
const revealDirectory = useCallback(
async (directoryPath: string) => {
if (!workspaceStateKey) return;
await revealExplorerDirectory({
directoryPath,
requestDirectoryListing,
showHiddenFiles: () => {
const panel = usePanelStore.getState();
if (!panel.explorerShowHiddenFiles) panel.toggleExplorerShowHiddenFiles();
},
expandPaths: (paths) =>
usePanelStore
.getState()
.setExpandedPathsForWorkspace(workspaceStateKey, (current) =>
Array.from(new Set([...current, ...paths])),
),
});
},
[requestDirectoryListing, workspaceStateKey],
);
const requestFilePreview = useCallback(
async (path: string) => {
if (!workspaceStateKey) {
@@ -354,6 +377,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
return {
workspaceStateKey,
revealDirectory,
requestDirectoryListing,
requestFilePreview,
requestFileDownloadToken,
+2
View File
@@ -360,6 +360,7 @@ export const ar: TranslationResources = {
details: "تفاصيل",
},
dictation: {
paused: "تمت مقاطعة الميكروفون. في انتظار الاستئناف…",
start: "بدء الإملاء الصوتي",
cancel: "إلغاء الإملاء",
retry: "أعد محاولة الإملاء",
@@ -369,6 +370,7 @@ export const ar: TranslationResources = {
failedRetry: "فشل الإملاء. اضغط على إعادة المحاولة.",
},
question: {
responseFailed: "تعذر إرسال ردك. حاول مرة أخرى.",
submit: "يُقدِّم",
next: "التالي",
answerPlaceholder: "اكتب إجابتك...",
+2
View File
@@ -357,6 +357,7 @@ export const en = {
details: "Details",
},
dictation: {
paused: "Microphone interrupted. Waiting to resume…",
start: "Start voice dictation",
cancel: "Cancel dictation",
retry: "Retry dictation",
@@ -366,6 +367,7 @@ export const en = {
failedRetry: "Dictation failed. Tap retry.",
},
question: {
responseFailed: "Could not send your response. Try again.",
submit: "Submit",
next: "Next",
answerPlaceholder: "Type your answer...",
+2
View File
@@ -365,6 +365,7 @@ export const es: TranslationResources = {
details: "Detalles",
},
dictation: {
paused: "Micrófono interrumpido. Esperando para reanudar…",
start: "Iniciar dictado de voz",
cancel: "Cancelar dictado",
retry: "Reintentar el dictado",
@@ -374,6 +375,7 @@ export const es: TranslationResources = {
failedRetry: "El dictado falló. Toca reintentar.",
},
question: {
responseFailed: "No se pudo enviar tu respuesta. Inténtalo de nuevo.",
submit: "Entregar",
next: "Siguiente",
answerPlaceholder: "Escribe tu respuesta...",
+2
View File
@@ -365,6 +365,7 @@ export const fr: TranslationResources = {
details: "Détails",
},
dictation: {
paused: "Microphone interrompu. En attente de reprise…",
start: "Démarrer la dictée vocale",
cancel: "Annuler la dictée",
retry: "Relancer la dictée",
@@ -374,6 +375,7 @@ export const fr: TranslationResources = {
failedRetry: "La dictée a échoué. Appuyez sur Réessayer.",
},
question: {
responseFailed: "Impossible d’envoyer votre réponse. Réessayez.",
submit: "Envoyer",
next: "Suivant",
answerPlaceholder: "Saisissez votre réponse…",
+2
View File
@@ -365,6 +365,7 @@ export const ja: TranslationResources = {
details: "詳細",
},
dictation: {
paused: "マイクが中断されました。再開を待っています…",
start: "音声入力を開始",
cancel: "音声入力をキャンセル",
retry: "音声入力を再試行",
@@ -374,6 +375,7 @@ export const ja: TranslationResources = {
failedRetry: "音声入力に失敗しました。再試行をタップしてください。",
},
question: {
responseFailed: "回答を送信できませんでした。もう一度お試しください。",
submit: "送信",
next: "次へ",
answerPlaceholder: "回答を入力...",
+2
View File
@@ -361,6 +361,7 @@ export const ko: TranslationResources = {
details: "세부 정보",
},
dictation: {
paused: "마이크가 중단되었습니다. 재개를 기다리는 중…",
start: "음성 받아쓰기 시작",
cancel: "받아쓰기 취소",
retry: "받아쓰기 다시 시도",
@@ -370,6 +371,7 @@ export const ko: TranslationResources = {
failedRetry: "받아쓰기에 실패했습니다. 다시 시도하려면 누르세요.",
},
question: {
responseFailed: "응답을 보내지 못했습니다. 다시 시도해 주세요.",
submit: "제출",
next: "다음",
answerPlaceholder: "답변을 입력하세요...",
+2
View File
@@ -364,6 +364,7 @@ export const ptBR: TranslationResources = {
details: "Detalhes",
},
dictation: {
paused: "Microfone interrompido. Aguardando para retomar…",
start: "Iniciar ditado por voz",
cancel: "Cancelar ditado",
retry: "Tentar ditado novamente",
@@ -373,6 +374,7 @@ export const ptBR: TranslationResources = {
failedRetry: "Falha no ditado. Toque para tentar novamente.",
},
question: {
responseFailed: "Não foi possível enviar sua resposta. Tente novamente.",
submit: "Enviar",
next: "Próximo",
answerPlaceholder: "Digite sua resposta...",
+2
View File
@@ -364,6 +364,7 @@ export const ru: TranslationResources = {
details: "Подробности",
},
dictation: {
paused: "Микрофон прерван. Ожидание возобновления…",
start: "Начать диктовку голосом",
cancel: "Отменить диктовку",
retry: "Повторить диктовку",
@@ -373,6 +374,7 @@ export const ru: TranslationResources = {
failedRetry: "Диктовка не удалась. Нажмите «Повторить».",
},
question: {
responseFailed: "Не удалось отправить ответ. Попробуйте ещё раз.",
submit: "Отправить",
next: "Далее",
answerPlaceholder: "Введите ответ...",
+2
View File
@@ -360,6 +360,7 @@ export const zhCN: TranslationResources = {
details: "详情",
},
dictation: {
paused: "麦克风已中断,正在等待恢复…",
start: "开始语音听写",
cancel: "取消听写",
retry: "重试听写",
@@ -369,6 +370,7 @@ export const zhCN: TranslationResources = {
failedRetry: "听写失败。点按重试。",
},
question: {
responseFailed: "未能发送你的回复,请重试。",
submit: "提交",
next: "下一步",
answerPlaceholder: "输入你的回答...",
@@ -1,7 +1,7 @@
import { useHosts, useHostRuntimeLastError } from "@/runtime/host-runtime";
import { useCallback, useEffect, useState, type ComponentType } from "react";
import { useTranslation } from "react-i18next";
import { View, Text, Pressable } from "react-native";
import { View, Text, Pressable, ScrollView } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useRouter } from "expo-router";
import { FolderOpen, Inbox, Plug, Smartphone } from "lucide-react-native";
@@ -18,11 +18,12 @@ import {
HEADER_INNER_HEIGHT_MOBILE,
HEADER_TOP_PADDING_MOBILE,
} from "@/constants/layout";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import { PairDeviceModal } from "@/desktop/components/pair-device-modal";
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
const WINDOW_CONTENT_DATASET = { "window-content": "" };
export function OpenProjectScreen() {
const { t } = useTranslation();
const hosts = useHosts();
@@ -61,51 +62,56 @@ export function OpenProjectScreen() {
return (
<View style={styles.container}>
<MenuHeader borderless />
<View style={styles.content}>
<TitlebarDragRegion />
<View style={styles.logo}>
<PaseoLogo size={52} />
</View>
{hosts.map((host) => (
<HostError key={host.serverId} serverId={host.serverId} label={host.label} />
))}
<View style={styles.tiles}>
<HomeTile
icon={FolderOpen}
title={t("openProject.tiles.addProject.title")}
description={t("openProject.tiles.addProject.description")}
onPress={handleOpenPicker}
testID="open-project-submit"
accent
/>
<HomeTile
icon={Inbox}
title={t("openProject.tiles.importSession.title")}
description={t("openProject.tiles.importSession.description")}
onPress={importSession.open}
testID="open-project-import-session"
/>
<HomeTile
icon={Plug}
title={t("openProject.tiles.setupProviders.title")}
description={t("openProject.tiles.setupProviders.description")}
onPress={handleOpenProviders}
testID="open-project-setup-providers"
/>
{localServerId ? (
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.scrollContent}
dataSet={WINDOW_CONTENT_DATASET}
>
<View style={styles.content}>
<View style={styles.logo}>
<PaseoLogo size={52} />
</View>
{hosts.map((host) => (
<HostError key={host.serverId} serverId={host.serverId} label={host.label} />
))}
<View style={styles.tiles}>
<HomeTile
icon={Smartphone}
title={t("openProject.tiles.pairDevice.title")}
description={t("openProject.tiles.pairDevice.description")}
onPress={handleOpenPairDevice}
testID="open-project-pair-device"
icon={FolderOpen}
title={t("openProject.tiles.addProject.title")}
description={t("openProject.tiles.addProject.description")}
onPress={handleOpenPicker}
testID="open-project-submit"
accent
/>
) : null}
<HomeTile
icon={Inbox}
title={t("openProject.tiles.importSession.title")}
description={t("openProject.tiles.importSession.description")}
onPress={importSession.open}
testID="open-project-import-session"
/>
<HomeTile
icon={Plug}
title={t("openProject.tiles.setupProviders.title")}
description={t("openProject.tiles.setupProviders.description")}
onPress={handleOpenProviders}
testID="open-project-setup-providers"
/>
{localServerId ? (
<HomeTile
icon={Smartphone}
title={t("openProject.tiles.pairDevice.title")}
description={t("openProject.tiles.pairDevice.description")}
onPress={handleOpenPairDevice}
testID="open-project-pair-device"
/>
) : null}
</View>
</View>
</View>
<View style={styles.communityRow}>
<CommunityLinks />
</View>
<View style={styles.communityRow}>
<CommunityLinks />
</View>
</ScrollView>
<PairDeviceModal
serverId={localServerId ?? ""}
visible={isPairDeviceOpen}
@@ -176,18 +182,21 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface0,
userSelect: "none",
},
scroll: {
flex: 1,
},
scrollContent: {
flexGrow: 1,
},
content: {
position: "relative",
flex: 1,
flexGrow: 1,
flexShrink: 0,
justifyContent: { xs: "flex-start", md: "center" },
alignItems: "center",
gap: 0,
padding: theme.spacing[6],
paddingTop: { xs: theme.spacing[12], md: theme.spacing[6] },
paddingBottom: {
xs: HEADER_INNER_HEIGHT_MOBILE + HEADER_TOP_PADDING_MOBILE + theme.spacing[6],
md: HEADER_INNER_HEIGHT + theme.spacing[6],
},
},
logo: {
marginBottom: theme.spacing[8],
@@ -238,10 +247,8 @@ const styles = StyleSheet.create((theme) => ({
lineHeight: 18,
},
communityRow: {
position: "absolute",
left: 0,
right: 0,
bottom: {
flexShrink: 0,
paddingBottom: {
xs: HEADER_INNER_HEIGHT_MOBILE + HEADER_TOP_PADDING_MOBILE + theme.spacing[2],
md: HEADER_INNER_HEIGHT + theme.spacing[2],
},
@@ -1,4 +1,9 @@
import { AssistantFileLinkResolverProvider, type InlinePathTarget } from "@/assistant-file-links";
import {
AssistantFileLinkResolverProvider,
createInlinePathOpener,
type InlinePathTarget,
} from "@/assistant-file-links";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import {
WorkspaceComposerEditingSessionsProvider,
useComposerTabFullscreen,
@@ -203,6 +208,7 @@ import { PluginHeaderButtons } from "@/plugins";
import {
createWorkspaceFileTabTarget,
normalizeWorkspaceFileLocation,
type OpenFileDisposition,
type WorkspaceFileLocation,
type WorkspaceFileOpenRequest,
} from "@/workspace/file-open";
@@ -3658,9 +3664,42 @@ function WorkspaceScreenContent({
</View>
);
const openMarkdownFile = useStableEvent((target: InlinePathTarget) => {
handleOpenPreferredAssistantFile({ location: target, parentTabId: activeTabId });
const { revealDirectory } = useFileExplorerActions({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
workspaceRoot: workspaceDirectory,
});
const openMarkdownFile = useStableEvent((location: WorkspaceFileLocation) => {
handleOpenPreferredAssistantFile({ location, parentTabId: activeTabId });
});
const openMarkdownDirectory = useStableEvent((directoryPath: string) => {
void revealDirectory(directoryPath);
openExplorerSidebarView({
isCompact: isMobile,
workspaceKey: persistenceKey,
checkout: activeExplorerCheckout,
view: "files",
});
});
const markdownPathOpener = useMemo(
() =>
createInlinePathOpener({
workspaceRoot: workspaceDirectory ?? "",
client,
openFile: openMarkdownFile,
openDirectory: openMarkdownDirectory,
}),
[workspaceDirectory, client, openMarkdownFile, openMarkdownDirectory],
);
useEffect(() => {
if (!isRouteFocused) markdownPathOpener.cancel();
return () => markdownPathOpener.cancel();
}, [markdownPathOpener, isRouteFocused, activeTabId]);
const openMarkdownPath = useStableEvent(
(target: InlinePathTarget, disposition: OpenFileDisposition) => {
if (isRouteFocused) void markdownPathOpener.open(target, disposition);
},
);
const renderedWorkspaceScreen = (
<RenderProfile id="WorkspaceScreenContent">
@@ -3705,7 +3744,7 @@ function WorkspaceScreenContent({
client={client}
serverId={normalizedServerId}
workspaceRoot={workspaceDirectory ?? undefined}
onOpenWorkspaceFile={openMarkdownFile}
onOpenWorkspaceFile={openMarkdownPath}
toast={toast}
>
{renderedWorkspaceScreen}
+1 -1
View File
@@ -741,7 +741,7 @@ export const darkPureBlackTheme = buildDarkTheme(pureBlackDarkColors);
const lightShadow = {
sm: {
shadowColor: "rgba(0, 0, 0, 0.02)",
shadowColor: "rgba(0, 0, 0, 0.08)",
shadowOffset: { width: 0, height: 2 },
shadowRadius: 8,
elevation: 2,
+250 -33
View File
@@ -29,6 +29,116 @@ async function git(cwd: string, args: string[]): Promise<void> {
await execFileAsync("git", args, { cwd });
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitFor(
condition: () => Promise<boolean>,
description: string,
timeoutMs = 60_000,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await condition()) return;
await sleep(50);
}
throw new Error(`Timed out waiting for ${description}`);
}
interface ObservedRequest {
type: string;
message: Record<string, unknown>;
observedAt: number;
}
/**
* Observes real CLI requests while forwarding them to the isolated daemon.
*
* Requests are recorded where the CLI sends them, so a scenario can measure a wait from the
* request boundary instead of from process start.
*/
async function startRequestObserver(upstreamPort: number): Promise<{
port: number;
requests: ObservedRequest[];
waitForRequest: (type: string) => Promise<number>;
close: () => Promise<void>;
}> {
const server = new WebSocketServer({ host: "127.0.0.1", port: 0 });
const requests: ObservedRequest[] = [];
server.on("connection", (socket, request) => {
const upstream = new WebSocket(`ws://127.0.0.1:${upstreamPort}${request.url}`, {
headers: { authorization: request.headers.authorization ?? "" },
});
const ready = once(upstream, "open");
socket.on("message", async (data, isBinary) => {
const frame = JSON.parse(data.toString()) as {
type?: string;
message?: { type?: string } & Record<string, unknown>;
};
if (frame.type === "session" && typeof frame.message?.type === "string") {
requests.push({
type: frame.message.type,
message: frame.message,
observedAt: Date.now(),
});
}
await ready;
upstream.send(data, { binary: isBinary });
});
upstream.on("message", (data, isBinary) => socket.send(data, { binary: isBinary }));
socket.on("close", () => upstream.close());
upstream.on("close", () => socket.close());
});
await once(server, "listening");
const address = server.address();
assert.ok(address && typeof address !== "string");
return {
port: address.port,
requests,
waitForRequest: async (type: string) => {
await waitFor(
async () => requests.some((entry) => entry.type === type),
`${type} to reach the daemon`,
);
return requests.find((entry) => entry.type === type)!.observedAt;
},
close: async () => {
for (const socket of server.clients) socket.close();
await promisify(server.close.bind(server))();
},
};
}
/** Build command that blocks the plugin lifecycle queue until the test writes the gate file. */
const gateWaitBuild = [
'const fs = require("node:fs");',
'fs.writeFileSync(process.argv[1], "started");',
"const wait = () => {",
" if (fs.existsSync(process.argv[2])) return;",
" setTimeout(wait, 50);",
"};",
"wait();",
].join("\n");
/** Plugin whose every activation appends one line, so a test can count real reactivations. */
function activationCountingPlugin(logPath: string): string {
return [
'import { appendFileSync } from "node:fs";',
`const activationLog = ${JSON.stringify(logPath)};`,
"export default function contribute(plugin: unknown) {",
' appendFileSync(activationLog, "activate\\n");',
" void plugin;",
" return () => undefined;",
"}",
].join("\n");
}
async function countActivations(logPath: string): Promise<number> {
const content = await readFile(logPath, "utf8").catch(() => "");
return content.split("\n").filter(Boolean).length;
}
async function main(): Promise<void> {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-cli-e2e-"));
const gitDirectory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-git-cli-e2e-"));
@@ -48,31 +158,11 @@ async function main(): Promise<void> {
await writeFile(path.join(directory, "index.server.ts"), pluginSource);
// Observe real CLI requests while forwarding them to the isolated daemon.
const proxy = new WebSocketServer({ host: "127.0.0.1", port: 0 });
const sources: string[] = [];
proxy.on("connection", (socket, request) => {
const upstream = new WebSocket(`ws://127.0.0.1:${context.port}${request.url}`, {
headers: { authorization: request.headers.authorization ?? "" },
});
const ready = once(upstream, "open");
socket.on("message", async (data, isBinary) => {
const message = JSON.parse(data.toString());
if (
message.type === "session" &&
message.message.type === "plugin.source.install.request"
) {
sources.push(message.message.source);
}
await ready;
upstream.send(data, { binary: isBinary });
});
upstream.on("message", (data, isBinary) => socket.send(data, { binary: isBinary }));
socket.on("close", () => upstream.close());
upstream.on("close", () => socket.close());
});
await once(proxy, "listening");
const address = proxy.address();
assert.ok(address && typeof address !== "string");
const observer = await startRequestObserver(context.port);
const installedSources = () =>
observer.requests
.filter((entry) => entry.type === "plugin.source.install.request")
.map((entry) => entry.message.source);
const sibling = path.join(context.workDir, "x");
const caller = path.join(context.workDir, "caller");
await mkdir(caller);
@@ -96,12 +186,12 @@ async function main(): Promise<void> {
["~", context.paseoHome, context.paseoHome, []],
["~/.:.", `${context.paseoHome}:.`, context.paseoHome, []],
] as const) {
sources.length = 0;
observer.requests.length = 0;
const result = await context.paseo(
["plugin", "add", source, ...extraArgs, "--host", `127.0.0.1:${address.port}`, "--json"],
["plugin", "add", source, ...extraArgs, "--host", `127.0.0.1:${observer.port}`, "--json"],
{ cwd: caller },
);
assert.deepEqual(sources, [expectedSource]);
assert.deepEqual(installedSources(), [expectedSource]);
assert.equal(result.exitCode, 0, result.stderr);
assert.equal(JSON.parse(result.stdout).path, expectedDirectory);
const removed = await context.paseo(["plugin", "remove", "relative-cli", "--json"]);
@@ -109,17 +199,16 @@ async function main(): Promise<void> {
}
// An absolute path belongs to the daemon's platform, which can differ from the CLI's.
const windowsSource = `C:\\${path.basename(context.workDir)}\\missing:sub`;
sources.length = 0;
observer.requests.length = 0;
const missing = await context.paseo(
["plugin", "add", windowsSource, "--host", `127.0.0.1:${address.port}`, "--json"],
["plugin", "add", windowsSource, "--host", `127.0.0.1:${observer.port}`, "--json"],
{ cwd: caller },
);
assert.deepEqual(sources, [windowsSource]);
assert.deepEqual(installedSources(), [windowsSource]);
assert.equal(missing.exitCode, 1);
assert.match(missing.stderr, /Plugin directory does not exist/);
} finally {
for (const socket of proxy.clients) socket.close();
await promisify(proxy.close.bind(proxy))();
await observer.close();
}
const install = await context.paseo(["plugin", "install", directory, "--json"]);
@@ -242,6 +331,134 @@ async function main(): Promise<void> {
assert.equal(enable.exitCode, 0, enable.stderr);
assert.equal(JSON.parse(enable.stdout).status, "running");
// A reload that finishes after the client's former one-minute deadline must still report its
// real result. The wait is the daemon's own lifecycle queue, so it runs on real wall-clock time.
const gateDirectory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-reload-e2e-"));
const buildStarted = path.join(gateDirectory, "build-started");
const buildGate = path.join(gateDirectory, "build-gate");
const activationLog = path.join(gateDirectory, "activations.log");
const targetDirectory = path.join(gateDirectory, "slow-reload-target");
const slowRepository = path.join(gateDirectory, "slow-build");
const reloadObserver = await startRequestObserver(context.port);
let slowInstall: ReturnType<typeof context.paseo> | undefined;
let slowReload: ReturnType<typeof context.paseo> | undefined;
try {
await mkdir(targetDirectory);
await writeFile(
path.join(targetDirectory, "paseo-plugin.json"),
JSON.stringify({
id: "slow-reload-target",
requirements: { paseo: `>=${resolveCliVersion()}` },
}),
);
await writeFile(
path.join(targetDirectory, "index.server.ts"),
activationCountingPlugin(activationLog),
);
const targetInstall = await context.paseo(["plugin", "install", targetDirectory, "--json"]);
assert.equal(targetInstall.exitCode, 0, targetInstall.stderr);
assert.equal(await countActivations(activationLog), 1);
await mkdir(slowRepository);
await git(slowRepository, ["init", "-b", "main"]);
await git(slowRepository, ["config", "user.name", "Paseo Tests"]);
await git(slowRepository, ["config", "user.email", "paseo@example.test"]);
await writeFile(
path.join(slowRepository, "paseo-plugin.json"),
JSON.stringify({
id: "slow-build",
requirements: { paseo: `>=${resolveCliVersion()}` },
build: [[process.execPath, "-e", gateWaitBuild, buildStarted, buildGate]],
}),
);
await writeFile(path.join(slowRepository, "index.server.ts"), pluginSource);
await git(slowRepository, ["add", "-A"]);
await git(slowRepository, ["commit", "-m", "blocking build"]);
// This install holds the plugin lifecycle queue while its build waits for the gate.
slowInstall = context.paseo(
["plugin", "add", `git:${pathToFileURL(slowRepository).href}`, "--json"],
{ timeout: 180_000 },
);
// A failed launch must not arrive as an unhandled rejection: that ends this direct Node run
// before the finally block below releases the build gate. Its result is still asserted.
void slowInstall.catch(() => undefined);
await waitFor(
async () =>
readFile(buildStarted, "utf8").then(
() => true,
() => false,
),
"the blocking build to start",
);
slowReload = context.paseo(
[
"plugin",
"reload",
"slow-reload-target",
"--host",
`127.0.0.1:${reloadObserver.port}`,
"--json",
],
{ timeout: 180_000 },
);
// Same as the install above: a launch failure must not end the run before the cleanup.
void slowReload.catch(() => undefined);
const reloadRequestedAt = await reloadObserver.waitForRequest("plugin.reload.request");
// The interim listing is not the reload's outcome: it still reports the plugin running and
// no restart has happened while the reload waits behind the queue.
const duringReload = await context.paseo(["plugin", "ls", "slow-reload-target", "--json"]);
assert.equal(duringReload.exitCode, 0, duringReload.stderr);
assert.equal(JSON.parse(duringReload.stdout)[0].status, "running");
assert.equal(await countActivations(activationLog), 1);
const heldMs = Date.now() - reloadRequestedAt;
if (heldMs < 66_000) {
await sleep(66_000 - heldMs);
}
await writeFile(buildGate, "go");
const [buildResult, reloadResult] = await Promise.all([slowInstall, slowReload]);
assert.equal(buildResult.exitCode, 0, buildResult.stderr);
assert.equal(reloadResult.exitCode, 0, reloadResult.stderr);
assert.equal(JSON.parse(reloadResult.stdout).status, "running");
assert.equal(await countActivations(activationLog), 2);
// A plugin that cannot start still fails its reload with the daemon's error.
const brokenDirectory = path.join(gateDirectory, "broken-plugin");
await mkdir(brokenDirectory);
await writeFile(
path.join(brokenDirectory, "paseo-plugin.json"),
JSON.stringify({
id: "broken-reload",
requirements: { paseo: `>=${resolveCliVersion()}` },
}),
);
await writeFile(path.join(brokenDirectory, "index.server.ts"), "export default broken !!!");
const brokenInstall = await context.paseo(["plugin", "install", brokenDirectory, "--json"]);
assert.equal(brokenInstall.exitCode, 1);
assert.match(brokenInstall.stderr, /broken-reload|broken|failed/i);
const brokenReload = await context.paseo(["plugin", "reload", "broken-reload", "--json"], {
timeout: 60_000,
});
assert.equal(brokenReload.exitCode, 1);
assert.match(brokenReload.stderr, /broken-reload|broken|failed/i);
for (const pluginId of ["slow-reload-target", "slow-build", "broken-reload"]) {
const removed = await context.paseo(["plugin", "remove", pluginId, "--json"]);
assert.equal(removed.exitCode, 0, removed.stderr);
}
} finally {
// Release the gate, then let the install and the reload settle before their files go: the
// build polls for the gate file, so cleanup must not delete it while the child still waits.
await writeFile(buildGate, "go").catch(() => undefined);
await Promise.all([slowInstall?.catch(() => undefined), slowReload?.catch(() => undefined)]);
await reloadObserver.close();
await rm(gateDirectory, { recursive: true, force: true });
}
const remove = await context.paseo(["plugin", "remove", "cli-e2e", "--json"]);
assert.equal(remove.exitCode, 0, remove.stderr);
const removeGit = await context.paseo(["plugin", "remove", "git-cli-e2e", "--json"]);
+194
View File
@@ -1963,6 +1963,200 @@ test("gets a structured plugin log snapshot", async () => {
]);
});
test("keeps a plugin reload pending past the session RPC deadline", async () => {
useHeartbeatClock();
try {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const reload = client.reloadPlugin("example");
let settled: "pending" | "resolved" | "rejected" = "pending";
void reload.then(
() => {
settled = "resolved";
return null;
},
() => {
settled = "rejected";
return null;
},
);
const request = parseSentFrame(mock.sent[0]);
expect(request).toEqual({
type: "plugin.reload.request",
requestId: expect.any(String),
pluginId: "example",
});
// A daemon that reloads for almost two minutes is still reloading, not a failed request.
await vi.advanceTimersByTimeAsync(114_034);
expect(settled).toBe("pending");
// Another plugin's reload finishing early must not settle this request.
mock.triggerMessage(
wrapSessionMessage({
type: "plugin.reload.response",
payload: {
requestId: "req-other-plugin",
plugin: { id: "other", path: "/plugins/other", enabled: true, status: "running" },
},
}),
);
await vi.advanceTimersByTimeAsync(0);
expect(settled).toBe("pending");
mock.triggerMessage(
wrapSessionMessage({
type: "plugin.reload.response",
payload: {
requestId: request.requestId,
plugin: { id: "example", path: "/plugins/example", enabled: true, status: "running" },
},
}),
);
await expect(reload).resolves.toEqual({
id: "example",
path: "/plugins/example",
enabled: true,
status: "running",
});
} finally {
vi.useRealTimers();
}
});
test("keeps enable and disable inside the session RPC deadline", async () => {
useHeartbeatClock();
try {
for (const action of ["enable", "disable"] as const) {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const pending =
action === "enable" ? client.enablePlugin("example") : client.disablePlugin("example");
let settled = false;
void pending.then(
() => {
settled = true;
return null;
},
() => {
settled = true;
return null;
},
);
expect(parseSentFrame(mock.sent[0])).toEqual({
type: `plugin.${action}.request`,
requestId: expect.any(String),
pluginId: "example",
});
await vi.advanceTimersByTimeAsync(59_999);
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await expect(pending).rejects.toThrow("Timeout waiting for message (60000ms)");
}
} finally {
vi.useRealTimers();
}
});
test("reports a plugin reload failure that arrives after the session RPC deadline", async () => {
useHeartbeatClock();
try {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const reload = client.reloadPlugin("example");
const request = parseSentFrame(mock.sent[0]);
await vi.advanceTimersByTimeAsync(114_034);
mock.triggerMessage(
wrapSessionMessage({
type: "rpc_error",
payload: {
requestId: request.requestId,
requestType: "plugin.reload.request",
error: "Request failed: Plugin failed to start: example",
code: "handler_error",
},
}),
);
await expect(reload).rejects.toThrow(
"Request failed: Plugin failed to start: example requestType=plugin.reload.request code=handler_error",
);
} finally {
vi.useRealTimers();
}
});
test("rejects a pending plugin reload when the connection closes", async () => {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const reload = client.reloadPlugin("example");
expect(parseSentFrame(mock.sent[0])).toMatchObject({ type: "plugin.reload.request" });
mock.triggerClose({ code: 1006, reason: "network lost" });
await expect(reload).rejects.toThrow(/network lost|disconnected|closed/i);
// Reconnecting must not resend the lost reload: only the caller decides to reload again.
const reconnecting = client.connect();
mock.triggerOpen();
await reconnecting;
expect(mock.sent).toEqual([]);
});
test("keeps waitForAgentUpsert initial fetch inside the requested deadline", async () => {
useHeartbeatClock();
const logger = createMockLogger();
+4
View File
@@ -5692,6 +5692,10 @@ export class DaemonClient {
requestId,
message: { type: `plugin.${action}.request`, requestId, pluginId },
responseType: `plugin.${action}.response`,
// Reload tears the plugin down and starts it again before the daemon answers, and it queues
// behind other plugin lifecycle work. Wait for that answer instead of failing the reload at
// the request deadline while the daemon keeps loading it.
...(action === "reload" ? { timeout: 0 } : {}),
});
return payload.plugin;
}
@@ -36,8 +36,14 @@ android {
defaultConfig {
versionCode 1
versionName "0.1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
lintOptions {
abortOnError false
}
}
dependencies {
androidTestImplementation 'androidx.test:runner:1.6.2'
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
}
@@ -0,0 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<application>
<activity android:name="expo.modules.twowayaudio.AudioEngineTestActivity"
android:exported="true" />
</application>
</manifest>
@@ -0,0 +1,223 @@
package expo.modules.twowayaudio
import AudioEngine
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.filters.SdkSuppress
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicInteger
class AudioEngineTestActivity : Activity()
/** Real AudioManager and AudioRecord: focus loss is delivered by Android, never injected. */
@RunWith(AndroidJUnit4::class)
class AudioEngineTest {
private val instrumentation = InstrumentationRegistry.getInstrumentation()
private lateinit var activity: Activity
private lateinit var engine: AudioEngine
private lateinit var audio: AudioManager
private val packets = AtomicInteger()
private val events = CopyOnWriteArrayList<String>()
private val requests = mutableListOf<AudioFocusRequest>()
@Before fun setUp() {
val context = instrumentation.context
shell("pm grant ${context.packageName} android.permission.RECORD_AUDIO")
activity = instrumentation.startActivitySync(
Intent(context, AudioEngineTestActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
instrumentation.waitForIdleSync()
audio = activity.getSystemService(Context.AUDIO_SERVICE) as AudioManager
main {
engine = AudioEngine(activity)
engine.onMicDataCallback = { packets.incrementAndGet() }
engine.onAudioInterruptionCallback = { events.add(it) }
}
}
@After fun tearDown() {
shell("cmd sensor_privacy disable 0 microphone")
main {
requests.forEach { audio.abandonAudioFocusRequest(it) }
engine.tearDown()
activity.finish()
}
}
@Test fun dictationContinuesWhenMusicTakesFocus() {
assertDictationSurvives(AudioManager.AUDIOFOCUS_GAIN)
}
@Test fun dictationContinuesWhenFocusIsTemporarilyLost() {
assertDictationSurvives(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
}
@Test fun dictationContinuesWhenAnotherAppRequestsDucking() {
assertDictationSurvives(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK)
}
@Test fun conversationStillStopsOnFocusLoss() {
main { assertTrue(engine.toggleRecording(true)) }
await("conversation microphone data") { packets.get() > 2 }
takeFocus(AudioManager.AUDIOFOCUS_GAIN)
await("terminal conversation interruption") { events.contains("blocked") }
assertFalse(engine.isRecording)
}
@Test fun dictationStartsWhileMusicOwnsFocusAndCanBeStartedAgain() {
takeFocus(AudioManager.AUDIOFOCUS_GAIN)
startDictation()
main { engine.toggleRecording(false); engine.releaseAudioSession() }
val stoppedAt = packets.get()
startDictation()
await("new recording data") { packets.get() > stoppedAt + 2 }
assertFalse(events.contains("blocked"))
}
@Test fun cancellingWhileBackgroundedDoesNotRestartOnResume() {
startDictation()
main {
engine.pauseRecordingAndPlayer()
engine.toggleRecording(false)
engine.releaseAudioSession()
engine.resumeRecordingAndPlayer()
}
assertFalse(engine.isRecording)
}
@Test fun dictationKeepsItsCaptureModeAfterBackgroundResume() {
startDictation()
main { engine.pauseRecordingAndPlayer(); engine.resumeRecordingAndPlayer() }
val resumedAt = packets.get()
takeFocus(AudioManager.AUDIOFOCUS_GAIN)
await("resumed dictation data after focus loss") { packets.get() > resumedAt + 3 }
assertTrue(engine.isRecording)
assertFalse(events.contains("blocked"))
}
@Test @SdkSuppress(minSdkVersion = 31)
fun microphonePrivacyInterruptionPreservesCaptureAndRecovers() {
startDictation()
shell("cmd sensor_privacy enable 0 microphone")
await("input paused") { events.contains("capture_paused") }
assertTrue(engine.isRecording)
assertFalse(events.contains("blocked"))
// Once Android reports silencing, do not enqueue artificial silence for transcription.
SystemClock.sleep(100)
val pausedAt = packets.get()
SystemClock.sleep(150)
assertEquals(pausedAt, packets.get())
shell("cmd sensor_privacy disable 0 microphone")
await("input resumed") { events.contains("capture_resumed") }
await("microphone data after recovery") { packets.get() > pausedAt + 3 }
assertTrue(engine.isRecording)
assertEquals(listOf("capture_paused", "capture_resumed"), events.toList())
}
@Test @SdkSuppress(minSdkVersion = 31)
fun startingWithMicrophonePrivacyEnabledWaitsAndRecovers() {
shell("cmd sensor_privacy enable 0 microphone")
main { assertTrue(engine.startDictation()) }
await("initial input paused") { events.contains("capture_paused") }
assertTrue(engine.isRecording)
assertFalse(events.contains("blocked"))
shell("cmd sensor_privacy disable 0 microphone")
await("initial input resumed") { events.contains("capture_resumed") }
await("microphone data") { packets.get() > 3 }
}
@Test fun resumingDictationDoesNotTakeFocusBackFromMusic() {
startDictation()
main { engine.pauseRecordingAndPlayer() }
val changes = CopyOnWriteArrayList<Int>()
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setOnAudioFocusChangeListener({ changes.add(it) }, Handler(Looper.getMainLooper()))
.build()
requests.add(request)
main { assertEquals(AudioManager.AUDIOFOCUS_REQUEST_GRANTED, audio.requestAudioFocus(request)) }
val before = packets.get()
main { engine.resumeRecordingAndPlayer() }
await("resumed microphone data") { packets.get() > before + 3 }
assertTrue(engine.isRecording)
assertTrue("Music lost focus on dictation resume: $changes", changes.isEmpty())
}
@Test @SdkSuppress(minSdkVersion = 31)
fun stoppingDictationReleasesTheCommunicationRoute() {
startDictation()
await("selected communication route") { hasCommunicationRoute() }
main { engine.toggleRecording(false); engine.releaseAudioSession() }
await("normal audio mode") { audio.mode == AudioManager.MODE_NORMAL }
await("cleared communication route") { !hasCommunicationRoute() }
assertFalse(engine.isRecording)
}
private fun assertDictationSurvives(gain: Int) {
startDictation()
val before = packets.get()
val request = takeFocus(gain)
await("microphone data while focus is lost") { packets.get() > before + 3 }
assertTrue(engine.isRecording)
assertFalse(events.contains("blocked"))
val during = packets.get()
main { audio.abandonAudioFocusRequest(request) }
await("microphone data after focus release") { packets.get() > during + 3 }
assertTrue(engine.isRecording)
assertFalse(events.contains("blocked"))
}
private fun startDictation() {
val before = packets.get()
main { assertTrue(engine.startDictation()) }
await("initial microphone data") { packets.get() > before + 3 }
}
private fun takeFocus(gain: Int): AudioFocusRequest {
val request = AudioFocusRequest.Builder(gain)
.setAudioAttributes(AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC).build())
.setOnAudioFocusChangeListener({}, Handler(Looper.getMainLooper()))
.build()
requests.add(request)
main { assertEquals(AudioManager.AUDIOFOCUS_REQUEST_GRANTED, audio.requestAudioFocus(request)) }
return request
}
private fun main(action: () -> Unit) = instrumentation.runOnMainSync(action)
private fun await(description: String, condition: () -> Boolean) {
val deadline = SystemClock.elapsedRealtime() + 5000
while (!condition() && SystemClock.elapsedRealtime() < deadline) SystemClock.sleep(20)
assertTrue("Timed out waiting for $description; events=$events packets=${packets.get()}", condition())
}
// communicationDevice reports the effective system device even after our selection is cleared.
// Inspect only current route clients, never the historical routing event log.
private fun hasCommunicationRoute(): Boolean {
val dump = shell("dumpsys audio")
assertTrue("Missing Android communication route diagnostics", dump.contains("Communication route clients:"))
return dump.lineSequence().any {
it.trimStart().startsWith("[CommunicationRouteClient: mUid: ${android.os.Process.myUid()} ")
}
}
private fun shell(command: String): String =
instrumentation.uiAutomation.executeShellCommand(command).use {
android.os.ParcelFileDescriptor.AutoCloseInputStream(it).readBytes().toString(Charsets.UTF_8)
}
}
@@ -6,12 +6,14 @@ import android.media.AudioFocusRequest
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioRecord
import android.media.AudioRecordingConfiguration
import android.media.AudioTrack
import android.media.MediaRecorder
import android.media.audiofx.AcousticEchoCanceler
import android.media.audiofx.NoiseSuppressor
import android.os.Build
import android.os.PowerManager
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.annotation.RequiresApi
import java.util.LinkedList
@@ -34,7 +36,26 @@ class AudioEngine (context: Context) {
private var noiseSuppressor: NoiseSuppressor? = null
private val executorServiceMicrophone = Executors.newFixedThreadPool(1)
private val executorServicePlayback = Executors.newFixedThreadPool(1)
private var speakerDevice: AudioDeviceInfo? = null
private enum class CaptureMode { CONVERSATION, DICTATION }
private var captureMode = CaptureMode.CONVERSATION
private val mainHandler = Handler(Looper.getMainLooper())
@Volatile private var captureSilenced = false
private val recordingCallback = object : AudioManager.AudioRecordingCallback() {
override fun onRecordingConfigChanged(configs: MutableList<AudioRecordingConfiguration>) {
if (!isRecording || !::audioRecord.isInitialized) return
val config = configs.firstOrNull { it.clientAudioSessionId == audioRecord.audioSessionId }
?: return
setCaptureSilenced(config.isClientSilenced)
}
}
private val deviceCallback = object : android.media.AudioDeviceCallback() {
override fun onAudioDevicesAdded(devices: Array<out AudioDeviceInfo>) {
if (communicationRouteActive) updateAudioRouting()
}
override fun onAudioDevicesRemoved(devices: Array<out AudioDeviceInfo>) {
if (communicationRouteActive) updateAudioRouting()
}
}
private var communicationRouteActive = false
private var bridgeWindowStartedAtMs = System.currentTimeMillis()
private var micEvents = 0
@@ -44,7 +65,7 @@ class AudioEngine (context: Context) {
private var playbackWrites = 0
private var playbackWriteBytes = 0L
var isRecording = false
@Volatile var isRecording = false
private var isRecordingBeforePause = false
var isPlaying = false
@@ -83,28 +104,9 @@ class AudioEngine (context: Context) {
@SuppressLint("NewApi")
private fun initializeAudio(context:Context) {
audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
activateCommunicationRoute()
if (!requestAudioFocus()) {
handleAudioFocusBlocked()
}
// Listen for changes in audio routing
audioManager.registerAudioDeviceCallback(object:android.media.AudioDeviceCallback(){
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>?) {
Log.d("AudioEngine", "onAudioDevicesAdded")
super.onAudioDevicesAdded(addedDevices)
if (communicationRouteActive) {
updateAudioRouting()
}
}
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>?) {
Log.d("AudioEngine", "onAudioDevicesRemoved")
super.onAudioDevicesRemoved(removedDevices)
if (communicationRouteActive) {
updateAudioRouting()
}
}
}, null)
// Initialization must not interrupt music before a capture/playback operation owns a session.
audioManager.registerAudioDeviceCallback(deviceCallback, mainHandler)
audioManager.registerAudioRecordingCallback(recordingCallback, mainHandler)
val bufferSize = AudioTrack.getMinBufferSize(
SAMPLE_RATE,
@@ -125,49 +127,42 @@ class AudioEngine (context: Context) {
bufferSize,
AudioTrack.MODE_STREAM,
audioManager.generateAudioSessionId()
).apply {
play()
}
)
}
@SuppressLint("NewApi")
private fun updateAudioRouting() {
val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
var isExternalDeviceConnected = false
var selectedDevice: AudioDeviceInfo? = null
for (device in devices) {
if (device.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER) {
speakerDevice = device
}
if (device.type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES ||
device.type == AudioDeviceInfo.TYPE_WIRED_HEADSET ||
device.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP ||
device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO) {
isExternalDeviceConnected = true
selectedDevice = device
break
} else if (device.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER) {
selectedDevice = device
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// Use the modern API for Android S and above
try {
selectedDevice?.let {
audioManager.setCommunicationDevice(it)
}
}catch (e:Exception){
Log.e("AudioEngine", "Error setting communication device. Using speaker")
speakerDevice?.let {
audioManager.setCommunicationDevice(it)
}
val devices = audioManager.availableCommunicationDevices
// A2DP is a music output, not a microphone route. Android chooses the matching
// input for a valid communication sink (including SCO and BLE headsets).
val selected = devices.firstOrNull { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO ||
it.type == AudioDeviceInfo.TYPE_BLE_HEADSET }
?: devices.firstOrNull { it.type == AudioDeviceInfo.TYPE_WIRED_HEADSET ||
it.type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES ||
it.type == AudioDeviceInfo.TYPE_USB_HEADSET }
?: devices.firstOrNull { it.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER }
val routed = try {
selected != null && audioManager.setCommunicationDevice(selected)
} catch (_: IllegalArgumentException) {
// The device disconnected between enumeration and selection.
false
}
if (!routed) audioManager.clearCommunicationDevice()
} else {
// Fall back to deprecated method for older Android versions
@Suppress("DEPRECATION")
audioManager.isSpeakerphoneOn = !isExternalDeviceConnected
val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
// Before Android 12, A2DP signals a connected headset; start SCO to open its mic.
val bluetooth = devices.any { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO ||
it.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP }
@Suppress("DEPRECATION")
if (bluetooth) audioManager.startBluetoothSco() else audioManager.stopBluetoothSco()
@Suppress("DEPRECATION")
audioManager.isSpeakerphoneOn = !bluetooth && !devices.any {
it.type == AudioDeviceInfo.TYPE_WIRED_HEADSET ||
it.type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES ||
it.type == AudioDeviceInfo.TYPE_USB_HEADSET
}
}
}
@@ -186,6 +181,8 @@ class AudioEngine (context: Context) {
} else {
@Suppress("DEPRECATION")
audioManager.isSpeakerphoneOn = false
@Suppress("DEPRECATION")
audioManager.stopBluetoothSco()
}
audioManager.mode = AudioManager.MODE_NORMAL
}
@@ -245,8 +242,8 @@ class AudioEngine (context: Context) {
audioFocusRequest = null
}
val focusRequest =
AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE)
lateinit var focusRequest: AudioFocusRequest
focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
@@ -255,6 +252,7 @@ class AudioEngine (context: Context) {
)
.setAcceptsDelayedFocusGain(true)
.setOnAudioFocusChangeListener { focusChange ->
if (audioFocusRequest !== focusRequest) return@setOnAudioFocusChangeListener
when (focusChange) {
AudioManager.AUDIOFOCUS_GAIN -> {
Log.d("AudioEngine", "Audio focus gained")
@@ -286,6 +284,14 @@ class AudioEngine (context: Context) {
}
private fun handleAudioFocusBlocked() {
// Focus governs playback. Dictation keeps its AudioRecord alive; Android's
// recording configuration reports real input silencing and restores it after calls.
if (captureMode == CaptureMode.DICTATION) {
stopPlayback()
audioFocusRequest?.let { audioManager.abandonAudioFocusRequest(it) }
audioFocusRequest = null
return
}
if (isRecording) {
stopRecording()
}
@@ -306,11 +312,11 @@ class AudioEngine (context: Context) {
@RequiresApi(Build.VERSION_CODES.Q)
@SuppressLint("MissingPermission")
private fun startRecording(): Boolean {
private fun startRecording(requestFocus: Boolean = true): Boolean {
activateCommunicationRoute()
if (!requestAudioFocus()) {
if (requestFocus && !requestAudioFocus()) {
handleAudioFocusBlocked()
return false
if (captureMode == CaptureMode.CONVERSATION) return false
}
val bufferSize = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT)
@@ -344,17 +350,27 @@ class AudioEngine (context: Context) {
audioRecord.startRecording()
isRecording = true
startMicSampleTap()
setCaptureSilenced(audioRecord.activeRecordingConfiguration?.isClientSilenced ?: false)
startMicSampleTap(audioRecord)
return true
}
private fun startMicSampleTap(){
private fun setCaptureSilenced(silenced: Boolean) {
if (captureSilenced == silenced) return
captureSilenced = silenced
if (silenced) onInputVolumeCallback?.invoke(0.0F)
onAudioInterruptionCallback?.invoke(if (silenced) "capture_paused" else "capture_resumed")
}
private fun startMicSampleTap(recorder: AudioRecord){
executorServiceMicrophone.execute {
val buffer = ByteArray(1024)
try {
while (isRecording) {
val read = audioRecord.read(buffer, 0, buffer.size)
if (read > 0) {
while (isRecording && audioRecord === recorder) {
val read = recorder.read(buffer, 0, buffer.size)
if (!isRecording || audioRecord !== recorder) break
if (read < 0) throw IllegalStateException("AudioRecord read failed: $read")
if (read > 0 && !captureSilenced) {
val data = buffer.copyOf(read)
micEvents += 1
micBytes += data.size.toLong()
@@ -367,9 +383,13 @@ class AudioEngine (context: Context) {
Log.d("AudioEngine", "Mic sample tap stopped.")
}catch (e: Exception){
Log.e("AudioEngine", "Error reading mic sample data", e)
isRecording = false
tearDown()
throw e
mainHandler.post {
if (isRecording && audioRecord === recorder) {
stopRecording()
releaseAudioSession()
onAudioInterruptionCallback?.invoke("blocked")
}
}
}
}
}
@@ -379,16 +399,31 @@ class AudioEngine (context: Context) {
isRecording = false
if (audioRecord.recordingState == AudioRecord.RECORDSTATE_RECORDING) {
audioRecord.stop()
audioRecord.release()
}
audioRecord.release()
echoCanceler?.release()
echoCanceler = null
noiseSuppressor?.release()
noiseSuppressor = null
setCaptureSilenced(false)
onInputVolumeCallback?.invoke(0.0F)
}
@RequiresApi(Build.VERSION_CODES.Q)
fun startDictation(): Boolean {
isRecordingBeforePause = false
if (isRecording) return true
captureMode = CaptureMode.DICTATION
return startRecording()
}
@RequiresApi(Build.VERSION_CODES.Q)
fun toggleRecording(value: Boolean): Boolean {
isRecordingBeforePause = false
if (value == isRecording) return isRecording
if (value) {
captureMode = CaptureMode.CONVERSATION
return startRecording()
} else {
stopRecording()
@@ -401,6 +436,7 @@ class AudioEngine (context: Context) {
@SuppressLint("NewApi")
fun playPCMData(data: ByteArray) {
acquireAudioSessionIfNeeded()
audioTrack.play()
audioSampleQueue.add(data)
playbackEvents += 1
playbackQueuedBytes += data.size.toLong()
@@ -464,7 +500,7 @@ class AudioEngine (context: Context) {
@RequiresApi(Build.VERSION_CODES.Q)
fun pauseRecordingAndPlayer() {
isRecordingBeforePause = isRecording
isRecording = toggleRecording(false)
stopRecording()
audioTrack.pause()
}
@@ -478,16 +514,18 @@ class AudioEngine (context: Context) {
if (!wasRecordingBeforePause && !isPlaying) {
return
}
activateCommunicationRoute()
if (!requestAudioFocus()) {
handleAudioFocusBlocked()
return
isRecordingBeforePause = false
if (wasRecordingBeforePause) {
// Returning from music controls must not pause music the user explicitly resumed.
if (!startRecording(requestFocus = captureMode == CaptureMode.CONVERSATION)) return
} else {
activateCommunicationRoute()
if (!requestAudioFocus()) {
handleAudioFocusBlocked()
return
}
}
isRecording = toggleRecording(wasRecordingBeforePause)
if (wasRecordingBeforePause && !isRecording) {
return
}
audioTrack.play()
if (captureMode == CaptureMode.CONVERSATION) audioTrack.play()
}
fun stopPlayback() {
@@ -520,7 +558,11 @@ class AudioEngine (context: Context) {
audioManager.abandonAudioFocusRequest(request)
}
audioFocusRequest = null
audioManager.unregisterAudioDeviceCallback(deviceCallback)
audioManager.unregisterAudioRecordingCallback(recordingCallback)
audioTrack.release()
executorServiceMicrophone.shutdownNow()
executorServicePlayback.shutdownNow()
}
@@ -42,6 +42,12 @@ class ExpoTwoWayAudioModule : Module() {
audioEngine?.isRecording ?: false
}
Function("startDictation") {
val isRecording = audioEngine?.startDictation() ?: false
sendEvent(ON_RECORDING_CHANGE_EVENT, mapOf("data" to isRecording))
isRecording
}
Function("toggleRecording") { value: Boolean ->
audioEngine?.let { engine ->
val isRecording = engine.toggleRecording(value)
@@ -59,6 +59,12 @@ public class ExpoTwoWayAudioModule: Module {
return audioEngine.isRecording
}
Function("startDictation") { () -> Bool in
let isRecording = self.audioEngine?.toggleRecording(true) ?? false
self.sendEvent(ON_RECORDING_CHANGE_EVENT_NAME, ["data": isRecording])
return isRecording
}
Function("toggleRecording") { (val: Bool) -> Bool in
guard let audioEngine = self.audioEngine else {
print("AudioEngine not initialized")
+10
View File
@@ -17,6 +17,16 @@ export function toggleRecording(val: boolean): boolean {
return ExpoTwoWayAudioModule.toggleRecording(val);
}
/** Capture speech without treating another app's playback focus as a microphone failure. */
export function startDictation(): boolean {
// COMPAT(dictationCapture): added after v0.11.2, remove after 2027-01-16 once
// the native binary floor includes startDictation. Older OTA clients retain legacy capture.
if (typeof ExpoTwoWayAudioModule.startDictation !== "function") {
return ExpoTwoWayAudioModule.toggleRecording(true);
}
return ExpoTwoWayAudioModule.startDictation();
}
export function isRecording(): boolean {
return ExpoTwoWayAudioModule.isRecording();
}
@@ -12,6 +12,13 @@ import {
toAgentPersistenceHandle,
} from "../persistence-hooks.js";
export class AgentNotFoundError extends Error {
constructor(agentId: string) {
super(`Agent not found: ${agentId}`);
this.name = "AgentNotFoundError";
}
}
interface PendingAgentInitialization {
promise: Promise<ManagedAgent>;
options: { broadcastTimeline: boolean };
@@ -94,7 +101,7 @@ export async function ensureAgentLoaded(
const initPromise = (async () => {
const record = await deps.agentStorage.get(agentId);
if (!record) {
throw new Error(`Agent not found: ${agentId}`);
throw new AgentNotFoundError(agentId);
}
const validProviders = deps.validProviders ?? deps.agentManager.getRegisteredProviderIds();
@@ -0,0 +1,157 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import pino from "pino";
import { afterEach, describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { AgentManager } from "./agent-manager.js";
import { PiRpcAgentClient } from "./providers/pi/agent.js";
import { FakePi } from "./providers/pi/test-utils/fake-pi.js";
const AGENT_ID = "00000000-0000-4000-8000-000000000001";
async function drainSessionEvents(): Promise<void> {
for (let index = 0; index < 10_000; index++) await Promise.resolve();
}
async function waitFor(condition: () => boolean): Promise<void> {
for (let attempt = 0; attempt < 250; attempt++) {
if (condition()) return;
await new Promise((resolve) => setTimeout(resolve, 20));
}
throw new Error("Timed out waiting for Pi subagent output");
}
afterEach(() => vi.useRealTimers());
describe("provider subagent lifecycle ownership", () => {
test(
"a delayed parent turn end preserves a Pi child until its provider settles it",
{ timeout: 15_000 },
async () => {
// Run the real Pi client and adapter with an injected runtime.
const pi = new FakePi();
const client = new PiRpcAgentClient({
logger: pino({ level: "silent" }),
runtime: pi,
// The injected runtime owns execution; use an existing executable for the real availability
// probe so this test does not depend on a Pi installation or replace a client method.
runtimeSettings: { command: { mode: "replace", argv: [process.execPath] } },
});
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-provider-subagent-poll-"));
const manager = new AgentManager({
clients: { pi: client },
idFactory: () => AGENT_ID,
logger: createTestLogger(),
});
try {
await manager.createAgent({ provider: "pi", cwd: workdir }, AGENT_ID, {
workspaceId: undefined,
});
const piSession = pi.latestSession();
// An async spawn whose status directory never appears: every poll tick is a miss.
piSession.emit({
type: "tool_execution_start",
toolCallId: "call-1",
toolName: "subagent",
args: { agent: "scout", task: "Inspect", async: true },
});
piSession.emit({
type: "tool_execution_end",
toolCallId: "call-1",
toolName: "subagent",
result: {
details: {
mode: "single",
runId: "run-1",
asyncId: "run-1",
asyncDir: join(workdir, "gone"),
results: [],
},
},
});
const row = () => manager.getProviderSubagent(AGENT_ID, "call-1");
await waitFor(() => row()?.status === "running");
// Model delayed delivery of the parent turn end, with no provider cancellation.
// Only Date moves: the adapter's real polling interval has not expired its miss budget.
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date(Date.now() + 60_000));
piSession.emit({ type: "agent_start" });
piSession.emit({ type: "agent_end" });
await drainSessionEvents();
expect(row()?.status).toBe("running");
// The provider's completion notice, rather than elapsed time in the manager, settles it.
piSession.emit({
type: "message_end",
message: {
role: "custom",
customType: "subagent-notify",
content: `Background task completed: **scout**\nRetention-managed async directory: ${join(workdir, "gone")}`,
display: true,
},
});
await drainSessionEvents();
expect(row()?.status).toBe("completed");
} finally {
try {
await manager.closeAgent(AGENT_ID);
} catch {
// A createAgent failure leaves nothing to close; the workdir cleanup must still run.
} finally {
rmSync(workdir, { recursive: true, force: true });
}
}
},
);
test("history replay preserves the async status supplied by Pi", async () => {
const pi = new FakePi();
const client = new PiRpcAgentClient({
logger: pino({ level: "silent" }),
runtime: pi,
// The injected runtime owns execution; use an existing executable for the real availability
// probe so this test does not depend on a Pi installation or replace a client method.
runtimeSettings: { command: { mode: "replace", argv: [process.execPath] } },
});
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-provider-subagent-poll-"));
const manager = new AgentManager({
clients: { pi: client },
idFactory: () => AGENT_ID,
logger: createTestLogger(),
});
try {
await manager.createAgent({ provider: "pi", cwd: workdir }, AGENT_ID, {
workspaceId: undefined,
});
pi.latestSession().messages = [
{
role: "assistant",
content: [
{
type: "toolCall",
id: "call-replay",
name: "subagent",
arguments: { workflowScript: "return await runs.all([]);", async: true },
},
],
},
{
role: "toolResult",
toolCallId: "call-replay",
toolName: "subagent",
content: [],
details: {
mode: "workflow",
runId: "run-replay",
asyncId: "run-replay",
asyncDir: join(workdir, "gone"),
results: [],
},
},
];
await manager.hydrateTimelineFromProvider(AGENT_ID, { force: true });
expect(manager.getProviderSubagent(AGENT_ID, "call-replay")?.status).toBe("running");
} finally {
await manager.closeAgent(AGENT_ID);
rmSync(workdir, { recursive: true, force: true });
}
});
});
@@ -10929,6 +10929,41 @@ test("authoritative timeline records a daemon-handled submitted prompt before it
}
});
test("tryRunOutOfBand gives providers the command another agent sent, not its envelope", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-agent-out-of-band-"));
const seenPrompts: AgentPromptInput[] = [];
class SlashCommandSession extends TestAgentSession {
override tryHandleOutOfBand(prompt: AgentPromptInput) {
seenPrompts.push(prompt);
return prompt === "/handled" ? { run: async () => {} } : null;
}
}
const client = new (class extends TestAgentClient {
override async createSession(config: AgentSessionConfig) {
return new SlashCommandSession(config);
}
})();
const manager = new AgentManager({ clients: { codex: client }, logger });
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
try {
const fromAgent = prepareAgentMessage("/handled", { kind: "agent-message", agentId: "sender" });
expect(manager.tryRunOutOfBand(agent.id, fromAgent.prompt)).toBe(true);
expect(seenPrompts).toEqual(["/handled"]);
// A human who pastes an envelope gets it quoted, and it stays a plain prompt.
const pasted = prepareAgentMessage(String(fromAgent.prompt));
expect(manager.tryRunOutOfBand(agent.id, pasted.prompt)).toBe(false);
expect(seenPrompts.at(-1)).toBe(pasted.prompt);
} finally {
await manager.closeAgent(agent.id);
rmSync(workdir, { recursive: true, force: true });
}
});
test("replaceAgentRun succeeds when foreground turn terminal event is never delivered", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-stale-fg-"));
const storagePath = join(workdir, "agents");
@@ -79,7 +79,7 @@ import {
type PendingForegroundRun,
} from "./agent-run-state.js";
import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js";
import { projectAgentMessage } from "./agent-messages/index.js";
import { parseAgentMessage, projectAgentMessage } from "./agent-messages/index.js";
import { isStaleProviderSessionError } from "./stale-provider-session-error.js";
import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js";
import { resolveCreateAgentTitles } from "./create-agent-title.js";
@@ -2459,7 +2459,12 @@ export class AgentManager {
*/
tryRunOutOfBand(agentId: string, prompt: AgentPromptInput, options?: AgentRunOptions): boolean {
const agent = this.requireSessionAgent(agentId);
const handler = agent.session.tryHandleOutOfBand?.(prompt);
// Providers parse slash commands from the raw text, so a command another agent sent
// must reach them without its sender envelope.
const agentMessage = typeof prompt === "string" ? parseAgentMessage(prompt) : null;
const handler = agent.session.tryHandleOutOfBand?.(
agentMessage?.source ? agentMessage.text : prompt,
);
if (!handler) {
return false;
}
@@ -5,7 +5,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { describe, expect, it, vi } from "vitest";
import { realpathSync } from "node:fs";
import { access, mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { access, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { join, resolve as resolvePath } from "node:path";
import { tmpdir } from "node:os";
import { z } from "zod";
@@ -15,6 +15,8 @@ import { createAgentMcpServer as createAgentMcpServerWithDependencies } from "./
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 { OmpAgentClient } from "./providers/omp/agent.js";
import { FakeOmp } from "./providers/omp/test-utils/fake-omp.js";
import type {
AgentClient,
AgentMode,
@@ -841,6 +843,49 @@ function createAgentMcpServer(options: Parameters<typeof createAgentMcpServerWit
});
}
describe("bundled Paseo skill", () => {
it("documents only advertised create_workspace input names", async () => {
const server = await createAgentMcpServer({
agentManager: new BoundaryAgentManagerFake() as AgentManager,
agentStorage: new BoundaryAgentStorageFake() as AgentStorage,
providerSnapshotManager:
new BoundaryProviderSnapshotManagerFake() as unknown as ProviderSnapshotManager,
logger: createTestLogger(),
});
const client = await connectInMemoryMcpClient(server);
try {
const skill = await readFile(
new URL("../../../../../skills/paseo/SKILL.md", import.meta.url),
"utf8",
);
const paragraph = skill.split("\n").find((line) => line.startsWith("**`create_workspace`**"));
expect(paragraph).toBeTypeOf("string");
// The final sentence describes output, rather than tool inputs.
const inputGuidance = paragraph!.split("Returns ")[0]!;
const documentedInputs = [...inputGuidance.matchAll(/`([a-zA-Z][a-zA-Z0-9]*)`/g)].map(
(match) => match[1]!,
);
expect(documentedInputs.length).toBeGreaterThan(0);
const { tools } = await client.listTools();
const tool = tools.find((entry) => entry.name === "create_workspace")!;
const properties = tool.inputSchema.properties!;
const advertisedNamesAndValues = new Set([
...Object.keys(properties),
...Object.values(properties).flatMap((property) =>
typeof property === "object" && property !== null && Array.isArray(property.enum)
? property.enum
: [],
),
]);
expect(documentedInputs.filter((name) => !advertisedNamesAndValues.has(name))).toEqual([]);
} finally {
await client.close();
await server.close();
}
});
});
describe("browser MCP tools", () => {
const logger = createTestLogger();
@@ -3856,8 +3901,7 @@ class HeldTurnAgentSession implements AgentSession {
}
tryHandleOutOfBand(prompt: AgentPromptInput) {
const text = typeof prompt === "string" ? (parseAgentMessage(prompt)?.text ?? prompt) : null;
return text === "/side-command" ? { run: async () => {} } : null;
return prompt === "/side-command" ? { run: async () => {} } : null;
}
async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
@@ -4557,6 +4601,180 @@ describe("send_agent_prompt MCP tool", () => {
});
});
describe("send_agent_prompt MCP tool to an OMP child", () => {
const logger = createTestLogger();
const existingCwd = process.cwd();
async function setupOmpChild() {
const workdir = await mkdtemp(join(tmpdir(), "mcp-omp-child-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const parentClient = new HeldTurnAgentClient("claude", false);
const omp = new FakeOmp();
const agentManager = new AgentManager({
clients: {
claude: parentClient,
omp: new OmpAgentClient({
logger,
runtime: omp,
noTurnScheduler: { waitForSettle: async () => {} },
runtimeSettings: { command: { mode: "replace", argv: [process.execPath] } },
}),
},
registry: storage,
logger,
});
const parent = await agentManager.createAgent(
{ provider: "claude", cwd: existingCwd },
undefined,
{ workspaceId: "wks_parent" },
);
const child = await agentManager.createAgent(
{ provider: "omp", cwd: existingCwd, title: "OMP child" },
undefined,
{ workspaceId: "wks_parent" },
);
const server = await createAgentMcpServer({
agentManager,
agentStorage: storage,
callerAgentId: parent.id,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
return {
agentManager,
storage,
workdir,
parentClient,
ompSession: omp.latestSession(),
childId: child.id,
tool: registeredTool(server, "send_agent_prompt"),
};
}
async function expectOneFinishNotification(
agentManager: AgentManager,
parentClient: HeldTurnAgentClient,
childId: string,
) {
await vi.waitFor(() => expect(agentManager.getAgent(childId)?.lifecycle).toBe("idle"));
await vi.waitFor(() => {
expect(parentClient.sessions[0]!.prompts).toHaveLength(1);
expect(parentClient.sessions[0]!.prompts[0]).toContain(
`Agent ${childId} (OMP child) finished.`,
);
});
}
it.each([
{ command: "follow-up", streamingBehavior: "followUp" },
{ command: "steer", streamingBehavior: "steer" },
] as const)(
"delivers /$command into a turn OMP started on its own, without aborting it",
async ({ command, streamingBehavior }) => {
const { agentManager, storage, workdir, parentClient, ompSession, childId, tool } =
await setupOmpChild();
try {
ompSession.beginTurn();
ompSession.streamAssistantText("background job finished, working on it");
await vi.waitFor(() => {
expect(agentManager.getAgent(childId)?.activeTurnId).toMatch(/^autonomous-/);
});
const response = await invokeToolWithParsedInput(tool, {
agentId: childId,
prompt: `/${command} then summarize the logs`,
});
expect(response.structuredContent).toMatchObject({
success: true,
disposition: "out_of_band",
status: "running",
});
await vi.waitFor(() => {
expect(ompSession.prompts).toEqual([
{ message: "then summarize the logs", imageCount: 0, streamingBehavior },
]);
});
expect(ompSession.abortRequested).toBe(false);
expect(agentManager.getAgent(childId)?.lifecycle).toBe("running");
ompSession.finishTurn();
await expectOneFinishNotification(agentManager, parentClient, childId);
} finally {
await removeAgentStateDir(agentManager, storage, workdir);
}
},
);
it.each(["before ack", "after ack"])(
"reports an idle follow-up rejected %s and notifies its parent",
async (order) => {
const { agentManager, storage, workdir, parentClient, ompSession, childId, tool } =
await setupOmpChild();
try {
ompSession.promptAck = { requestId: "follow-up-rejected" };
const rejection = {
type: "prompt_result",
id: "follow-up-rejected",
agentInvoked: false,
status: "error",
sessionSettled: true,
error: { message: "Provider rejected the follow-up", retryable: false },
} as const;
const promptStarted = ompSession.nextPrompt();
const response = invokeToolWithParsedInput(tool, {
agentId: childId,
prompt: "/follow-up check the build",
});
await promptStarted;
if (order === "before ack") ompSession.emit(rejection);
await response;
if (order === "after ack") ompSession.emit(rejection);
await vi.waitFor(() => expect(agentManager.getAgent(childId)?.lifecycle).toBe("error"));
expect(agentManager.getAgent(childId)?.lastError).toContain(
"Provider rejected the follow-up",
);
await vi.waitFor(() => expect(parentClient.sessions[0]!.prompts).toHaveLength(1));
expect(parentClient.sessions[0]!.prompts[0]).toContain(
`Agent ${childId} (OMP child) errored.`,
);
} finally {
await removeAgentStateDir(agentManager, storage, workdir);
}
},
);
it("lets an idle child start a run with the follow-up", async () => {
const { agentManager, storage, workdir, parentClient, ompSession, childId, tool } =
await setupOmpChild();
try {
const response = await invokeToolWithParsedInput(tool, {
agentId: childId,
prompt: "/follow-up check the build",
});
expect(response.structuredContent).toMatchObject({
success: true,
disposition: "out_of_band",
status: "running",
});
await vi.waitFor(() => {
expect(ompSession.prompts).toEqual([
{ message: "check the build", imageCount: 0, streamingBehavior: "followUp" },
]);
});
ompSession.beginTurn();
ompSession.streamAssistantText("Build is green");
await vi.waitFor(() => expect(agentManager.getAgent(childId)?.lifecycle).toBe("running"));
ompSession.finishTurn();
await expectOneFinishNotification(agentManager, parentClient, childId);
} finally {
await removeAgentStateDir(agentManager, storage, workdir);
}
});
});
describe("update_agent MCP tool", () => {
const logger = createTestLogger();
@@ -4,7 +4,7 @@ import path from "node:path";
import { afterEach, describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js";
import type { AgentTimelineRow } from "../../agent-manager.js";
import { projectTimelineRows } from "../../timeline-projection.js";
import { ClaudeAgentClient } from "./agent.js";
@@ -105,6 +105,39 @@ async function collectUntilTerminal(
return events;
}
async function collectTimelineAcrossTurns(
session: AgentSession,
prompt: string,
turnCount: number,
): Promise<AgentTimelineRow[]> {
const rows: AgentTimelineRow[] = [];
let currentTurnId: string | undefined;
let completedTurns = 0;
let unsubscribe = () => {};
const turnsCompleted = new Promise<void>((resolve) => {
unsubscribe = session.subscribe((event) => {
if (event.type === "turn_started") currentTurnId = event.turnId;
if (event.type === "timeline") {
rows.push({
seq: rows.length + 1,
timestamp: new Date(Date.UTC(2026, 9, 4, 0, 0, rows.length)).toISOString(),
item: event.item,
...(currentTurnId ? { turnId: currentTurnId } : {}),
});
}
if (event.type === "turn_completed" && ++completedTurns === turnCount) resolve();
});
});
try {
await session.startTurn(prompt);
await turnsCompleted;
return rows;
} finally {
unsubscribe();
await session.close();
}
}
describe("background Claude subagents", () => {
afterEach(() => {
queryFactory.mockReset();
@@ -431,6 +464,124 @@ describe("background Claude subagents", () => {
type: "sub_agent",
subAgentType: "general-purpose",
description: "Count files here",
log: "[Bash] ls -1",
},
});
});
test("keeps one logged Task card when the child streams on after the parent's turn ends", async () => {
// A child can stream across the main session's turn boundary. Claude opens the next
// main-session turn with system/init; bare child frames do not open a turn.
queryFactory.mockImplementation(() =>
buildQueryMock([
{ type: "system", subtype: "init", session_id: "bg-session", permissionMode: "default" },
{
type: "assistant",
message: {
model: "claude-haiku-4-5",
content: [
{
type: "tool_use",
id: "toolu_background",
name: "Agent",
input: {
subagent_type: "general-purpose",
description: "Count files here",
prompt: "Run ls and reply with the number of entries.",
run_in_background: true,
},
},
],
},
},
{
type: "system",
subtype: "task_started",
task_id: "bg-task",
tool_use_id: "toolu_background",
task_type: "local_agent",
subagent_type: "general-purpose",
description: "Count files here",
prompt: "Run ls and reply with the number of entries.",
},
{
type: "user",
message: {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_background",
content: "Async agent launched successfully.",
},
],
},
},
{
type: "assistant",
parent_tool_use_id: "toolu_background",
message: {
model: "claude-haiku-4-5",
content: [
{
type: "tool_use",
id: "toolu_ls",
name: "Bash",
input: { command: "ls -1", description: "List files" },
},
],
},
},
{ type: "result", subtype: "success", usage: {}, total_cost_usd: 0 },
{ type: "system", subtype: "init", session_id: "bg-session", permissionMode: "default" },
{
type: "user",
parent_tool_use_id: "toolu_background",
message: {
role: "user",
content: [{ type: "tool_result", tool_use_id: "toolu_ls", content: "a\nb\nc" }],
},
},
{
type: "assistant",
parent_tool_use_id: "toolu_background",
message: { model: "claude-haiku-4-5", content: [{ type: "text", text: "3" }] },
},
{
type: "system",
subtype: "task_updated",
task_id: "bg-task",
patch: { status: "completed" },
},
{
type: "system",
subtype: "task_notification",
task_id: "bg-task",
tool_use_id: "toolu_background",
status: "completed",
},
{ type: "result", subtype: "success", usage: {}, total_cost_usd: 0 },
]),
);
const session = await new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory,
resolveBinary: async () => "/test/claude/bin",
}).createSession({ provider: "claude", cwd: process.cwd() });
const rows = await collectTimelineAcrossTurns(session, "delegate work", 2);
const cards = projectTimelineRows({ rows, mode: "projected" })
.map((entry) => entry.item)
.filter((item) => item.type === "tool_call" && item.callId === "toolu_background");
expect(cards).toHaveLength(1);
expect(cards[0]).toMatchObject({
status: "completed",
detail: {
type: "sub_agent",
subAgentType: "general-purpose",
description: "Count files here",
log: "[Bash] ls -1",
},
});
});
@@ -0,0 +1,157 @@
import { expect, test } from "vitest";
import type { Query, SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
import { ClaudeAgentClient } from "./agent.js";
import type { ClaudeQueryInput } from "./query.js";
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
// The injected SDK adapter consumes real session input and holds a command open
// until the test releases it. Closing the query interrupts that command.
class ForegroundQuery {
readonly work = deferred<void>();
readonly closed = deferred<void>();
private stopped = false;
private readonly output: AsyncGenerator<SDKMessage>;
constructor(input: ClaudeQueryInput) {
this.output = this.events(input);
}
private async *events(input: ClaudeQueryInput): AsyncGenerator<SDKMessage> {
if (typeof input.prompt === "string") throw new Error("Expected streaming input");
for await (const _prompt of input.prompt) {
yield {
type: "system",
subtype: "init",
session_id: "settings-session",
model: "claude-haiku-5-5",
permissionMode: "auto",
} as SDKMessage;
yield {
type: "assistant",
message: {
content: [
{ type: "tool_use", id: "tool_sleep", name: "Bash", input: { command: "sleep 20" } },
],
},
} as SDKMessage;
await this.work.promise;
if (this.stopped) return;
yield { type: "assistant", message: { content: "DONE" } } as SDKMessage;
yield {
type: "result",
subtype: "success",
usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 },
total_cost_usd: 0,
} as SDKMessage;
}
}
next() {
return this.output.next();
}
return() {
this.close();
return this.output.return();
}
close() {
this.stopped = true;
this.closed.resolve();
this.work.resolve();
}
async interrupt() {
this.close();
}
async setPermissionMode() {}
async setModel() {}
async supportedCommands() {
return [];
}
[Symbol.asyncIterator]() {
return this;
}
}
test.each(["mode", "model", "commands"] as const)(
"a pending thinking change preserves the current turn during a %s request",
async (control) => {
const queries: ForegroundQuery[] = [];
const efforts: ClaudeQueryInput["options"]["effort"][] = [];
const client = new ClaudeAgentClient({
logger: createTestLogger(),
resolveBinary: async () => "/test/claude",
queryFactory: (input) => {
const query = new ForegroundQuery(input);
queries.push(query);
efforts.push(input.options.effort);
return query as unknown as Query;
},
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
model: "claude-haiku-5-5",
modeId: "auto",
thinkingOptionId: "low",
});
const running = deferred<void>();
let terminal = deferred<string>();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => {
events.push(event);
if (
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.status === "running"
)
running.resolve();
if (
event.type === "turn_completed" ||
event.type === "turn_failed" ||
event.type === "turn_canceled"
)
terminal.resolve(event.type);
});
try {
await session.startTurn("Run the foreground command");
await running.promise;
expect(await session.setThinkingOption?.("medium")).toMatchObject({
message: "Thinking level applies next turn",
});
if (control === "mode") await session.setMode("acceptEdits");
else if (control === "model") await session.setModel("claude-sonnet-5-6");
else await session.listCommands();
queries[0].work.resolve();
expect(
await Promise.race([
terminal.promise,
queries[0].closed.promise.then(() => "query closed before turn finished"),
]),
).toBe("turn_completed");
expect(
events.some(
(event) =>
event.type === "timeline" &&
event.item.type === "assistant_message" &&
event.item.text === "DONE",
),
).toBe(true);
terminal = deferred<string>();
await session.startTurn("Run another command with the new thinking level");
queries[1].work.resolve();
expect(await terminal.promise).toBe("turn_completed");
expect(efforts).toEqual(["low", "medium"]);
} finally {
await session.close();
}
},
);
@@ -575,6 +575,36 @@ describe("ClaudeAgentClient binary resolution", () => {
await expect(resolveClaudeCodeVersion()).resolves.toMatch(/^\d+\.\d+\.\d+$/);
});
test("rejects a wrapper command's own version output instead of reading it as Claude Code's", async () => {
const wrapperDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-version-"));
const wrapperPath = path.join(wrapperDir, "wrapper");
await fs.writeFile(wrapperPath, 'console.log("cux 0.4.1");\n');
try {
await expect(
resolveClaudeCodeVersion({
command: { mode: "replace", argv: [process.execPath, wrapperPath] },
}),
).rejects.toThrow("Unable to parse Claude Code version from --version output");
} finally {
await fs.rm(wrapperDir, { recursive: true, force: true });
}
});
test("resolves the version when the command forwards the Claude Code banner", async () => {
const wrapperDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-version-"));
const wrapperPath = path.join(wrapperDir, "wrapper");
await fs.writeFile(wrapperPath, 'console.log("2.1.302 (Claude Code)");\n');
try {
await expect(
resolveClaudeCodeVersion({
command: { mode: "replace", argv: [process.execPath, wrapperPath] },
}),
).resolves.toBe("2.1.302");
} finally {
await fs.rm(wrapperDir, { recursive: true, force: true });
}
});
test("loads user, project, and local Claude settings", async () => {
const queryReturn = vi.fn();
queryReturn.mockResolvedValue(undefined);
@@ -1839,7 +1839,13 @@ export async function resolveClaudeCodeVersion(
timeout: 5_000,
signal,
});
const version = parseClaudeCodeVersion(`${stdout}\n${stderr}`);
// A wrapper command (the `command` provider setting) prints its own version here
// instead of Claude Code's. Only trust output that identifies itself as Claude Code,
// so a wrapper's version can't mis-gate models behind a minimumClaudeCodeVersion.
const rawVersionOutput = `${stdout}\n${stderr}`;
const version = /\d+\.\d+\.\d+\s+\(Claude Code\)/i.test(rawVersionOutput)
? parseClaudeCodeVersion(rawVersionOutput)
: null;
if (!version) {
throw new Error("Unable to parse Claude Code version from --version output");
}
@@ -2228,18 +2234,37 @@ class ClaudeAgentSession implements AgentSession {
getToolInput: (toolUseId) => this.toolUseCache.get(toolUseId)?.input ?? null,
readWorkflowResult: readClaudeWorkflowResultFile,
});
/**
* Subagent tool calls Claude has already answered while the child still runs, with the input
* they were launched with and the turn that answered them. A background child is answered at
* launch and streams its frames afterwards.
*/
private readonly settledSubagentToolCalls = new Map<
string,
{
item: Extract<AgentTimelineItem, { type: "tool_call" }>;
input: AgentMetadata | null;
turnId: string | null;
}
>();
private readonly sidechainTracker = new ClaudeSidechainTracker({
getToolInput: (toolUseId) => this.toolUseCache.get(toolUseId)?.input ?? null,
getToolInput: (toolUseId) =>
this.toolUseCache.get(toolUseId)?.input ??
this.settledSubagentToolCallInCurrentTurn(toolUseId)?.input ??
null,
// Releases that predate the task protocol announce nothing, so the tracker keeps deriving
// identity and status from frames for them. Detecting the capability beats comparing version
// strings: it reacts to what this session actually does.
isDescriptorOwnedElsewhere: () => this.taskProtocolSource.isActive,
// The parent's tool call owns its card once its result arrives. A background child streams
// its frames after Claude has already answered the call, and re-emitting the card from them
// would replace the settled card with an unlabeled, running one.
// A background child streams its frames after Claude has already answered the call, so its
// card is rebuilt from the settled call: a running one would replace the settled, labeled card.
// A card belongs to the turn that answered the call, so frames from a later turn leave it be.
needsSyntheticParentToolCard: (toolUseId) =>
this.toolUseCache.has(toolUseId) &&
(this.toolUseCache.has(toolUseId) ||
this.settledSubagentToolCallInCurrentTurn(toolUseId) !== undefined) &&
this.taskProtocolSource.needsSyntheticParentToolCard(toolUseId),
getSettledParentToolCall: (toolUseId) =>
this.settledSubagentToolCallInCurrentTurn(toolUseId)?.item,
});
private persistedHistory: PersistedTimelineEntry[] = [];
private persistedProviderSubagentEvents: Extract<
@@ -2591,13 +2616,13 @@ class ClaudeAgentSession implements AgentSession {
const normalized = isPermissionMode(modeId) ? modeId : "default";
assertClaudeModeCanRun(normalized, this.harnessEnvironment);
const previousMode = this.currentMode;
const launchesQuery = !this.query || this.queryRestartNeeded;
const previousQuery = this.query;
const activeQuery = await this.ensureQuery(normalized);
try {
await activeQuery.setPermissionMode(normalized);
} catch (error) {
// The query was launched in the rejected mode; relaunch in the current mode next time.
if (launchesQuery) this.queryRestartNeeded = true;
if (activeQuery !== previousQuery) this.queryRestartNeeded = true;
throw error;
}
if (normalized === "plan") {
@@ -2876,6 +2901,7 @@ class ClaudeAgentSession implements AgentSession {
this.cancelCurrentTurn = null;
this.turnState = "idle";
this.sidechainTracker.clear();
this.settledSubagentToolCalls.clear();
this.taskProtocolSource.reset();
this.input?.end();
this.query?.close?.();
@@ -3294,7 +3320,9 @@ class ClaudeAgentSession implements AgentSession {
}
private async ensureQuery(launchMode: PermissionMode = this.currentMode): Promise<Query> {
if (this.query && !this.queryRestartNeeded) {
// Controls use the running turn's query. Pending launch settings take effect
// after that turn finishes, when its active query handle has been cleared.
if (this.query && (!this.queryRestartNeeded || this.activeForegroundQuery === this.query)) {
return this.query;
}
@@ -4361,6 +4389,7 @@ class ClaudeAgentSession implements AgentSession {
for (const event of foldSubagentObservations(subagentObservations)) {
events.push({ type: "provider_subagent", provider: "claude", event });
}
this.releaseFinishedSubagentToolCalls(subagentObservations);
for (const observation of subagentObservations) {
if (observation.kind !== "declared") continue;
if (!this.taskProtocolSource.needsSyntheticParentToolCard(observation.id)) continue;
@@ -5548,28 +5577,22 @@ class ClaudeAgentSession implements AgentSession {
const { images, text } = splitClaudeToolResultImages(block.content);
const output = this.buildToolOutput(text, block, entry);
if (block.is_error) {
this.pushToolCall(
mapClaudeFailedToolCall({
const settledToolCall = block.is_error
? mapClaudeFailedToolCall({
name: toolName,
callId,
input: entry?.input ?? null,
output: output ?? null,
error: { ...block, content: text },
}),
items,
);
} else {
this.pushToolCall(
mapClaudeCompletedToolCall({
})
: mapClaudeCompletedToolCall({
name: toolName,
callId,
input: entry?.input ?? null,
output: output ?? null,
}),
items,
);
}
});
this.pushToolCall(settledToolCall, items);
this.rememberSettledSubagentToolCall(settledToolCall, entry);
for (const image of images) {
const imageItem = renderProviderImageOutputAsAssistantMarkdown(image, {
@@ -5585,6 +5608,34 @@ class ClaudeAgentSession implements AgentSession {
}
}
private rememberSettledSubagentToolCall(
item: Extract<AgentTimelineItem, { type: "tool_call" }> | null,
entry: ToolUseCacheEntry | undefined,
): void {
const subagentId = item ? this.taskProtocolSource.resolveSubagentId(item.callId) : undefined;
if (!item || !subagentId) return;
this.settledSubagentToolCalls.set(subagentId, {
item,
input: entry?.input ?? null,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null,
});
}
private settledSubagentToolCallInCurrentTurn(subagentId: string) {
const settled = this.settledSubagentToolCalls.get(subagentId);
const currentTurnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null;
return settled && settled.turnId === currentTurnId ? settled : undefined;
}
/** A finished child streams no more frames, so its settled call has nothing left to update. */
private releaseFinishedSubagentToolCalls(observations: SubagentObservation[]): void {
for (const observation of observations) {
if (observation.kind === "status" && observation.status !== "running") {
this.settledSubagentToolCalls.delete(observation.id);
}
}
}
private buildToolOutput(
content: unknown,
block: ClaudeContentChunk,
@@ -559,6 +559,33 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
expect(models.map((model) => model.id)).not.toContain("claude-opus-5[1m]");
expect(models.map((model) => model.id)).not.toContain("claude-opus-5");
});
it("keeps version-gated models when the probe command cannot identify Claude Code", async () => {
const configDir = await createClaudeConfigDir({});
const wrapperDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-wrapper-"));
const wrapperPath = path.join(wrapperDir, "wrapper");
await fs.writeFile(wrapperPath, 'console.log("cux 0.4.1");\n');
try {
const client = new ClaudeAgentClient({
logger: createTestLogger(),
runtimeSettings: {
command: { mode: "replace", argv: [process.execPath, wrapperPath] },
env: { CLAUDE_CONFIG_DIR: configDir },
},
discoverModels: async () => [],
});
const { models } = await client.fetchCatalog({
scope: "workspace",
cwd: configDir,
force: false,
});
expect(models.map((model) => model.id)).toContain("claude-opus-5-5");
} finally {
await fs.rm(wrapperDir, { recursive: true, force: true });
}
});
});
describe("normalizeClaudeRuntimeModelId", () => {
@@ -18,6 +18,8 @@ interface ClaudeContentChunk {
[key: string]: unknown;
}
type ClaudeToolCallItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
interface SubAgentActionEntry {
index: number;
toolName: string;
@@ -64,6 +66,7 @@ export class ClaudeSidechainTracker {
private readonly getToolInput: (toolUseId: string) => AgentMetadata | null | undefined;
private readonly isDescriptorOwnedElsewhere: () => boolean;
private readonly needsSyntheticParentToolCard: (toolUseId: string) => boolean;
private readonly getSettledParentToolCall: (toolUseId: string) => ClaudeToolCallItem | undefined;
constructor(input: {
getToolInput: (toolUseId: string) => AgentMetadata | null | undefined;
@@ -74,10 +77,17 @@ export class ClaudeSidechainTracker {
*/
isDescriptorOwnedElsewhere?: () => boolean;
needsSyntheticParentToolCard?: (toolUseId: string) => boolean;
/**
* The parent's tool call once Claude has answered it. A background child streams its frames
* after that answer, so its card keeps the settled status and output and gains the child's
* action log.
*/
getSettledParentToolCall?: (toolUseId: string) => ClaudeToolCallItem | undefined;
}) {
this.getToolInput = input.getToolInput;
this.isDescriptorOwnedElsewhere = input.isDescriptorOwnedElsewhere ?? (() => false);
this.needsSyntheticParentToolCard = input.needsSyntheticParentToolCard ?? (() => true);
this.getSettledParentToolCall = input.getSettledParentToolCall ?? (() => undefined);
}
handleMessage(message: SDKMessage, parentToolUseId: string): AgentStreamEvent[] {
@@ -119,17 +129,19 @@ export class ClaudeSidechainTracker {
return [];
}
const toolCall = mapClaudeRunningToolCall({
name: "Task",
callId: parentToolUseId,
input: null,
output: null,
});
const toolCall =
this.getSettledParentToolCall(parentToolUseId) ??
mapClaudeRunningToolCall({
name: "Task",
callId: parentToolUseId,
input: null,
output: null,
});
if (!toolCall) {
return [];
}
const detail: Extract<AgentTimelineItem, { type: "tool_call" }>["detail"] = {
const detail: ClaudeToolCallItem["detail"] = {
type: "sub_agent",
...(state.subAgentType ? { subAgentType: state.subAgentType } : {}),
...(state.description ? { description: state.description } : {}),
@@ -3,7 +3,8 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { setTimeout as delay } from "node:timers/promises";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { runProviderRefreshWithDeadline } from "../provider-refresh-deadline.js";
@@ -203,16 +204,28 @@ describe("GenericACPAgentClient diagnostics", () => {
});
test("reports a hung ACP session/new phase without failing the diagnostic", async () => {
await withFakeACPAgent("hang-session", async (scriptPath, mode) => {
await withFakeACPAgent("hang-session", async (scriptPath, mode, testDir) => {
const tracePath = path.join(testDir, "phase-trace.jsonl");
const client = new GenericACPAgentClient({
logger: createTestLogger(),
command: [process.execPath, scriptPath, mode],
command: [process.execPath, scriptPath, mode, "", tracePath],
providerId: "grok",
label: "Grok",
diagnosticPhaseTimeoutMs: TEST_ACP_TIMEOUT_MS,
});
const { diagnostic } = await client.getDiagnostic();
// Freeze phase deadlines until the child actually reaches session/new. Process startup
// and initialize latency must not consume the session timeout this test exercises.
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
let diagnostic: string;
try {
const pending = client.getDiagnostic();
await waitForSessionNew(tracePath);
await vi.advanceTimersByTimeAsync(TEST_ACP_TIMEOUT_MS);
({ diagnostic } = await pending);
} finally {
vi.useRealTimers();
}
expect(diagnostic).toContain("Grok (ACP)");
expect(diagnostic).toContain("Provider ID: grok");
@@ -356,6 +369,14 @@ async function withTempDir(prefix: string, run: (testDir: string) => Promise<voi
}
}
async function waitForSessionNew(tracePath: string): Promise<void> {
const readyBy = Date.now() + 10_000;
while (!(await readFile(tracePath, "utf8").catch(() => "")).includes("session/new")) {
if (Date.now() >= readyBy) throw new Error("Fake ACP agent did not reach session/new");
await delay(20);
}
}
async function expectProcessExit(pid: number): Promise<void> {
const deadline = Date.now() + 2_000;
while (Date.now() < deadline) {
@@ -426,6 +447,7 @@ rl.on("line", (line) => {
if (message.method === "session/new") {
if (mode === "hang-session") {
if (initializeTracePath) fs.appendFileSync(initializeTracePath, "session/new\\n");
return;
}
@@ -39,6 +39,19 @@ const ABORTED_TERMINAL_RESPONSE: OmpAgentMessage = {
errorMessage: "Interrupted by user",
};
test("an idle follow-up is submitted as a prompt so OMP can start a turn", async () => {
const runtime = new FakeOmp();
const client = new OmpAgentClient({ logger: createTestLogger(), runtime });
const session = await client.createSession({ provider: "omp", cwd: process.cwd() });
onTestFinished(() => session.close());
const handler = session.tryHandleOutOfBand?.("/follow-up check the build");
expect(handler).not.toBeNull();
await handler!.run({ emit: () => {} });
expect(runtime.latestSession().prompts).toEqual([
{ message: "check the build", imageCount: 0, streamingBehavior: "followUp" },
]);
});
test("OMP ready timeout defaults to 20 seconds and RPC timeout overrides both", () => {
expect(resolveOmpProviderOptions({}).runtimeOptions).toMatchObject({
readyTimeoutMs: 20_000,
@@ -705,6 +705,7 @@ export class OmpAgentSession implements AgentSession {
private activeAssistantMessageId: string | null = null;
private activeTurnTerminalAssistantMessage: OmpAgentMessage | null = null;
private activeTurnStarted = false;
private outOfBandTurnStartAnnounced = false;
private activeTurnHasUserMessage = false;
private activeNoTurnPromptText: string | null = null;
private readonly pendingNoTurnOutputs: Array<{ turnId: string; message: string }> = [];
@@ -849,6 +850,20 @@ export class OmpAgentSession implements AgentSession {
}
async startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<StartTurnResult> {
return this.startPrompt({ prompt, options });
}
private async startPrompt({
prompt,
options,
streamingBehavior,
announceTurn = false,
}: {
prompt: AgentPromptInput;
options?: AgentRunOptions;
streamingBehavior?: "steer" | "followUp";
announceTurn?: boolean;
}): Promise<StartTurnResult> {
if (this.activeTurnId) {
throw new Error("An OMP turn is already active");
}
@@ -861,17 +876,26 @@ export class OmpAgentSession implements AgentSession {
this.activeAssistantMessageId = null;
this.activeTurnTerminalAssistantMessage = null;
this.activeTurnStarted = false;
this.outOfBandTurnStartAnnounced = false;
this.activeTurnHasUserMessage = false;
this.activePromptRequestId = null;
this.clearNoTurnBuffers();
this.activeNoTurnPromptText = payload.text;
this.usagePoller.startTurn();
if (announceTurn) {
this.outOfBandTurnStartAnnounced = true;
this.emit({ type: "turn_started", provider: this.provider, turnId });
}
void (async () => {
try {
if (this.runtimeDead) await this.replaceRuntime(this.currentModeId ?? "full");
if (this.closed) throw new Error("OMP session is closed");
const ack = await this.runtimeSession.prompt(payload.text, payload.images);
const ack = await this.runtimeSession.prompt({
message: payload.text,
images: payload.images,
streamingBehavior,
});
this.activePromptRequestId = ack.requestId ?? null;
const correlatedResult = ack.requestId
? this.pendingPromptResults.get(ack.requestId)
@@ -1191,6 +1215,7 @@ export class OmpAgentSession implements AgentSession {
this.activeAssistantMessageId = null;
this.activeTurnTerminalAssistantMessage = null;
this.activeTurnStarted = false;
this.outOfBandTurnStartAnnounced = false;
this.activeTurnHasUserMessage = false;
this.pendingClientMessages.length = 0;
this.clearNoTurnBuffers();
@@ -1235,12 +1260,13 @@ export class OmpAgentSession implements AgentSession {
if (!message) {
return null;
}
const streamingBehavior = commandName === "steer" ? "steer" : "followUp";
return {
run: async () => {
if (commandName === "steer") {
this.runtimeSession.steer(message);
if (!this.activeTurnId && !this.activeTurnStarted) {
await this.startPrompt({ prompt: message, streamingBehavior, announceTurn: true });
} else {
this.runtimeSession.followUp(message);
await this.runtimeSession.prompt({ message, streamingBehavior });
}
},
};
@@ -1804,11 +1830,11 @@ export class OmpAgentSession implements AgentSession {
case "turn_start":
this.activeTurnStarted = true;
this.clearNoTurnBuffers();
this.emit({
type: "turn_started",
provider: this.provider,
turnId,
});
if (this.outOfBandTurnStartAnnounced) {
this.outOfBandTurnStartAnnounced = false;
} else {
this.emit({ type: "turn_started", provider: this.provider, turnId });
}
return;
case "message_start":
this.handleMessageStart(event);
@@ -143,6 +143,26 @@ describe("OMP CLI runtime", () => {
await expect(result).rejects.toThrow("rejected");
await session.close();
});
test("sends the prompt streamingBehavior only when one is given", async () => {
const child = createOmpChild();
const session = await createRuntime(child, []).startSession({ cwd: "/workspace/project" });
for (const streamingBehavior of ["followUp", undefined] as const) {
const pending = capturePendingCommand(child, "prompt");
const result = session.prompt({ message: "after this, update the docs", streamingBehavior });
const command = await pending;
expect(withoutRequestId(command)).toEqual({
type: "prompt",
message: "after this, update the docs",
...(streamingBehavior ? { streamingBehavior } : {}),
});
child.stdout.write(
`${JSON.stringify({ id: command.id, type: "response", command: "prompt", success: true })}\n`,
);
await result;
}
await session.close();
});
test("uses the configured RPC timeout and attributes the pending phase", async () => {
vi.useFakeTimers();
const child = createOmpChild();
@@ -438,7 +458,7 @@ describe("OMP CLI runtime", () => {
replyToCommands(child, () => undefined);
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
await expect(session.prompt("hello")).resolves.toEqual({ requestId: "req_1" });
await expect(session.prompt({ message: "hello" })).resolves.toEqual({ requestId: "req_1" });
});
test("reports OMP 18.3's late prompt rejection as a failed prompt result", async () => {
@@ -455,7 +475,7 @@ describe("OMP CLI runtime", () => {
const events: unknown[] = [];
session.onEvent((event) => events.push(event));
await expect(session.prompt("hello")).resolves.toEqual({ requestId: "req_1" });
await expect(session.prompt({ message: "hello" })).resolves.toEqual({ requestId: "req_1" });
expect(events).toContainEqual({
type: "prompt_result",
@@ -15,6 +15,7 @@ import {
type OmpRuntime,
type OmpRuntimeLaunch,
type OmpRuntimeSession,
type OmpPromptInput,
type OmpStartSessionInput,
} from "./runtime.js";
import {
@@ -142,14 +143,12 @@ class OmpCliRuntimeSession implements OmpRuntimeSession {
};
}
async prompt(
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): Promise<OmpPromptAck> {
async prompt({ message, images, streamingBehavior }: OmpPromptInput): Promise<OmpPromptAck> {
const { id: requestId, promise } = this.process.startRequest({
type: "prompt",
message,
...(images?.length ? { images } : {}),
...(streamingBehavior ? { streamingBehavior: streamingBehavior } : {}),
});
const ack = OmpPromptAckSchema.parse(await promise) ?? {};
return { requestId, ...ack };
@@ -282,13 +281,6 @@ class OmpCliRuntimeSession implements OmpRuntimeSession {
await this.request({ type: "steer", message, ...(images?.length ? { images } : {}) });
}
followUp(
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): void {
this.process.send({ type: "follow_up", message, ...(images?.length ? { images } : {}) });
}
async handoff(customInstructions?: string): Promise<void> {
await this.request({
type: "handoff",
@@ -535,6 +535,7 @@ export const OmpRpcCommandSchema = z.discriminatedUnion("type", [
type: z.literal("prompt"),
message: z.string(),
images: z.array(OmpImageContentSchema).optional(),
streamingBehavior: z.enum(["steer", "followUp"]).optional(),
}),
z.object({
...OmpCommandBase,
@@ -42,13 +42,18 @@ export interface OmpStartSessionInput {
extraArgs?: string[];
}
export interface OmpPromptInput {
message: string;
images?: Array<{ type: "image"; data: string; mimeType: string }>;
streamingBehavior?: "steer" | "followUp";
}
export interface OmpRuntimeSession {
readonly environment: Record<string, string>;
onEvent(callback: (event: OmpRuntimeEvent) => void): () => void;
prompt(
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): Promise<OmpPromptAck>;
// With streamingBehavior, a busy OMP queues the message instead of rejecting it, and an
// idle OMP starts a run with it.
prompt(input: OmpPromptInput): Promise<OmpPromptAck>;
compact(customInstructions?: string): Promise<void>;
setAutoCompaction(enabled: boolean): Promise<void>;
abort(): Promise<void>;
@@ -71,10 +76,6 @@ export interface OmpRuntimeSession {
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): Promise<void>;
followUp(
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): void;
handoff(customInstructions?: string): Promise<void>;
respondToExtensionUiRequest(
id: string,
@@ -2,6 +2,7 @@ import type {
OmpRuntime,
OmpRuntimeLaunch,
OmpRuntimeSession,
OmpPromptInput,
OmpStartSessionInput,
} from "../runtime.js";
import type {
@@ -133,7 +134,11 @@ export class FakeOmpSession implements OmpRuntimeSession {
readonly environment: Record<string, string>;
fastModeResult = { enabled: false, active: false };
readonly setFastModeRequests: boolean[] = [];
readonly prompts: Array<{ message: string; imageCount: number }> = [];
readonly prompts: Array<{
message: string;
imageCount: number;
streamingBehavior?: "steer" | "followUp";
}> = [];
readonly compactRequests: Array<{ customInstructions?: string }> = [];
readonly setAutoCompactionRequests: boolean[] = [];
readonly subagentSubscriptionRequests: FakeOmpSubagentSubscriptionLevel[] = [];
@@ -143,7 +148,6 @@ export class FakeOmpSession implements OmpRuntimeSession {
readonly handoffRequests: Array<{ customInstructions?: string }> = [];
readonly steerRequests: Array<{ message: string; imageCount: number }> = [];
steerError: Error | null = null;
readonly followUpRequests: Array<{ message: string; imageCount: number }> = [];
readonly hostToolSetRequests: OmpRpcHostToolDefinition[][] = [];
readonly hostToolResults: OmpRpcHostToolResult[] = [];
readonly hostToolUpdates: OmpRpcHostToolUpdate[] = [];
@@ -216,14 +220,19 @@ export class FakeOmpSession implements OmpRuntimeSession {
};
}
async prompt(
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): Promise<OmpPromptAck> {
if (this.state.isStreaming || this.state.isCompacting) {
async prompt({ message, images, streamingBehavior }: OmpPromptInput): Promise<OmpPromptAck> {
const busy = this.state.isStreaming || this.state.isCompacting;
if (busy && !streamingBehavior) {
throw new Error("Agent is already processing");
}
this.prompts.push({ message, imageCount: images?.length ?? 0 });
this.prompts.push({
message,
imageCount: images?.length ?? 0,
...(streamingBehavior ? { streamingBehavior: streamingBehavior } : {}),
});
if (busy) {
return {};
}
this.promptWaiters.shift()?.();
const heldPrompt = this.nextHeldPrompt;
if (heldPrompt) {
@@ -412,13 +421,6 @@ export class FakeOmpSession implements OmpRuntimeSession {
if (this.steerError) throw this.steerError;
}
followUp(
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): void {
this.followUpRequests.push({ message, imageCount: images?.length ?? 0 });
}
sendHostToolResult(result: OmpRpcHostToolResult): void {
this.hostToolResults.push(result);
this.hostToolResultWaiters.shift()?.(result);
@@ -0,0 +1,202 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import { createPaseoToolCatalog } from "../../tools/paseo-tools.js";
import { AgentManager } from "../../agent-manager.js";
import { AgentStorage } from "../../agent-storage.js";
import { ProviderSnapshotManager } from "../../provider-snapshot-manager.js";
import { OpenCodeBridge } from "./bridge.js";
import { ScheduleService } from "../../../schedule/service.js";
import { createWorkspaceScriptsService } from "../../../session/workspace-scripts/workspace-scripts-service.js";
import {
FileBackedProjectRegistry,
FileBackedWorkspaceRegistry,
} from "../../../workspace-registry.js";
import { createServiceProxySubsystem } from "../../../service-proxy.js";
import { WorkspaceScriptRuntimeStore } from "../../../workspace-script-runtime-store.js";
import { createTerminalManager } from "../../../../terminal/terminal-manager.js";
test("bridge preserves tool failure messages and classifies invalid requests as client errors", async () => {
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-7045-"));
const logger = createTestLogger();
const providerSnapshotManager = new ProviderSnapshotManager({ logger });
const agentManager = new AgentManager({ logger });
const agentStorage = new AgentStorage(paseoHome, logger);
const unused = async (): Promise<never> => {
throw new Error("Unexpected workspace or agent creation");
};
const scheduleService = new ScheduleService({
paseoHome,
logger,
agentManager,
agentStorage,
createAgent: unused,
createDirectoryWorkspace: unused,
createPaseoWorktreeWorkspace: unused,
archiveWorkspace: unused,
runner: async () => ({ agentId: null, output: "done" }),
});
const workspaceScripts = createWorkspaceScriptsService({
serviceProxy: createServiceProxySubsystem({ logger }),
scriptRuntimeStore: new WorkspaceScriptRuntimeStore(),
terminalManager: createTerminalManager(),
workspaceRegistry: new FileBackedWorkspaceRegistry(
path.join(paseoHome, "workspaces.json"),
logger,
),
projectRegistry: new FileBackedProjectRegistry(path.join(paseoHome, "projects.json"), logger),
workspaceGitService: {
peekSnapshot: () => {
throw new Error("Unexpected git lookup");
},
},
getDaemonTcpPort: null,
getDaemonTcpHost: null,
serviceProxyPublicBaseUrl: null,
resolveScriptHealth: null,
logger,
emit: () => {},
publishStatusUpdate: () => {},
spawnWorkspaceScript: unused,
assertAutomationAllowed: unused,
});
const catalog = createPaseoToolCatalog({
workspaceScripts,
agentManager,
agentStorage,
scheduleService,
providerSnapshotManager,
logger,
});
const bridge = new OpenCodeBridge({ paseoHome, logger });
try {
await bridge.start();
bridge.setManifestCatalog(catalog);
bridge.bindSession({ sessionId: "diagnosis", env: {}, tools: catalog });
const config = JSON.parse(bridge.decorateServerEnv({}).OPENCODE_CONFIG_CONTENT);
const { baseUrl, token } = config.plugin[0][1];
const completed = await scheduleService.create({
prompt: "Run once",
name: "Daily check",
cadence: { type: "cron", expression: "0 0 * * *" },
target: { type: "new-agent", config: { provider: "codex", cwd: paseoHome } },
maxRuns: 1,
runOnCreate: true,
});
await scheduleService.tick();
expect((await scheduleService.inspect(completed.id)).status).toBe("completed");
const manifest = await fetch(`${baseUrl}/_internal/opencode/tools`, {
headers: { Authorization: `Bearer ${token}` },
});
const { tools } = await manifest.json();
expect(
tools.find((tool: { name: string }) => tool.name === "create_agent").inputSchema.properties,
).not.toHaveProperty("background");
expect(
tools.find((tool: { name: string }) => tool.name === "update_schedule").inputSchema
.properties,
).not.toHaveProperty("every");
await writeFile(
path.join(paseoHome, "schedules", "broken.json"),
JSON.stringify({ id: "broken", prompt: 17 }),
);
const observed = [];
for (const [tool, body, expected] of [
[
"create_agent",
JSON.stringify({ title: "Probe", provider: "opencode", initialPrompt: "Reply OK" }),
400,
],
["create_agent", "{", 400],
["nonexistent_tool", "{}", 404],
["inspect_schedule", JSON.stringify({ id: "missing" }), 400],
...["pause_schedule", "resume_schedule", "run_schedule_once"].map(
(action) => [action, JSON.stringify({ id: completed.id }), 400] as const,
),
[
"create_schedule",
JSON.stringify({ prompt: "Probe", provider: "codex", cron: "not-a-cron" }),
400,
],
[
"create_schedule",
JSON.stringify({
prompt: "Probe",
provider: "codex",
cron: "0 0 * * *",
timezone: "Mars/Phobos",
}),
400,
],
["update_schedule", JSON.stringify({ id: completed.id, name: "" }), 200],
["update_schedule", JSON.stringify({ id: completed.id, cron: "0 0 30 2 *" }), 400],
["update_schedule", JSON.stringify({ id: completed.id, expiresIn: "not-a-duration" }), 400],
["send_agent_prompt", JSON.stringify({ agentId: "missing", prompt: "Hello" }), 400],
["inspect_schedule", JSON.stringify({ id: "broken" }), 500],
["list_workspace_scripts", JSON.stringify({ workspaceId: "wks_typo" }), 500],
[
"start_workspace_script",
JSON.stringify({ workspaceId: "wks_typo", scriptName: "web" }),
500,
],
] as const) {
const response = await fetch(
`${baseUrl}/_internal/opencode/sessions/diagnosis/tools/${tool}`,
{
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body,
},
);
observed.push({
tool,
body,
expected,
status: response.status,
payload: await response.json(),
});
}
for (const result of observed) expect.soft(result.status, result.body).toBe(result.expected);
expect((await scheduleService.inspect(completed.id)).name).toBeNull();
expect(
tools.find((tool: { name: string }) => tool.name === "update_schedule").inputSchema.properties
.name,
).not.toHaveProperty("minLength");
for (const result of observed.slice(14)) {
expect.soft(result.payload).toEqual({ error: "Workspace not found: wks_typo" });
}
expect.soft(observed[12].payload).toEqual({ error: "Agent not found: missing" });
expect.soft(observed[13].payload).toEqual({ error: expect.stringContaining('"prompt"') });
expect.soft(observed[13].payload).not.toHaveProperty("issues");
expect(observed[3].payload).toEqual({ error: "Schedule not found: missing" });
for (const result of observed.slice(4, 7))
expect(result.payload).toEqual({ error: `Schedule ${completed.id} is already completed` });
for (const [index, field] of [
[7, "cron"],
[8, "timezone"],
[10, "cron"],
[11, "expiresIn"],
] as const) {
expect(observed[index].payload).toMatchObject({
error: "Invalid tool input",
issues: expect.arrayContaining([expect.objectContaining({ path: [field] })]),
});
}
expect(observed[0].payload).toMatchObject({
error: "Invalid tool input",
issues: expect.arrayContaining([
expect.objectContaining({
path: ["provider"],
message: expect.stringContaining("provider/model"),
}),
]),
});
} finally {
await bridge.close();
await providerSnapshotManager.shutdown();
await rm(paseoHome, { recursive: true, force: true });
}
});
@@ -13,8 +13,11 @@ export default async function paseoPlugin(input, options) {
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(payload.error ?? `Paseo OpenCode bridge failed: ${response.status}`);
const detail = formatErrorDetail(payload.issues ?? payload.details);
const message = payload.error ?? `Paseo OpenCode bridge failed: ${response.status}`;
const error = new Error(detail ? `${message}: ${detail}` : message);
error.status = response.status;
if (detail) error.details = detail;
throw error;
}
return payload;
@@ -79,6 +82,17 @@ export default async function paseoPlugin(input, options) {
};
}
function formatErrorDetail(details) {
if (!Array.isArray(details)) return typeof details === "string" ? details : "";
return details
.map((issue) => {
const path =
Array.isArray(issue?.path) && issue.path.length > 0 ? issue.path.join(".") : "input";
return `${path}: ${issue?.message ?? "Invalid value"}`;
})
.join("; ");
}
function logPluginError(stage, context, error) {
console.error(`[paseo-opencode-plugin] ${stage} failed`, {
...context,
@@ -9,6 +9,7 @@ import { afterEach, describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import type { PaseoToolCatalog } from "../../tools/types.js";
import { PaseoToolInputError } from "../../tools/types.js";
import { createPaseoToolCatalog } from "../../tools/paseo-tools.js";
import { AgentManager } from "../../agent-manager.js";
import { AgentStorage } from "../../agent-storage.js";
@@ -36,8 +37,12 @@ function createCatalog(): PaseoToolCatalog {
description: "Returns the supplied value.",
inputSchema: { value: z.string() },
async handler(input: unknown) {
const parsed = z.object({ value: z.string() }).parse(input);
return { content: [{ type: "text", text: parsed.value }] };
const parsed = z
.object({ value: z.string().min(2, "value must be at least 2 characters") })
.strict()
.safeParse(input);
if (!parsed.success) throw new PaseoToolInputError(parsed.error.message, parsed.error.issues);
return { content: [{ type: "text", text: parsed.data.value }] };
},
};
const tools = new Map([[tool.name, tool]]);
@@ -193,6 +198,13 @@ describe("OpenCodeBridge", () => {
expect(asked).toEqual([
{ permission: "paseo_echo_context", patterns: ["*"], always: ["*"], metadata: {} },
]);
asked.length = 0;
await expect(
hooks.tool.paseo_echo_context.execute(
{ value: "x" },
{ sessionID: "ses_one", ask: async () => undefined },
),
).rejects.toThrow("value: value must be at least 2 characters");
executedInputs.length = 0;
await expect(
@@ -357,12 +369,17 @@ describe("OpenCodeBridge", () => {
body: JSON.stringify(input),
},
);
expect(response.status).toBe(500);
expect(response.status).toBe(400);
const payload = await response.json();
expect(payload.error).toContain("provider must be provider/model");
expect(payload).toMatchObject({ error: "Invalid tool input", issues: expect.any(Array) });
await expect(
tools.get("paseo_create_agent")!.execute(input, { sessionID: "caller" }),
).rejects.toMatchObject({ message: payload.error, status: 500 });
).rejects.toMatchObject({
status: 400,
message: expect.stringContaining(
"Invalid tool input: provider: provider must be provider/model, for example codex/gpt-5.4",
),
});
} finally {
await dispose();
}
@@ -550,6 +567,104 @@ describe("OpenCodeBridge", () => {
}
});
test("returns actionable 4xx responses for invalid bridge requests", async () => {
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-opencode-bridge-errors-"));
temporaryDirectories.push(paseoHome);
const bridge = new OpenCodeBridge({ paseoHome, logger: createTestLogger() });
await bridge.start();
const catalog = createCatalog();
bridge.bindSession({ sessionId: "ses_errors", env: {}, tools: catalog });
const expectedErrorCatalog: PaseoToolCatalog = {
tools: new Map([
[
"expected_error",
{
name: "expected_error",
description: "Throws an expected tool error.",
inputSchema: {},
async handler() {
throw new PaseoToolInputError("Agent not found: missing-agent");
},
},
],
[
"internal_error",
{
name: "internal_error",
description: "Throws an internal error.",
inputSchema: {},
async handler() {
throw new Error("Provider unavailable");
},
},
],
]),
getTool(name) {
return this.tools.get(name);
},
async executeTool(name, input, context) {
const tool = this.tools.get(name);
if (!tool) throw new Error(`Unknown tool: ${name}`);
return tool.handler(input, context ?? {});
},
};
bridge.bindSession({ sessionId: "ses_expected_error", env: {}, tools: expectedErrorCatalog });
try {
const plugin = readPluginOptions(bridge.decorateServerEnv({}));
const headers = {
Authorization: `Bearer ${plugin.token}`,
"Content-Type": "application/json",
};
const invalidInput = await fetch(
`${plugin.baseUrl}/_internal/opencode/sessions/ses_errors/tools/echo_context`,
{ method: "POST", headers, body: JSON.stringify({ value: "ok", extra: true }) },
);
expect(invalidInput.status).toBe(400);
expect(await invalidInput.json()).toEqual({
error: "Invalid tool input",
issues: [
{
code: "unrecognized_keys",
keys: ["extra"],
path: [],
message: 'Unrecognized key: "extra"',
},
],
});
const unknownTool = await fetch(
`${plugin.baseUrl}/_internal/opencode/sessions/ses_errors/tools/missing`,
{ method: "POST", headers, body: "{}" },
);
expect(unknownTool.status).toBe(404);
expect(await unknownTool.json()).toEqual({ error: "Paseo tool not found: missing" });
const malformedBody = await fetch(
`${plugin.baseUrl}/_internal/opencode/sessions/ses_errors/tools/echo_context`,
{ method: "POST", headers, body: "{" },
);
expect(malformedBody.status).toBe(400);
expect(await malformedBody.json()).toEqual({ error: "Malformed JSON request body" });
const expectedError = await fetch(
`${plugin.baseUrl}/_internal/opencode/sessions/ses_expected_error/tools/expected_error`,
{ method: "POST", headers, body: "{}" },
);
expect(expectedError.status).toBe(400);
expect(await expectedError.json()).toEqual({ error: "Agent not found: missing-agent" });
const internalError = await fetch(
`${plugin.baseUrl}/_internal/opencode/sessions/ses_expected_error/tools/internal_error`,
{ method: "POST", headers, body: "{}" },
);
expect(internalError.status).toBe(500);
expect(await internalError.json()).toEqual({ error: "Provider unavailable" });
} 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);
@@ -11,11 +11,22 @@ import {
addModelVisibleStructuredContent,
serializePaseoToolInputParameters,
} from "../../tools/paseo-tool-serialization.js";
import type { PaseoToolCatalog } from "../../tools/types.js";
import { PaseoToolInputError, type PaseoToolCatalog } from "../../tools/types.js";
const INTERNAL_PREFIX = "/_internal/opencode";
const MAX_REQUEST_BYTES = 1024 * 1024;
class OpenCodeBridgeClientError extends Error {
constructor(
readonly status: number,
message: string,
readonly details?: unknown,
) {
super(message);
this.name = "OpenCodeBridgeClientError";
}
}
interface OpenCodeBridgeOptions {
paseoHome: string;
logger: Logger;
@@ -187,10 +198,26 @@ export class OpenCodeBridge {
sendJson(response, 404, { error: "Not found" });
} catch (error) {
this.logger.warn({ err: error }, "OpenCode bridge request failed");
const failure = error instanceof Error ? error : new Error(String(error));
if (!response.headersSent) {
sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
if (error instanceof OpenCodeBridgeClientError) {
sendJson(response, error.status, {
error: error.message,
...(error.details === undefined ? {} : { details: error.details }),
});
} else if (error instanceof PaseoToolInputError) {
sendJson(
response,
error.status,
error.issues
? { error: "Invalid tool input", issues: error.issues }
: { error: error.message },
);
} else {
sendJson(response, 500, { error: failure.message });
}
} else {
response.destroy(error instanceof Error ? error : new Error(String(error)));
response.destroy(failure);
}
}
}
@@ -224,6 +251,9 @@ export class OpenCodeBridge {
sendJson(input.response, 403, { error: "Paseo tools are disabled for this session" });
return;
}
if (!binding.tools.getTool(input.toolName)) {
throw new OpenCodeBridgeClientError(404, `Paseo tool not found: ${input.toolName}`);
}
const body = await readJsonBody(input.request);
const controller = new AbortController();
input.request.once("aborted", () =>
@@ -349,11 +379,17 @@ async function readJsonBody(request: IncomingMessage): Promise<unknown> {
for await (const chunk of request) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
size += buffer.byteLength;
if (size > MAX_REQUEST_BYTES) throw new Error("OpenCode bridge request body is too large");
if (size > MAX_REQUEST_BYTES) {
throw new OpenCodeBridgeClientError(413, "OpenCode bridge request body is too large");
}
chunks.push(buffer);
}
if (chunks.length === 0) return {};
return JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown;
try {
return JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown;
} catch {
throw new OpenCodeBridgeClientError(400, "Malformed JSON request body");
}
}
function sendJson(response: ServerResponse, status: number, body: unknown): void {
@@ -15,12 +15,14 @@ export default {
if (allowMissing && response.status === 404) return null;
if (!response.ok) {
const payload = await response.json().catch(() => null);
const detail = formatErrorDetail(payload?.issues ?? payload?.details);
const message =
typeof payload?.error === "string" && payload.error
? payload.error
: `Paseo tool bridge returned HTTP ${response.status}`;
const error = new Error(message);
const error = new Error(detail ? `${message}: ${detail}` : message);
error.status = response.status;
if (detail) error.details = detail;
throw error;
}
return response.json();
@@ -78,6 +80,17 @@ export default {
},
};
function formatErrorDetail(details) {
if (!Array.isArray(details)) return typeof details === "string" ? details : "";
return details
.map((issue) => {
const path =
Array.isArray(issue?.path) && issue.path.length > 0 ? issue.path.join(".") : "input";
return `${path}: ${issue?.message ?? "Invalid value"}`;
})
.join("; ");
}
// OpenCode tool content is text or a file; MCP images arrive as base64 data.
function toOpenCodeContent(part) {
if (part.type === "image") {
@@ -2101,6 +2101,52 @@ describe("PiRpcAgentSession", () => {
});
});
test("fails an unfinished workflow on process exit while preserving detached runs", async () => {
const { pi, session, events } = await createSession();
onTestFinished(() => session.close());
const updates: AgentStreamEvent[] = [];
session.subscribe((event) => updates.push(event));
await session.startTurn("run workflows");
const runtime = pi.latestSession();
runtime.emit({
type: "tool_execution_start",
toolCallId: "detached",
toolName: "subagent",
args: { workflowScript: "return await runs.all([]);", async: true },
});
runtime.emit({
type: "tool_execution_end",
toolCallId: "detached",
toolName: "subagent",
result: {
details: {
mode: "workflow",
runId: "detached-run",
asyncId: "detached-run",
asyncDir: "/nonexistent/pi-exit-regression",
results: [],
},
},
});
runtime.emit({
type: "tool_execution_start",
toolCallId: "foreground",
toolName: "subagent",
args: { workflowScript: "return await runs.all([]);", async: false },
});
runtime.emit({ type: "process_exit", error: "Pi exited" });
await expect(events.nextTurnFailure()).resolves.toMatchObject({ error: "Pi exited" });
const statuses = (id: string) =>
updates.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "upsert" && event.event.id === id
? [event.event.status]
: [],
);
expect(statuses("foreground")).toEqual(["running", "failed"]);
expect(statuses("detached")).toEqual(["running", "running"]);
});
test("fails an autonomous turn when the Pi process exits before settlement", async () => {
const { pi, events } = await createSession();
const fakeSession = pi.latestSession();
@@ -2108,6 +2108,15 @@ export class PiRpcAgentSession implements AgentSession {
}
private handleProcessExit(error: string): void {
for (const [toolCallId, toolCall] of this.activeToolCalls) {
this.handleToolExecutionEnd({
type: "tool_execution_end",
toolCallId,
toolName: toolCall.toolName,
isError: true,
result: { content: [{ type: "text", text: error }] },
});
}
this.rejectAllExtensionResults(new Error(error));
this.interruptingTurn = null;
if (!this.activeTurnId && !this.activeTurnStarted) {
@@ -0,0 +1,153 @@
{
"provenance": {
"package": "pi-subagents",
"version": "0.78.0",
"sourceCommit": "reconstructed-workflow-launch",
"piVersion": "1.2.0",
"captureDate": "2026-10-17"
},
"events": [
{
"type": "tool_execution_start",
"toolCallId": "call_wf1",
"toolName": "subagent",
"args": {
"workflow": "tmp/rigor-ui-test.js",
"async": true
}
},
{
"type": "tool_execution_end",
"toolCallId": "call_wf1",
"toolName": "subagent",
"result": {
"content": [
{
"type": "text",
"text": "Run fan-out: 1/64 used, 63 remaining\nAsync workflow: [eac895df-6a71-4b4f-9f0a-2ba2b7f0f4a1]\n\nThe async run is detached and running in the background.\nMission: 3d6d9bb6-1ea3-4b8d-9c56-6a1f0e3b6f88 (active)"
}
],
"details": {
"mode": "workflow",
"runId": "eac895df-6a71-4b4f-9f0a-2ba2b7f0f4a1",
"results": [],
"asyncId": "eac895df-6a71-4b4f-9f0a-2ba2b7f0f4a1",
"asyncDir": "/tmp/pi-subagents-uid-1000/async-subagent-runs/eac895df-6a71-4b4f-9f0a-2ba2b7f0f4a1",
"workflowGraph": {
"lanes": [{ "key": "alpha", "agent": "scout" }]
},
"context": "fresh",
"timeoutMs": 1800000,
"deadlineAt": 1790199499998
}
},
"isError": false
},
{
"type": "message_end",
"message": {
"role": "custom",
"customType": "subagent-incremental-child-notify",
"content": "Workflow child completed: **alpha**\nWorkflow run: eac895df-6a71-4b4f-9f0a-2ba2b7f0f4a1\nChild run: 4cb1c0a2-2f2e-4c31-9c53-0f0d2ad0f3f1\nOutput: /tmp/paseo-pi-extension-phase3/workspace/tmp/alpha.md\nStatus: workflow still running",
"display": false,
"details": {
"childKey": "alpha",
"outcome": "completed"
},
"timestamp": 1790197711046
}
},
{
"type": "message_end",
"message": {
"role": "custom",
"customType": "subagent-notify",
"content": "Background task completed: **workflow**\n\nWorkflow receipt: /tmp/pi-subagents-uid-1000/async-subagent-runs/eac895df-6a71-4b4f-9f0a-2ba2b7f0f4a1/receipt.json\n\nRetention-managed async directory: /tmp/pi-subagents-uid-1000/async-subagent-runs/eac895df-6a71-4b4f-9f0a-2ba2b7f0f4a1\n\nSession file: /tmp/paseo-pi-extension-phase3/sessions-nico/2026-10-17T21-08-13-722Z_01a0d018/0f2c1b1e-9d1c-4b0e-8d5b-8e6f27a1c2d3/run-0/session.jsonl",
"display": false,
"timestamp": 1790197722046
}
}
],
"messages": [
{
"role": "assistant",
"content": [
{
"type": "toolCall",
"id": "call_wf1",
"name": "subagent",
"arguments": {
"workflow": "tmp/rigor-ui-test.js",
"async": true
}
}
],
"api": "openai-completions",
"provider": "openrouter",
"model": "google/gemini-3.6-flash",
"usage": {
"input": 3303,
"output": 114,
"cacheRead": 7053,
"cacheWrite": 0,
"reasoning": 81,
"totalTokens": 10470,
"cost": {
"input": 0.00247725,
"output": 0.00042750000000000004,
"cacheRead": 0.000528975,
"cacheWrite": 0,
"total": 0.003433725
}
},
"stopReason": "toolUse",
"timestamp": 1790197697608,
"responseId": "gen-1790197697-lxluaTrDkdaxjSXsO957",
"rawStopReason": "tool_calls"
},
{
"role": "toolResult",
"toolCallId": "call_wf1",
"toolName": "subagent",
"content": [
{
"type": "text",
"text": "Run fan-out: 1/64 used, 63 remaining\nAsync workflow: [eac895df-6a71-4b4f-9f0a-2ba2b7f0f4a1]\n\nThe async run is detached and running in the background.\nMission: 3d6d9bb6-1ea3-4b8d-9c56-6a1f0e3b6f88 (active)"
}
],
"details": {
"mode": "workflow",
"runId": "eac895df-6a71-4b4f-9f0a-2ba2b7f0f4a1",
"results": [],
"asyncId": "eac895df-6a71-4b4f-9f0a-2ba2b7f0f4a1",
"asyncDir": "/tmp/pi-subagents-uid-1000/async-subagent-runs/eac895df-6a71-4b4f-9f0a-2ba2b7f0f4a1",
"workflowGraph": {
"lanes": [{ "key": "alpha", "agent": "scout" }]
},
"context": "fresh",
"timeoutMs": 1800000,
"deadlineAt": 1790199499998
},
"isError": false,
"timestamp": 1790197700011
},
{
"role": "custom",
"customType": "subagent-incremental-child-notify",
"content": "Workflow child completed: **alpha**\nWorkflow run: eac895df-6a71-4b4f-9f0a-2ba2b7f0f4a1\nChild run: 4cb1c0a2-2f2e-4c31-9c53-0f0d2ad0f3f1\nOutput: /tmp/paseo-pi-extension-phase3/workspace/tmp/alpha.md\nStatus: workflow still running",
"display": false,
"details": {
"childKey": "alpha",
"outcome": "completed"
},
"timestamp": 1790197711046
},
{
"role": "custom",
"customType": "subagent-notify",
"content": "Background task completed: **workflow**\n\nWorkflow receipt: /tmp/pi-subagents-uid-1000/async-subagent-runs/eac895df-6a71-4b4f-9f0a-2ba2b7f0f4a1/receipt.json\n\nRetention-managed async directory: /tmp/pi-subagents-uid-1000/async-subagent-runs/eac895df-6a71-4b4f-9f0a-2ba2b7f0f4a1\n\nSession file: /tmp/paseo-pi-extension-phase3/sessions-nico/2026-10-17T21-08-13-722Z_01a0d018/0f2c1b1e-9d1c-4b0e-8d5b-8e6f27a1c2d3/run-0/session.jsonl",
"display": false,
"timestamp": 1790197722046
}
]
}
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { fileURLToPath } from "node:url";
import { mkdtemp, writeFile, rm } from "node:fs/promises";
import { mkdtemp, writeFile, appendFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createPiExtensionHost } from "../index.js";
@@ -45,6 +45,18 @@ function hasCompletedChild(events: AgentStreamEvent[]) {
);
}
function subagentUpserts(events: AgentStreamEvent[]) {
return events.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "upsert" ? [event.event] : [],
);
}
function reportedUpsert(events: AgentStreamEvent[], id: string, status?: string) {
return subagentUpserts(events).some(
(event) => event.id === id && (status === undefined || event.status === status),
);
}
describe("pi-subagents adapter", () => {
test("follows async status and transcript without bg_wait", async () => {
const dir = await mkdtemp(join(tmpdir(), "paseo-pi-async-"));
@@ -273,4 +285,858 @@ describe("pi-subagents adapter", () => {
},
]);
});
test("maps a reconstructed workflowScript run to a live spawn row and its children", async () => {
const fixture = readSubagentFixture(new URL("./fixtures/workflow.json", import.meta.url));
const events = await verifySubagentFixture(fixture);
const upserts = events
.filter((event) => event.event.type === "upsert")
.map((event) => (event.event.type === "upsert" ? event.event : null));
expect(upserts.map((event) => [event?.id, event?.status])).toEqual([
["call_wf1", "running"],
["call_wf1", "running"],
["call_wf1:alpha", "completed"],
["call_wf1", "completed"],
]);
// The workflow call carries no agent/task, so the script file names the run.
expect(upserts[0]?.title).toBe("rigor-ui-test.js");
expect(upserts[2]?.title).toBeUndefined();
});
test("maps a reported workflowScript run to its reviewer row and saved timeline", async () => {
// Reported in #5837: `subagent({ workflow: true })` reaches the adapter as `workflowScript`,
// not `workflow`, so a launch check keyed on `workflow` alone drops the whole run.
const dir = await mkdtemp(join(tmpdir(), "paseo-pi-workflow-script-"));
const host = createPiExtensionHost();
try {
const sessionFile = join(dir, "child.jsonl");
await writeFile(
sessionFile,
JSON.stringify({
type: "message",
message: { role: "assistant", content: [{ type: "text", text: "README summary" }] },
}) + "\n",
);
const args = {
workflowScript:
"return await runs.run('review', { agent: 'reviewer', task: 'Read README.md' });",
async: false,
};
expect(
host.mapToolCall({
callId: "reported-workflow",
toolName: "subagent",
args,
status: "running",
result: null,
})?.subagents,
).toEqual([
expect.objectContaining({ id: "reported-workflow", title: "workflow", status: "running" }),
]);
const output = host.mapToolCall({
callId: "reported-workflow",
toolName: "subagent",
args,
status: "completed",
result: {
details: {
mode: "workflow",
runId: "reported-run",
results: [{ index: 0, agent: "reviewer", exitCode: 0, sessionFile }],
},
},
});
expect(output?.subagents).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "upsert", title: "reviewer", status: "completed" }),
]),
);
expect(hasChildTimeline(await output!.hydration)).toBe(true);
} finally {
host.close();
await rm(dir, { recursive: true, force: true });
}
});
test("keys workflow child rows by their childKey while the run reports progress", async () => {
const dir = await mkdtemp(join(tmpdir(), "paseo-pi-workflow-"));
const received: AgentStreamEvent[] = [];
const host = createPiExtensionHost();
try {
host.follow((event) => received.push(event));
host.mapToolCall({
callId: "call-wf",
toolName: "subagent",
args: { workflow: "tmp/rigor-ui-test.js", async: true },
status: "completed",
result: {
details: {
mode: "workflow",
runId: "run-wf",
asyncId: "run-wf",
asyncDir: dir,
results: [],
},
},
});
await writeFile(
join(dir, "status.json"),
JSON.stringify({
state: "running",
steps: [
{ agent: "scout", workflowKey: "alpha", status: "complete" },
{ agent: "reviewer", workflowKey: "beta", status: "running" },
],
}),
);
await expect.poll(() => reportedUpsert(received, "call-wf:beta")).toBe(true);
expect(subagentUpserts(received).map((event) => [event.id, event.status])).toEqual(
expect.arrayContaining([
["call-wf:alpha", "running"],
["call-wf:beta", "running"],
["call-wf", "running"],
]),
);
await writeFile(
join(dir, "status.json"),
JSON.stringify({
state: "complete",
steps: [
{ agent: "scout", workflowKey: "alpha", status: "complete" },
{ agent: "reviewer", workflowKey: "beta", status: "complete" },
],
}),
);
await expect.poll(() => reportedUpsert(received, "call-wf", "completed")).toBe(true);
expect(
subagentUpserts(received).findLast((event) => event.id === "call-wf:alpha")?.status,
).toBe("completed");
} finally {
host.close();
await rm(dir, { recursive: true, force: true });
}
});
test("keeps a missing status directory's row alive until the miss budget expires", async () => {
vi.useFakeTimers();
const received: AgentStreamEvent[] = [];
const host = createPiExtensionHost();
try {
host.follow((event) => received.push(event));
host.mapToolCall({
callId: "call-lost",
toolName: "subagent",
args: { agent: "scout", task: "Inspect", async: true },
status: "completed",
result: {
details: {
mode: "single",
runId: "run-1",
asyncId: "run-1",
asyncDir: "/nonexistent",
results: [],
},
},
});
await vi.advanceTimersByTimeAsync(250 * 5);
// A missing status file supplies no new outcome before the miss budget expires.
const duringMisses = subagentUpserts(received);
expect(duringMisses).toEqual([]);
// 60 s of missing ticks, the budget before a status directory counts as gone.
await vi.advanceTimersByTimeAsync(250 * 236);
// The launch row comes back through the caller; polling reports the give-up exactly once.
const afterGiveUp = subagentUpserts(received);
expect(afterGiveUp.findLast((event) => event.id === "call-lost")).toEqual({
type: "upsert",
id: "call-lost",
status: "canceled",
});
expect(afterGiveUp.filter((event) => event.status === "canceled")).toHaveLength(1);
} finally {
host.close();
vi.useRealTimers();
}
});
test("a completion notice stops polling so a stale status file cannot revert the card", async () => {
const dir = await mkdtemp(join(tmpdir(), "paseo-pi-stale-"));
const received: AgentStreamEvent[] = [];
const host = createPiExtensionHost();
try {
host.follow((event) => received.push(event));
host.mapToolCall({
callId: "call-1",
toolName: "subagent",
args: { agent: "scout", task: "Inspect", async: true },
status: "completed",
result: {
details: { mode: "single", runId: "run-1", asyncId: "run-1", asyncDir: dir, results: [] },
},
});
await writeFile(
join(dir, "status.json"),
JSON.stringify({ state: "running", steps: [{ agent: "scout", status: "running" }] }),
);
const statuses = () =>
received.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "upsert"
? [event.event.status]
: [],
);
await expect.poll(() => statuses().length).toBeGreaterThan(1);
host.mapCustomMessage({
role: "custom",
customType: "subagent-notify",
content: [
"Background task completed: **scout**",
"",
`Retention-managed async directory: ${dir}`,
].join("\n"),
});
const settled = statuses().length;
// The run's directory is still on disk with a running status; polling must stay off.
await new Promise((resolve) => setTimeout(resolve, 400));
expect(statuses().length).toBe(settled);
} finally {
host.close();
await rm(dir, { recursive: true, force: true });
}
});
test("a bg_wait result keeps its verdict while the run's status names no child yet", async () => {
vi.useFakeTimers();
const dir = await mkdtemp(join(tmpdir(), "paseo-pi-settled-owner-"));
const received: AgentStreamEvent[] = [];
const host = createPiExtensionHost();
try {
host.follow((event) => received.push(event));
host.mapToolCall({
callId: "call-1",
toolName: "subagent",
args: { agent: "scout", task: "Inspect", async: true },
status: "completed",
result: {
details: { mode: "single", runId: "run-1", asyncId: "run-1", asyncDir: dir, results: [] },
},
});
// A single unnamed child takes the owner's id, so this result settles the launch row while the
// run itself keeps polling.
expect(
host.mapToolCall({
callId: "wait-1",
toolName: "bg_wait",
args: { runId: "run-1" },
status: "completed",
result: {
details: {
mode: "single",
results: [],
completions: [
{ runId: "run-1", results: [{ agent: "scout", success: true, exitCode: 0 }] },
],
},
},
})?.subagents,
).toEqual([expect.objectContaining({ id: "call-1", status: "completed" })]);
// The queue has not started the child yet, so the status file names no step: that tick must
// not re-report the owner as running over the verdict it already has.
await writeFile(join(dir, "status.json"), JSON.stringify({ state: "running", steps: [] }));
const beforeTick = received.length;
await vi.advanceTimersByTimeAsync(250 * 2);
expect(subagentUpserts(received.slice(beforeTick))).toEqual([]);
} finally {
host.close();
vi.useRealTimers();
await rm(dir, { recursive: true, force: true });
}
});
test("a paused run settles as canceled", async () => {
const dir = await mkdtemp(join(tmpdir(), "paseo-pi-paused-"));
const host = createPiExtensionHost();
try {
host.mapToolCall({
callId: "call-1",
toolName: "subagent",
args: { agent: "scout", task: "Inspect", async: true },
status: "completed",
result: {
details: { mode: "single", runId: "run-1", asyncId: "run-1", asyncDir: dir, results: [] },
},
});
expect(
host.mapCustomMessage({
role: "custom",
customType: "subagent-notify",
content: [
"Background task paused: **scout**",
"",
`Retention-managed async directory: ${dir}`,
].join("\n"),
})?.subagents,
).toEqual([{ type: "upsert", id: "call-1", status: "canceled" }]);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("a paused workflow child notice settles its row as canceled", () => {
const host = createPiExtensionHost();
host.mapToolCall({
callId: "call-wf",
toolName: "subagent",
args: { workflow: "tmp/rigor-ui-test.js", async: true },
status: "completed",
result: {
details: {
mode: "workflow",
runId: "run-wf",
asyncId: "run-wf",
asyncDir: "/nonexistent",
results: [],
},
},
});
expect(
host.mapCustomMessage({
role: "custom",
customType: "subagent-incremental-child-notify",
content: "Workflow child paused (needs attention): **alpha**\nWorkflow run: run-wf",
})?.subagents,
).toEqual([{ type: "upsert", id: "call-wf:alpha", status: "canceled" }]);
});
test("partial and rejected runs settle every child instead of leaving a spinner", async () => {
for (const [state, expected] of [
["partial", ["completed", "completed", "canceled"]],
["rejected", ["completed", "completed", "failed"]],
] as const) {
vi.useFakeTimers();
const dir = await mkdtemp(join(tmpdir(), "paseo-pi-partial-"));
const received: AgentStreamEvent[] = [];
const host = createPiExtensionHost();
try {
host.mapToolCall({
callId: "call-wf",
toolName: "subagent",
args: { workflow: "tmp/rigor-ui-test.js", async: true },
status: "completed",
result: {
details: {
mode: "workflow",
runId: "run-wf",
asyncId: "run-wf",
asyncDir: dir,
results: [],
},
},
});
await writeFile(
join(dir, "status.json"),
JSON.stringify({
state,
steps: [
{ agent: "scout", workflowKey: "alpha", status: "complete" },
// WorkflowNodeStatus spells the same outcome `completed`.
{ agent: "auditor", workflowKey: "gamma", status: "completed" },
{
agent: "reviewer",
workflowKey: "beta",
status: state === "partial" ? "running" : "failed",
},
],
}),
);
host.follow((event) => received.push(event));
await vi.advanceTimersByTimeAsync(250);
expect(subagentUpserts(received).map((event) => [event.id, event.status])).toEqual(
expect.arrayContaining([
["call-wf:alpha", expected[0]],
["call-wf:gamma", expected[1]],
["call-wf:beta", expected[2]],
["call-wf", "canceled"],
]),
);
expect(
subagentUpserts(received).filter((event) => event.status === "running"),
).toHaveLength(0);
// The run is terminal, so the poll stops instead of re-reporting fresh rows.
const settled = received.length;
await vi.advanceTimersByTimeAsync(250 * 4);
expect(received.length).toBe(settled);
} finally {
host.close();
vi.useRealTimers();
await rm(dir, { recursive: true, force: true });
}
}
});
test("a child notice keeps its status while the run is still reporting", async () => {
vi.useFakeTimers();
const dir = await mkdtemp(join(tmpdir(), "paseo-pi-notice-pin-"));
const received: AgentStreamEvent[] = [];
const host = createPiExtensionHost();
try {
host.mapToolCall({
callId: "call-wf",
toolName: "subagent",
args: { workflow: "tmp/rigor-ui-test.js", async: true },
status: "completed",
result: {
details: {
mode: "workflow",
runId: "run-wf",
asyncId: "run-wf",
asyncDir: dir,
results: [],
},
},
});
// The stale step entry still says running; the child's notice is its verdict.
await writeFile(
join(dir, "status.json"),
JSON.stringify({
state: "running",
steps: [
{ agent: "scout", workflowKey: "alpha", status: "complete" },
{ agent: "reviewer", workflowKey: "beta", status: "running" },
],
}),
);
host.follow((event) => received.push(event));
await vi.advanceTimersByTimeAsync(250);
expect(
host.mapCustomMessage({
role: "custom",
customType: "subagent-incremental-child-notify",
content: "Workflow child completed: **alpha**\nWorkflow run: run-wf",
})?.subagents,
).toEqual([{ type: "upsert", id: "call-wf:alpha", status: "completed" }]);
const beforeNotice = received.length;
await vi.advanceTimersByTimeAsync(250 * 3);
expect(
subagentUpserts(received.slice(beforeNotice)).filter(
(event) => event.id === "call-wf:alpha",
),
).toEqual([]);
// The rest of the run keeps re-reporting, so this is not a poll that simply stopped.
expect(reportedUpsert(received.slice(beforeNotice), "call-wf:beta")).toBe(true);
} finally {
host.close();
vi.useRealTimers();
await rm(dir, { recursive: true, force: true });
}
});
test("a child notice's verdict survives the run's terminal tick", async () => {
vi.useFakeTimers();
const dir = await mkdtemp(join(tmpdir(), "paseo-pi-notice-terminal-"));
const received: AgentStreamEvent[] = [];
const host = createPiExtensionHost();
try {
host.mapToolCall({
callId: "call-wf",
toolName: "subagent",
args: { workflow: "tmp/rigor-ui-test.js", async: true },
status: "completed",
result: {
details: {
mode: "workflow",
runId: "run-wf",
asyncId: "run-wf",
asyncDir: dir,
results: [],
},
},
});
host.follow((event) => received.push(event));
// The child's notice settles it before the run itself ends.
expect(
host.mapCustomMessage({
role: "custom",
customType: "subagent-incremental-child-notify",
content: "Workflow child completed: **alpha**\nWorkflow run: run-wf",
})?.subagents,
).toEqual([{ type: "upsert", id: "call-wf:alpha", status: "completed" }]);
// The run then ends without a verdict for alpha: its step entry still says running, which the
// terminal tick would read as canceled if the notice were not the child's verdict.
await writeFile(
join(dir, "status.json"),
JSON.stringify({
state: "stopped",
steps: [
{ agent: "scout", workflowKey: "alpha", status: "running" },
{ agent: "reviewer", workflowKey: "beta", status: "stopped" },
],
}),
);
await vi.advanceTimersByTimeAsync(250);
expect(
subagentUpserts(received).filter((event) => event.id === "call-wf:alpha" && event.status),
).toEqual([expect.objectContaining({ id: "call-wf:alpha", status: "completed" })]);
// The child that never reported a verdict of its own is still settled by that tick.
expect(
subagentUpserts(received).findLast((event) => event.id === "call-wf:beta")?.status,
).toBe("canceled");
} finally {
host.close();
vi.useRealTimers();
await rm(dir, { recursive: true, force: true });
}
});
test("gives up without rewriting a child that already settled", async () => {
vi.useFakeTimers();
const dir = await mkdtemp(join(tmpdir(), "paseo-pi-giveup-"));
const received: AgentStreamEvent[] = [];
const host = createPiExtensionHost();
try {
host.mapToolCall({
callId: "call-wf",
toolName: "subagent",
args: { workflow: "tmp/rigor-ui-test.js", async: true },
status: "completed",
result: {
details: {
mode: "workflow",
runId: "run-wf",
asyncId: "run-wf",
asyncDir: dir,
results: [],
},
},
});
await writeFile(
join(dir, "status.json"),
JSON.stringify({
state: "running",
steps: [
{ agent: "scout", workflowKey: "alpha", status: "complete" },
{ agent: "reviewer", workflowKey: "beta", status: "running" },
],
}),
);
host.follow((event) => received.push(event));
await vi.advanceTimersByTimeAsync(250);
expect(reportedUpsert(received, "call-wf:beta")).toBe(true);
// The workflow keeps running, so polling continues after the child's own notice.
expect(
host.mapCustomMessage({
role: "custom",
customType: "subagent-incremental-child-notify",
content: "Workflow child completed: **alpha**\nWorkflow run: run-wf",
})?.subagents,
).toEqual([{ type: "upsert", id: "call-wf:alpha", status: "completed" }]);
await rm(join(dir, "status.json"));
await vi.advanceTimersByTimeAsync(250 * 241);
expect(
subagentUpserts(received).some(
(event) => event.id === "call-wf:alpha" && event.status === "canceled",
),
).toBe(false);
expect(
subagentUpserts(received).findLast((event) => event.id === "call-wf:beta")?.status,
).toBe("canceled");
} finally {
host.close();
vi.useRealTimers();
await rm(dir, { recursive: true, force: true });
}
});
test("a run's final notice cancels the children it stopped with", async () => {
vi.useFakeTimers();
const dir = await mkdtemp(join(tmpdir(), "paseo-pi-stopped-children-"));
const received: AgentStreamEvent[] = [];
const host = createPiExtensionHost();
try {
host.mapToolCall({
callId: "call-wf",
toolName: "subagent",
args: { workflow: "tmp/rigor-ui-test.js", async: true },
status: "completed",
result: {
details: {
mode: "workflow",
runId: "run-wf",
asyncId: "run-wf",
asyncDir: dir,
results: [],
},
},
});
await writeFile(
join(dir, "status.json"),
JSON.stringify({
state: "running",
steps: [
{ agent: "scout", workflowKey: "alpha", status: "running" },
{ agent: "reviewer", workflowKey: "beta", status: "running" },
],
}),
);
host.follow((event) => received.push(event));
await vi.advanceTimersByTimeAsync(250);
expect(reportedUpsert(received, "call-wf:beta")).toBe(true);
// alpha reports its own verdict before the run stops.
expect(
host.mapCustomMessage({
role: "custom",
customType: "subagent-incremental-child-notify",
content: "Workflow child completed: **alpha**\nWorkflow run: run-wf",
})?.subagents,
).toEqual([{ type: "upsert", id: "call-wf:alpha", status: "completed" }]);
// The run then stops with beta unfinished. The notice ends the poll, so no later tick will
// ever report beta: it settles as canceled, never completed. alpha keeps its own verdict.
expect(
host.mapCustomMessage({
role: "custom",
customType: "subagent-notify",
content: [
"Background task stopped: **rigor-ui-test.js**",
"",
`Retention-managed async directory: ${dir}`,
].join("\n"),
})?.subagents,
).toEqual([
{ type: "upsert", id: "call-wf", status: "canceled" },
{ type: "upsert", id: "call-wf:beta", status: "canceled" },
]);
// Polling is off; the status file still says running and must not bring the rows back.
const settled = received.length;
await vi.advanceTimersByTimeAsync(250 * 3);
expect(received.length).toBe(settled);
} finally {
host.close();
vi.useRealTimers();
await rm(dir, { recursive: true, force: true });
}
});
test("names a child a notice settled before the first poll saw it", async () => {
vi.useFakeTimers();
const dir = await mkdtemp(join(tmpdir(), "paseo-pi-fast-child-"));
const received: AgentStreamEvent[] = [];
const host = createPiExtensionHost();
try {
host.mapToolCall({
callId: "call-wf",
toolName: "subagent",
args: { workflow: "tmp/rigor-ui-test.js", async: true },
status: "completed",
result: {
details: {
mode: "workflow",
runId: "run-wf",
asyncId: "run-wf",
asyncDir: dir,
results: [],
},
},
});
// A fast child reports before any poll tick, so its row is born without a name.
expect(
host.mapCustomMessage({
role: "custom",
customType: "subagent-incremental-child-notify",
content: "Workflow child completed: **alpha**\nWorkflow run: run-wf",
})?.subagents,
).toEqual([{ type: "upsert", id: "call-wf:alpha", status: "completed" }]);
await writeFile(
join(dir, "status.json"),
JSON.stringify({
state: "running",
steps: [{ agent: "scout", workflowKey: "alpha", status: "running" }],
}),
);
host.follow((event) => received.push(event));
await vi.advanceTimersByTimeAsync(250);
// The poll leaves the settled status alone but still fills the name status.json carries.
const alphaUpserts = () =>
subagentUpserts(received).filter((event) => event.id === "call-wf:alpha");
expect(alphaUpserts()).toEqual([
{
type: "upsert",
id: "call-wf:alpha",
title: "scout",
status: "completed",
},
]);
// Reported once, not on every tick.
await vi.advanceTimersByTimeAsync(250 * 2);
expect(alphaUpserts()).toHaveLength(1);
} finally {
host.close();
vi.useRealTimers();
await rm(dir, { recursive: true, force: true });
}
});
test("a workflow that reported rows replaces its placeholder with them", () => {
const host = createPiExtensionHost();
const args = { workflow: true };
// The launch placeholder is replaced once the workflow supplies its child rows.
expect(
host.mapToolCall({
callId: "fanout",
toolName: "subagent",
args,
status: "running",
result: null,
})?.subagents,
).toEqual([expect.objectContaining({ id: "fanout", status: "running" })]);
expect(
host.mapToolCall({
callId: "fanout",
toolName: "subagent",
args,
status: "completed",
result: {
details: {
mode: "workflow",
results: [
{ index: 0, agent: "scout", workflowKey: "alpha", success: true, exitCode: 0 },
{ index: 1, agent: "reviewer", workflowKey: "beta", success: true, exitCode: 0 },
],
},
},
})?.subagents,
).toEqual([
{ type: "remove", id: "fanout" },
expect.objectContaining({ id: "fanout:alpha", title: "scout", status: "completed" }),
expect.objectContaining({ id: "fanout:beta", title: "reviewer", status: "completed" }),
]);
});
test("replays keyed async workflow child transcripts without a live poll", async () => {
const dir = await mkdtemp(join(tmpdir(), "paseo-pi-workflow-replay-"));
const host = createPiExtensionHost();
try {
const sessionFile = join(dir, "child.jsonl");
await writeFile(
sessionFile,
JSON.stringify({
type: "message",
message: {
role: "assistant",
content: [{ type: "text", text: "Saved reviewer answer" }],
},
}) + "\n",
);
await writeFile(
join(dir, "status.json"),
JSON.stringify({
state: "complete",
steps: [{ agent: "reviewer", workflowKey: "review", status: "complete", sessionFile }],
}),
);
const output = host.mapToolCall({
callId: "replayed-workflow",
toolName: "subagent",
args: { workflowScript: "return await runs.all(items);", async: true },
status: "completed",
result: {
details: {
mode: "workflow",
runId: "saved-run",
asyncId: "saved-run",
asyncDir: dir,
results: [],
},
},
});
const events = await output!.hydration;
expect(
events.some(
(event) =>
event.type === "provider_subagent" &&
event.event.type === "timeline" &&
event.event.id === "replayed-workflow:review",
),
).toBe(true);
} finally {
host.close();
await rm(dir, { recursive: true, force: true });
}
});
test("stops following a child whose completion arrived before its transcript was attached", async () => {
const dir = await mkdtemp(join(tmpdir(), "paseo-pi-finished-reader-"));
const host = createPiExtensionHost();
const received: AgentStreamEvent[] = [];
try {
const sessionFile = join(dir, "child.jsonl");
const message = (text: string) =>
JSON.stringify({
type: "message",
message: {
role: "assistant",
content: [{ type: "text", text }],
},
}) + "\n";
await writeFile(sessionFile, message("Finished child"));
host.follow((event) => received.push(event));
const spawn = host.mapToolCall({
callId: "fast-workflow",
toolName: "subagent",
args: { workflow: true, async: true },
status: "completed",
result: {
details: {
mode: "workflow",
runId: "fast-run",
asyncId: "fast-run",
asyncDir: dir,
results: [],
},
},
});
await spawn!.hydration;
const notice = host.mapCustomMessage({
role: "custom",
customType: "subagent-incremental-child-notify",
content: "Workflow child completed: **review**\nWorkflow run: fast-run",
});
await notice!.hydration;
await writeFile(
join(dir, "status.json"),
JSON.stringify({
state: "running",
steps: [{ agent: "reviewer", workflowKey: "review", status: "running", sessionFile }],
}),
);
await expect.poll(() => hasChildTimeline(received)).toBe(true);
const timelineCount = () =>
received.filter(
(event) => event.type === "provider_subagent" && event.event.type === "timeline",
).length;
const count = timelineCount();
await appendFile(sessionFile, message("Later content must not be followed"));
await new Promise((resolve) => setTimeout(resolve, 600));
expect(timelineCount()).toBe(count);
} finally {
host.close();
await rm(dir, { recursive: true, force: true });
}
});
});
@@ -2,12 +2,20 @@ import { z } from "zod";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { extractTextFromToolResult } from "../../tool-call-mapper.js";
import type { PiAgentMessage } from "../../rpc-types.js";
import type { ProviderSubagentInputEvent } from "../../../../provider-subagents/store.js";
import type { PiExtension, PiExtensionToolCall, PiExtensionToolMapping } from "../contract.js";
type CustomMessage = Extract<PiAgentMessage, { role: "custom" }>;
type SubagentUpsert = Extract<ProviderSubagentInputEvent, { type: "upsert" }>;
const Args = z
.object({
agent: z.string().trim().min(1).optional(),
task: z.string().trim().min(1).optional(),
workflow: z.union([z.string().trim().min(1), z.literal(true)]).optional(),
// `subagent({ workflow: true })` launches the reply's script; Pi delivers it as this field.
workflowScript: z.string().trim().min(1).optional(),
async: z.boolean().optional(),
action: z.unknown().optional(),
})
@@ -16,6 +24,7 @@ const Row = z
.object({
index: z.number().int().nonnegative().optional(),
agent: z.string().trim().min(1),
workflowKey: z.string().trim().min(1).optional(),
exitCode: z.number().optional(),
success: z.boolean().optional(),
sessionFile: z.string().trim().min(1).optional(),
@@ -41,6 +50,7 @@ const AsyncStatus = z
z
.object({
agent: z.string().trim().min(1),
workflowKey: z.string().trim().min(1).optional(),
status: z.string().optional(),
sessionFile: z.string().trim().min(1).optional(),
})
@@ -49,21 +59,116 @@ const AsyncStatus = z
})
.passthrough();
/**
* Run states that will not change again: `paused` waits on the user, and `partial`/`rejected`
* still end the run even when some steps never produced a result of their own.
*/
const TERMINAL_RUN_STATES = new Set([
"complete",
"failed",
"stopped",
"paused",
"partial",
"rejected",
]);
/** The run's status file, or undefined while it is missing or not yet readable. */
function readAsyncStatus(dir: string): z.infer<typeof AsyncStatus> | undefined {
try {
const parsed = AsyncStatus.safeParse(
JSON.parse(readFileSync(join(dir, "status.json"), "utf8")),
);
return parsed.success ? parsed.data : undefined;
} catch {
return undefined;
}
}
/** One async run the poll follows, keyed by its async directory. */
interface AsyncRun {
owner: string;
polling: boolean;
misses: number;
deadline: number;
rows: Set<string>;
}
/**
* Missing ticks before a status directory counts as gone: 60 s at the 250 ms follow cadence. A live
* run can briefly lose its file while the concurrency queue starts it or retention rotates it.
*/
const POLL_MISS_LIMIT = 240;
/**
* A run that never reaches a terminal state in its status file must not keep a card spinning
* forever. pi-subagents' own default run timeout is 30 minutes; this is the backstop.
*/
const POLL_TTL_MS = 6 * 60 * 60 * 1000;
function asyncChildStatus(runState: string, stepStatus?: string) {
if (!["complete", "failed", "stopped"].includes(runState)) return "running";
if (runState === "stopped" || stepStatus === "stopped") return "canceled";
if (!TERMINAL_RUN_STATES.has(runState)) return "running";
// A partial or rejected run says nothing about its individual children: read their own outcome,
// and treat a step that never reached one as canceled rather than still running.
if (runState === "partial" || runState === "rejected") {
// Two vocabularies: status.json steps say `complete`, WorkflowNodeStatus says `completed`.
if (stepStatus === "complete" || stepStatus === "completed") return "completed";
if (stepStatus === "failed" || stepStatus === "error") return "failed";
return "canceled";
}
if (runState === "stopped" || runState === "paused" || stepStatus === "stopped")
return "canceled";
if (runState === "failed" || stepStatus === "failed" || stepStatus === "error") return "failed";
return "completed";
}
/** The `subagent-notify` header; a grouped notice reports every run as completed. */
/** The status of the run itself, used for the row a launch opened before its children reported. */
function asyncRunStatus(runState: string) {
if (runState === "complete") return "completed" as const;
if (runState === "failed") return "failed" as const;
return "canceled" as const;
}
/**
* The `subagent-notify` / `subagent-incremental-child-notify` header. A grouped notice reports
* every run as completed; a paused run is waiting on the user, which the track shows as canceled.
*/
function noticeStatus(header: string) {
if (/^Background tasks completed \(\d+\):/.test(header)) return "completed";
const outcome = header.match(
/^(?:Background task|Detached foreground task) (completed|failed|stopped): \*\*/,
/^(?:Background task|Detached foreground task|Workflow child) (completed|failed|paused|stopped)(?: \([^)]*\))?: \*\*/,
)?.[1];
if (outcome === "stopped") return "canceled";
return outcome as "completed" | "failed" | undefined;
if (!outcome) return undefined;
return outcome === "paused" || outcome === "stopped"
? ("canceled" as const)
: (outcome as "completed" | "failed");
}
/**
* A workflow launch carries no `agent`/`task`: the children are declared by the script. Its label
* is the script file name, or the bare `workflow` of the reply-block form.
*/
function workflowLabel(workflow: string | true | undefined) {
if (workflow === undefined) return undefined;
if (workflow === true) return "workflow";
const segments = workflow.split(/[\\/]/).filter(Boolean);
return segments[segments.length - 1] ?? workflow;
}
/**
* A workflow launch carries no agent/task; its children are declared by the script. A reply-scoped
* script arrives as `workflowScript` instead of a `workflow` path, so it takes the generic label.
*/
function workflowTitle(args: { workflow?: string | true; workflowScript?: string }) {
const label = workflowLabel(args.workflow);
if (label !== undefined) return label;
return args.workflowScript === undefined ? undefined : "workflow";
}
/**
* A row keeps one id from launch to settle: the owner alone for a single unnamed child, otherwise
* `owner:key`. Workflow children carry their `workflowKey`, so progress updates, child notices and
* final results all land on the same row.
*/
function rowId(owner: string, count: number, key: string | undefined, index: number) {
return count === 1 && !key ? owner : `${owner}:${key ?? index}`;
}
export const piSubagents: PiExtension = {
@@ -71,13 +176,64 @@ export const piSubagents: PiExtension = {
createSession: () => {
const callsByRun = new Map<string, string>();
const readSessions = new Set<string>();
/** Rows already reported terminal; a late give-up must not rewrite one of them. */
const settledRows = new Map<string, NonNullable<SubagentUpsert["status"]>>();
/** Rows whose poll already reported the step's agent. A settled row is still named once. */
const namedRows = new Set<string>();
const recordTerminal = (id: string, status: NonNullable<SubagentUpsert["status"]>) => {
if (status !== "running") settledRows.set(id, status);
};
/** Keyed by async directory, which both status.json and the completion notice carry. */
const asyncRuns = new Map<string, { owner: string; polling: boolean }>();
const asyncRuns = new Map<string, AsyncRun>();
const rememberAsyncRun = (details: z.infer<typeof Details>, call: PiExtensionToolCall) => {
if (details.asyncDir && call.status !== "failed") {
asyncRuns.set(details.asyncDir, { owner: call.callId, polling: true });
asyncRuns.set(details.asyncDir, {
owner: call.callId,
polling: true,
misses: 0,
deadline: Date.now() + POLL_TTL_MS,
rows: new Set<string>(),
});
}
};
/**
* The run is over and nothing will ever update its rows again, so settle them rather than leave a
* spinner. A row that already reported a terminal status keeps it: this is not its verdict.
*/
const giveUpRun = (run: AsyncRun) => {
run.polling = false;
const subagents: NonNullable<PiExtensionToolMapping["subagents"]> = [];
for (const id of new Set([run.owner, ...run.rows])) {
if (settledRows.has(id)) continue;
subagents.push({ type: "upsert", id, status: "canceled" });
}
return subagents;
};
/**
* The notice is the run's last word: it ends the poll, settles the launch row, and cancels the
* children the run stopped with. No later tick will report those, and nothing here proves they
* succeeded, so they settle as canceled. A child that already reported its own verdict keeps it.
*/
const settleRunNotice = (run: AsyncRun, status: "completed" | "failed" | "canceled") => {
// A stale status.json must not revert this card either.
run.polling = false;
recordTerminal(run.owner, status);
const subagents: NonNullable<PiExtensionToolMapping["subagents"]> = [
{ type: "upsert", id: run.owner, status },
];
for (const id of run.rows) {
if (settledRows.has(id)) continue;
subagents.push({ type: "upsert", id, status: "canceled" });
}
return subagents;
};
/**
* A row id can settle twice: a completion notice records a verdict in `settledRows`, and a
* `subagent_wait` result repeats the run's rows with the run's own verdict. Both are terminal, so
* a disagreement moves a row terminal-to-terminal (completed → failed) and never resurrects it;
* the owner row is what carries the run outcome.
*/
// ponytail: child verdicts can diverge between notice and bg_wait result — unify when one source of truth exists
const collectRows = (
owner: string,
rows: z.infer<typeof Row>[],
@@ -87,17 +243,19 @@ export const piSubagents: PiExtension = {
const subagents: NonNullable<PiExtensionToolMapping["subagents"]> = [];
const childSessions: NonNullable<PiExtensionToolMapping["childSessions"]> = [];
for (const [index, row] of rows.entries()) {
const id = rows.length === 1 ? owner : `${owner}:${row.index ?? index}`;
const id = rowId(owner, rows.length, row.workflowKey, row.index ?? index);
const status =
failed || row.success === false || (row.success !== true && row.exitCode !== 0)
? ("failed" as const)
: ("completed" as const);
recordTerminal(id, status);
subagents.push({
type: "upsert",
id,
title: row.agent,
description,
toolCallId: owner,
status:
failed || row.success === false || (row.success !== true && row.exitCode !== 0)
? "failed"
: "completed",
status,
});
if (row.sessionFile && !readSessions.has(row.sessionFile)) {
readSessions.add(row.sessionFile);
@@ -123,66 +281,164 @@ export const piSubagents: PiExtension = {
}
return subagents.length ? { subagents, childSessions } : undefined;
};
const mapSpawn = (call: PiExtensionToolCall) => {
const args = Args.safeParse(call.args);
if (!args.success || args.data.action !== undefined || !args.data.agent || !args.data.task)
return undefined;
const detail = {
type: "sub_agent" as const,
subAgentType: args.data.agent,
description: args.data.task,
log: extractTextFromToolResult(call.result)?.trim() ?? "",
};
const base = {
type: "upsert" as const,
id: call.callId,
title: args.data.agent,
description: args.data.task,
toolCallId: call.callId,
};
if (call.status === "running")
return { detail, subagents: [{ ...base, status: "running" as const }] };
/** Project a saved status both when replaying a launch and when its live poll advances. */
const projectAsyncStatus = (run: AsyncRun, status: z.infer<typeof AsyncStatus>) => {
const subagents: NonNullable<PiExtensionToolMapping["subagents"]> = [];
const childSessions: NonNullable<PiExtensionToolMapping["childSessions"]> = [];
const runTerminal = TERMINAL_RUN_STATES.has(status.state);
for (const [index, step] of status.steps.entries()) {
const id = rowId(run.owner, status.steps.length, step.workflowKey, index);
run.rows.add(id);
// A child's notice supplies its verdict even when its saved status is stale. Reuse that
// verdict when attaching a transcript or filling its name after the notice arrived.
const settledStatus = settledRows.get(id);
const childSession =
step.sessionFile && !readSessions.has(step.sessionFile)
? { id, file: step.sessionFile }
: undefined;
if (!settledStatus) {
const childStatus = asyncChildStatus(status.state, step.status);
recordTerminal(id, childStatus);
namedRows.add(id);
subagents.push({
type: "upsert",
id,
title: step.agent,
status: childStatus,
});
} else if (!namedRows.has(id) || childSession) {
// Attach a late transcript with its known verdict, so the host reads it once instead of
// starting a follower that no later terminal update will release.
namedRows.add(id);
subagents.push({
type: "upsert",
id,
title: step.agent,
status: settledStatus,
});
}
if (childSession) {
readSessions.add(childSession.file);
childSessions.push(childSession);
}
}
if (runTerminal) run.polling = false;
// The launch row stands for the whole run, and a single unnamed child already took its
// id.
// A row that already settled keeps that verdict: a result that landed before the run's
// steps named its children must not be reverted to running by the next tick.
if (!run.rows.has(run.owner) && !settledRows.has(run.owner)) {
const ownerStatus = run.polling ? ("running" as const) : asyncRunStatus(status.state);
recordTerminal(run.owner, ownerStatus);
subagents.push({
type: "upsert",
id: run.owner,
status: ownerStatus,
});
}
return { subagents, childSessions };
};
/** A settled spawn: Pi either reports the rows it ran, or an async directory the poll follows. */
const mapSettledSpawn = (
call: PiExtensionToolCall,
base: SubagentUpsert,
description: string | undefined,
asyncRequested: boolean,
): Pick<PiExtensionToolMapping, "subagents" | "childSessions"> => {
const failed = call.status === "failed";
const details = Details.safeParse(
typeof call.result === "object" ? call.result?.details : null,
);
if (!details.success || details.data.mode === "management")
return call.status === "failed"
? { detail, subagents: [{ ...base, status: "failed" as const }] }
: { detail };
if (!details.success || details.data.mode === "management") {
if (!failed) return { subagents: [] };
recordTerminal(call.callId, "failed");
return { subagents: [{ ...base, status: "failed" as const }] };
}
if (details.data.runId) callsByRun.set(details.data.runId, call.callId);
if (details.data.results.length === 0 && (details.data.asyncId || args.data.async)) {
if (details.data.results.length === 0) {
if (failed) {
recordTerminal(call.callId, "failed");
return { subagents: [{ ...base, status: "failed" as const }] };
}
if (!details.data.asyncId && !asyncRequested) {
// ponytail: inferred completion — revisit if pi can return success while a child outlives the call.
recordTerminal(call.callId, "completed");
return { subagents: [{ ...base, status: "completed" as const }] };
}
// Follow the async run from its saved status or later poll updates.
rememberAsyncRun(details.data, call);
const dir = details.data.asyncDir;
const run = dir ? asyncRuns.get(dir) : undefined;
const status = dir ? readAsyncStatus(dir) : undefined;
if (run && status) return projectAsyncStatus(run, status);
return { subagents: [{ ...base, status: "running" as const }] };
}
const collected = collectRows(call.callId, details.data.results, description, failed);
// A run that reported rows of its own never keeps the placeholder card it opened at launch.
if (!collected.subagents.some((event) => event.id === call.callId)) {
collected.subagents.unshift({ type: "remove", id: call.callId });
}
return collected;
};
const mapSpawn = (call: PiExtensionToolCall) => {
const args = Args.safeParse(call.args);
if (!args.success || args.data.action !== undefined) return undefined;
const single = Boolean(args.data.agent && args.data.task);
let workflow: string | undefined;
if (!single) workflow = workflowTitle(args.data);
if (!single && workflow === undefined) return undefined;
const base = {
type: "upsert" as const,
id: call.callId,
title: (single ? args.data.agent : workflow) ?? "workflow",
description: args.data.task,
toolCallId: call.callId,
};
const detail = {
type: "sub_agent" as const,
subAgentType: base.title,
description: args.data.task,
log: extractTextFromToolResult(call.result)?.trim() ?? "",
};
// ponytail: replay re-emits running before the settled rows land — one-pass client flip, upgrade when replay maps calls with their results first
if (call.status === "running")
return {
detail,
subagents: [
{
...base,
status: call.status === "failed" ? ("failed" as const) : ("running" as const),
},
],
subagents: [{ ...base, status: "running" as const }],
};
}
if (details.data.results.length === 0 && call.status === "failed")
return { detail, subagents: [{ ...base, status: "failed" as const }] };
return {
detail,
...collectRows(call.callId, details.data.results, args.data.task, call.status === "failed"),
...mapSettledSpawn(call, base, args.data.task, Boolean(args.data.async)),
};
};
const noticeText = (message: CustomMessage) =>
typeof message.content === "string"
? message.content
: extractTextFromToolResult({ content: message.content });
const mapWorkflowChildNotice = (message: CustomMessage) => {
const lines = noticeText(message)?.split("\n") ?? [];
const status = noticeStatus(lines[0] ?? "");
const key = lines[0]?.match(
/^Workflow child (?:completed|failed|paused|stopped)(?: \([^)]*\))?: \*\*(.+?)\*\*/,
)?.[1];
const runId = lines.find((line) => line.startsWith("Workflow run: "))?.slice(14);
const owner = runId ? callsByRun.get(runId) : undefined;
if (!status || !key || !owner) return undefined;
const id = `${owner}:${key}`;
recordTerminal(id, status);
return { subagents: [{ type: "upsert" as const, id, status }] };
};
return {
mapToolCall(call) {
if (call.toolName === "bg_wait") return mapWait(call);
if (call.toolName === "subagent") return mapSpawn(call);
return undefined;
},
// The notice is the only completion Pi persists, so replay settles async runs from it.
mapCustomMessage(message) {
if (message.customType === "subagent-incremental-child-notify")
return mapWorkflowChildNotice(message);
if (message.customType !== "subagent-notify") return undefined;
const text =
typeof message.content === "string"
? message.content
: extractTextFromToolResult({ content: message.content });
const lines = text?.split("\n") ?? [];
const lines = noticeText(message)?.split("\n") ?? [];
const status = noticeStatus(lines[0] ?? "");
if (!status) return undefined;
const subagents: NonNullable<PiExtensionToolMapping["subagents"]> = [];
@@ -192,8 +448,10 @@ export const piSubagents: PiExtension = {
for (const line of lines) {
const dir = line.match(/^Retention-managed async directory: (.+)$/)?.[1];
if (dir) {
owner = asyncRuns.get(dir)?.owner;
if (owner) subagents.push({ type: "upsert", id: owner, status });
const run = asyncRuns.get(dir);
owner = run?.owner;
if (!run || !owner) continue;
subagents.push(...settleRunNotice(run, status));
continue;
}
const file = owner ? line.match(/^Session file: (.+)$/)?.[1] : undefined;
@@ -209,36 +467,26 @@ export const piSubagents: PiExtension = {
poll() {
const subagents: NonNullable<PiExtensionToolMapping["subagents"]> = [];
const childSessions: NonNullable<PiExtensionToolMapping["childSessions"]> = [];
const now = Date.now();
for (const [dir, run] of asyncRuns) {
if (!run.polling) continue;
let raw: unknown;
try {
raw = JSON.parse(readFileSync(join(dir, "status.json"), "utf8"));
} catch {
if (now >= run.deadline) {
subagents.push(...giveUpRun(run));
continue;
}
const parsed = AsyncStatus.safeParse(raw);
if (!parsed.success) continue;
const terminal = ["complete", "failed", "stopped"].includes(parsed.data.state);
for (const [index, step] of parsed.data.steps.entries()) {
const id = parsed.data.steps.length === 1 ? run.owner : `${run.owner}:${index}`;
const status = asyncChildStatus(parsed.data.state, step.status);
subagents.push({ type: "upsert", id, title: step.agent, status });
if (step.sessionFile && !readSessions.has(step.sessionFile)) {
readSessions.add(step.sessionFile);
childSessions.push({ id, file: step.sessionFile });
const status = readAsyncStatus(dir);
if (!status) {
run.misses += 1;
if (run.misses >= POLL_MISS_LIMIT) {
subagents.push(...giveUpRun(run));
continue;
}
continue;
}
if (terminal) {
if (parsed.data.steps.length === 0) {
subagents.push({
type: "upsert",
id: run.owner,
status: parsed.data.state === "complete" ? "completed" : "failed",
});
}
run.polling = false;
}
run.misses = 0;
const output = projectAsyncStatus(run, status);
subagents.push(...output.subagents);
childSessions.push(...output.childSessions);
}
return subagents.length || childSessions.length ? { subagents, childSessions } : undefined;
},
@@ -25,7 +25,7 @@ import {
import { curateAgentActivity } from "../activity-curator.js";
import { selectItemsByProjectedLimit } from "../timeline-projection.js";
import type { AgentStorage } from "../agent-storage.js";
import { ensureAgentLoaded } from "../agent-loading.js";
import { AgentNotFoundError, ensureAgentLoaded } from "../agent-loading.js";
import { isStoredAgentProviderAvailable } from "../../persistence-hooks.js";
import {
archiveByScope,
@@ -40,7 +40,12 @@ import { everyMsToFiveFieldCron } from "@getpaseo/protocol/schedule/cadence";
import { expandUserPath, isSameOrDescendantPath, resolvePathFromBase } from "../../path-utils.js";
import type { TerminalManager } from "../../../terminal/terminal-manager.js";
import type { CreatePaseoWorktreeWorkflowFn } from "../../worktree-session.js";
import type { ScheduleService } from "../../schedule/service.js";
import { computeNextRunAt, validateScheduleCadence } from "../../schedule/cron.js";
import {
ScheduleNotFoundError,
ScheduleStateConflictError,
type ScheduleService,
} from "../../schedule/service.js";
import {
ScheduleRunSchema,
ScheduleSummarySchema,
@@ -91,12 +96,13 @@ import {
} from "../../worktree/commands.js";
import { registerBrowserTools } from "../../browser-tools/tools.js";
import type { BrowserToolsBroker } from "../../browser-tools/broker.js";
import type {
PaseoToolCatalog,
PaseoToolConfig,
PaseoToolDefinition,
PaseoToolExecutionContext,
PaseoToolResult,
import {
PaseoToolInputError,
type PaseoToolCatalog,
type PaseoToolConfig,
type PaseoToolDefinition,
type PaseoToolExecutionContext,
type PaseoToolResult,
} from "./types.js";
import type { ProviderPaseoToolsPolicy } from "@getpaseo/protocol/provider-config";
import { isPaseoToolEnabled } from "../paseo-tool-policy.js";
@@ -229,7 +235,7 @@ function assertOptionsAbsent(
message: string,
): void {
if (options.some(([, value]) => value !== undefined)) {
throw new Error(message);
throw new PaseoToolInputError(message);
}
}
@@ -265,7 +271,7 @@ function resolveWorkspaceWorktreeTarget(input: WorkspaceWorktreeOptions): Worksp
};
case "checkout-branch":
if (!input.branch) {
throw new Error("branch is required for checkout-branch mode");
throw new PaseoToolInputError("branch is required for checkout-branch mode");
}
assertOptionsAbsent(
[
@@ -279,7 +285,7 @@ function resolveWorkspaceWorktreeTarget(input: WorkspaceWorktreeOptions): Worksp
return { action: "checkout", refName: input.branch };
case "checkout-pr":
if (input.prNumber === undefined) {
throw new Error("prNumber is required for checkout-pr mode");
throw new PaseoToolInputError("prNumber is required for checkout-pr mode");
}
assertOptionsAbsent(
[
@@ -355,7 +361,7 @@ function resolveScheduleProviderAndModel(params: {
const provider = providerInput.slice(0, slashIndex).trim();
const model = providerInput.slice(slashIndex + 1).trim();
if (!provider || !model) {
throw new Error("provider must be <provider> or <provider>/<model>");
throw new PaseoToolInputError("provider must be <provider> or <provider>/<model>");
}
return {
@@ -372,7 +378,7 @@ function resolveScheduleUpdateProviderAndModel(params: {
const modelInput = typeof params.model === "string" ? params.model.trim() : params.model;
if (params.model !== undefined && modelInput === "") {
throw new Error("model cannot be empty");
throw new PaseoToolInputError("model cannot be empty");
}
if (!providerInput) {
@@ -390,13 +396,13 @@ function resolveScheduleUpdateProviderAndModel(params: {
const provider = providerInput.slice(0, slashIndex).trim();
const modelFromProvider = providerInput.slice(slashIndex + 1).trim();
if (!provider || !modelFromProvider) {
throw new Error("provider must be <provider> or <provider>/<model>");
throw new PaseoToolInputError("provider must be <provider> or <provider>/<model>");
}
if (params.model === null) {
throw new Error("provider specifies a model but model is null");
throw new PaseoToolInputError("provider specifies a model but model is null");
}
if (typeof modelInput === "string" && modelInput !== modelFromProvider) {
throw new Error("Conflicting model values provided");
throw new PaseoToolInputError("Conflicting model values provided");
}
return {
@@ -438,26 +444,82 @@ function normalizeScheduleTimeZoneArg(value: string | undefined): string | undef
return normalizeScheduleCadenceArg(value);
}
function addScheduleCadenceIssue(
input: { cron?: string; timezone?: string },
context: z.RefinementCtx,
): void {
const expression = input.cron?.trim();
if (!expression) {
return;
}
const cadence: ScheduleCadence = {
type: "cron",
expression,
...(input.timezone !== undefined ? { timezone: input.timezone.trim() } : {}),
};
try {
validateScheduleCadence(cadence);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
context.addIssue({
code: "custom",
path: [message.startsWith("Invalid cron time zone:") ? "timezone" : "cron"],
message,
});
return;
}
try {
computeNextRunAt(cadence, new Date());
} catch (error) {
context.addIssue({
code: "custom",
path: ["cron"],
message: error instanceof Error ? error.message : String(error),
});
}
}
function parseToolArguments<T>(schema: z.ZodType<T>, input: unknown): T {
const result = schema.safeParse(input);
if (!result.success) {
throw new PaseoToolInputError(result.error.message, result.error.issues);
}
return result.data;
}
function addScheduleDurationIssue(value: string, context: z.RefinementCtx): void {
try {
parseDurationString(value);
} catch (error) {
context.addIssue({
code: "custom",
message: error instanceof Error ? error.message : String(error),
});
}
}
const scheduleDurationInputSchema = z.string().superRefine(addScheduleDurationIssue);
function resolveScheduleUpdateCadence(input: ScheduleUpdateToolInput): ScheduleCadence | undefined {
const every = normalizeScheduleCadenceArg(input.every);
const cron = normalizeScheduleCadenceArg(input.cron);
const timeZone = normalizeScheduleTimeZoneArg(input.timezone);
if (every !== undefined && cron !== undefined) {
throw new Error("Specify at most one of every or cron");
throw new PaseoToolInputError("Specify at most one of every or cron");
}
if (timeZone !== undefined && cron === undefined) {
throw new Error("timezone can only be used with cron");
throw new PaseoToolInputError("timezone can only be used with cron");
}
if (every !== undefined) {
// COMPAT(scheduleEveryInput): accept the old hidden field and canonicalize it before write.
// Added in v0.2.0; remove after 2027-01-17.
const everyMs = parseDurationString(every);
const everyMs = parseDurationString(parseToolArguments(scheduleDurationInputSchema, every));
const expression = everyMsToFiveFieldCron(everyMs);
if (expression) {
return { type: "cron", expression };
}
throw new Error(`${every} cannot be represented faithfully by five-field cron`);
throw new PaseoToolInputError(`${every} cannot be represented faithfully by five-field cron`);
}
if (cron !== undefined) {
return {
@@ -471,7 +533,7 @@ function resolveScheduleUpdateCadence(input: ScheduleUpdateToolInput): ScheduleC
function resolveScheduleUpdateExpiresAt(input: ScheduleUpdateToolInput): string | null | undefined {
if (input.expiresIn !== undefined && input.clearExpires) {
throw new Error("Specify at most one of expiresIn or clearExpires");
throw new PaseoToolInputError("Specify at most one of expiresIn or clearExpires");
}
if (input.expiresIn !== undefined) {
return new Date(Date.now() + parseDurationString(input.expiresIn)).toISOString();
@@ -593,7 +655,11 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
typeof (inputSchema as { safeParseAsync?: unknown }).safeParseAsync === "function"
? (inputSchema as z.ZodType)
: z.object(inputSchema as z.ZodRawShape).passthrough();
return schema.parseAsync(input);
const result = await schema.safeParseAsync(input);
if (!result.success) {
throw new PaseoToolInputError(result.error.message, result.error.issues);
}
return result.data;
};
const tools = new Map<string, PaseoToolDefinition>();
@@ -627,7 +693,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
): Promise<PaseoToolResult> {
const tool = tools.get(name);
if (!tool) {
throw new Error(`Paseo tool not found: ${name}`);
throw new PaseoToolInputError(`Paseo tool not found: ${name}`);
}
return tool.handler(await parseToolInput(tool, input), context);
},
@@ -639,7 +705,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
}): ScheduleCadence => {
const expression = input.cron?.trim() ?? "";
if (!expression) {
throw new Error("cron is required");
throw new PaseoToolInputError("cron is required");
}
const timezone = normalizeScheduleTimeZoneArg(input.timezone);
return {
@@ -661,7 +727,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
}
const parentAgent = agentManager.getAgent(callerAgentId);
if (!parentAgent) {
throw new Error(`Parent agent ${callerAgentId} not found`);
throw new PaseoToolInputError(`Parent agent ${callerAgentId} not found`);
}
return parentAgent;
};
@@ -690,9 +756,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
const trimmedCwd = requestedCwd?.trim();
if (!trimmedCwd) {
if (opts?.required) {
throw new Error("cwd is required");
throw new PaseoToolInputError("cwd is required");
}
throw new Error("cwd is required outside an agent-scoped session");
throw new PaseoToolInputError("cwd is required outside an agent-scoped session");
}
return expandUserPath(trimmedCwd);
@@ -707,11 +773,12 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
}
if (!options.ensureWorkspaceForCreate) {
throw new Error(
callerAgentId
? `Caller agent ${callerAgentId} has no workspace and workspace minting is not configured`
: "workspaceId is required outside an agent-scoped session",
);
if (callerAgentId) {
throw new Error(
`Caller agent ${callerAgentId} has no workspace and workspace minting is not configured`,
);
}
throw new PaseoToolInputError("workspaceId is required outside an agent-scoped session");
}
return options.ensureWorkspaceForCreate(resolvedCwd);
@@ -726,11 +793,11 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
if (callerAgentId) {
const callerAgent = resolveCallerAgent();
if (!callerAgent?.workspaceId) {
throw new Error(`Caller agent ${callerAgentId} has no current workspace`);
throw new PaseoToolInputError(`Caller agent ${callerAgentId} has no current workspace`);
}
return callerAgent.workspaceId;
}
throw new Error("workspaceId is required outside an agent-scoped session");
throw new PaseoToolInputError("workspaceId is required outside an agent-scoped session");
}
const buildCallerAgentScheduleConfigExtras = (
@@ -800,7 +867,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
}
if (!params?.provider?.trim()) {
throw new Error("provider is required when target is new-agent");
throw new PaseoToolInputError("provider is required when target is new-agent");
}
const resolvedProviderModel = resolveScheduleProviderAndModel({
@@ -822,22 +889,41 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
if (!scheduleService) {
throw new Error("Schedule service is not configured");
}
const schedule = await scheduleService.inspect(id);
let schedule: Awaited<ReturnType<ScheduleService["inspect"]>>;
try {
schedule = await scheduleService.inspect(id);
} catch (error) {
if (error instanceof ScheduleNotFoundError) {
throw new PaseoToolInputError(error.message);
}
throw error;
}
if (schedule.target.type !== type) {
throw new Error(
throw new PaseoToolInputError(
type === "agent" ? `Heartbeat not found: ${id}` : `Schedule not found: ${id}`,
);
}
return schedule;
}
async function runScheduleAction<T>(action: () => Promise<T>): Promise<T> {
try {
return await action();
} catch (error) {
if (error instanceof ScheduleStateConflictError) {
throw new PaseoToolInputError(error.message);
}
throw error;
}
}
async function requireCallerHeartbeat(id: string) {
if (!callerAgentId) {
throw new Error("Heartbeat operations require an agent-scoped session");
throw new PaseoToolInputError("Heartbeat operations require an agent-scoped session");
}
const schedule = await requireScheduleTarget(id, "agent");
if (schedule.target.type !== "agent" || schedule.target.agentId !== callerAgentId) {
throw new Error(`Heartbeat ${id} does not belong to caller ${callerAgentId}`);
throw new PaseoToolInputError(`Heartbeat ${id} does not belong to caller ${callerAgentId}`);
}
return schedule;
}
@@ -847,6 +933,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
})
.refine(
(value) => {
if (!value.includes("/")) {
return true;
}
try {
resolveRequiredProviderModel(value);
return true;
@@ -1184,11 +1273,13 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
},
async (args, context) => {
if (!callerAgentId) {
throw new Error("speak is only available to agent-scoped tool sessions");
throw new PaseoToolInputError("speak is only available to agent-scoped tool sessions");
}
const handler = resolveSpeakHandler?.(callerAgentId) ?? null;
if (!handler) {
throw new Error(`No speak handler registered for your session '${callerAgentId}'`);
throw new PaseoToolInputError(
`No speak handler registered for your session '${callerAgentId}'`,
);
}
await handler({
text: args.text,
@@ -1289,7 +1380,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
if (isolation === "local") {
const cwd = resolveScopedCwd(path, { required: true });
if (!(await isExistingDirectory(cwd))) {
throw new Error(`Directory not found: ${cwd}`);
throw new PaseoToolInputError(`Directory not found: ${cwd}`);
}
assertOptionsAbsent(
[
@@ -1534,7 +1625,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
if (hasLegacyCreateAgentPlacement(args)) {
// COMPAT(nestedCreateAgentPlacement): accept the old relationship/workspace shape without
// advertising it to models. Added in v0.2.0; remove after 2027-01-17.
const parsed = legacyAgentToAgentCreateAgentArgsSchema.parse(args);
const parsed = parseToolArguments(legacyAgentToAgentCreateAgentArgsSchema, args);
const { cwd, workspaceId, worktree } = await resolveCreateAgentWorkspace(parsed.workspace, {
prompt: parsed.initialPrompt,
});
@@ -1547,7 +1638,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
worktree,
};
}
const parsed = agentToAgentCreateAgentArgsSchema.parse(args);
const parsed = parseToolArguments(agentToAgentCreateAgentArgsSchema, args);
const { cwd, workspaceId } = await resolveCanonicalCreateAgentWorkspace(parsed.workspaceId, {
prompt: parsed.initialPrompt,
});
@@ -1563,13 +1654,15 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
if (hasLegacyCreateAgentPlacement(args)) {
// COMPAT(nestedCreateAgentPlacement): see the agent-scoped branch above.
const parsedArgs = normalizeTopLevelCreateAgentArgs(
legacyTopLevelCreateAgentArgsSchema.parse(args),
parseToolArguments(legacyTopLevelCreateAgentArgsSchema, args),
);
if (parsedArgs.relationship?.kind === "subagent") {
throw new Error("relationship subagent requires an agent-scoped tool session");
throw new PaseoToolInputError(
"relationship subagent requires an agent-scoped tool session",
);
}
if (!parsedArgs.workspace) {
throw new Error("Legacy create_agent placement could not be resolved");
throw new PaseoToolInputError("Legacy create_agent placement could not be resolved");
}
const { cwd, workspaceId, worktree } = await resolveCreateAgentWorkspace(
parsedArgs.workspace,
@@ -1584,7 +1677,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
worktree,
};
}
const parsedArgs = canonicalTopLevelCreateAgentArgsSchema.parse(args);
const parsedArgs = parseToolArguments(canonicalTopLevelCreateAgentArgsSchema, args);
const { cwd, workspaceId } = await resolveCanonicalCreateAgentWorkspace(
parsedArgs.workspaceId,
{ prompt: parsedArgs.initialPrompt },
@@ -1644,7 +1737,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
}
const caller = resolveCallerAgent();
if (!caller?.workspaceId) {
throw new Error(`Caller agent ${callerAgentId} has no current workspace`);
throw new PaseoToolInputError(`Caller agent ${callerAgentId} has no current workspace`);
}
return { cwd: undefined, workspaceId: caller.workspaceId };
}
@@ -1672,18 +1765,18 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
};
if (canonicalCandidate.relationship && canonicalCandidate.workspace) {
return legacyTopLevelCreateAgentArgsSchema.parse({
return parseToolArguments(legacyTopLevelCreateAgentArgsSchema, {
...canonicalCandidate,
...(Object.keys(settings).length > 0 ? { settings } : {}),
});
}
if (canonicalCandidate.relationship || canonicalCandidate.workspace) {
throw new Error("relationship and workspace must be provided together");
throw new PaseoToolInputError("relationship and workspace must be provided together");
}
if (!cwd?.trim()) {
throw new Error("cwd is required for legacy top-level create_agent calls");
throw new PaseoToolInputError("cwd is required for legacy top-level create_agent calls");
}
const legacyWorktreeTarget = resolveLegacyCreateAgentWorktreeTarget({
@@ -1710,7 +1803,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
},
};
return legacyTopLevelCreateAgentArgsSchema.parse({
return parseToolArguments(legacyTopLevelCreateAgentArgsSchema, {
...canonicalCandidate,
relationship: { kind: "detached" },
workspace,
@@ -1763,11 +1856,11 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
}> {
if (workspace.kind === "current") {
if (!callerAgentId) {
throw new Error("workspace current requires an agent-scoped tool session");
throw new PaseoToolInputError("workspace current requires an agent-scoped tool session");
}
const callerAgent = resolveCallerAgent();
if (!callerAgent?.workspaceId) {
throw new Error(`Caller agent ${callerAgentId} has no current workspace`);
throw new PaseoToolInputError(`Caller agent ${callerAgentId} has no current workspace`);
}
return {
cwd: workspace.cwd,
@@ -1784,14 +1877,16 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
(candidate) => candidate.workspaceId === workspace.workspaceId,
);
if (!existingWorkspace) {
throw new Error(`Workspace ${workspace.workspaceId} not found`);
throw new PaseoToolInputError(`Workspace ${workspace.workspaceId} not found`);
}
const cwd = workspace.cwd
? resolveScopedCwd(workspace.cwd, { required: true })
: existingWorkspace.cwd;
const lockedCwd = callerContext?.lockedCwd?.trim();
if (lockedCwd && !isSameOrDescendantPath(expandUserPath(lockedCwd), cwd)) {
throw new Error(`Workspace ${workspace.workspaceId} is outside the allowed cwd`);
throw new PaseoToolInputError(
`Workspace ${workspace.workspaceId} is outside the allowed cwd`,
);
}
return {
cwd,
@@ -1893,6 +1988,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
logger: childLogger,
}).catch((error) => {
stopNotification?.();
if (error instanceof AgentNotFoundError) {
throw new PaseoToolInputError(error.message);
}
throw error;
});
@@ -1956,7 +2054,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
const record = await agentStorage.get(agentId);
if (!record || record.internal) {
throw new Error(`Agent ${agentId} not found`);
throw new PaseoToolInputError(`Agent ${agentId} not found`);
}
const structuredSnapshot = buildStoredAgentPayload(
@@ -2194,10 +2292,10 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
const workspaceId = resolveWorkspaceIdForRename(requestedWorkspaceId);
const existing = await options.workspaceRegistry.get(workspaceId);
if (!existing) {
throw new Error(`Workspace ${workspaceId} not found`);
throw new PaseoToolInputError(`Workspace ${workspaceId} not found`);
}
if (existing.archivedAt) {
throw new Error(`Workspace ${workspaceId} is archived`);
throw new PaseoToolInputError(`Workspace ${workspaceId} is archived`);
}
await options.workspaceRegistry.upsert({
@@ -2401,7 +2499,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
const terminal = terminalManager.getTerminal(terminalId);
if (!terminal) {
throw new Error(`Terminal ${terminalId} not found`);
throw new PaseoToolInputError(`Terminal ${terminalId} not found`);
}
terminal.kill();
@@ -2437,7 +2535,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
}
if (!terminalManager.getTerminal(terminalId)) {
throw new Error(`Terminal ${terminalId} not found`);
throw new PaseoToolInputError(`Terminal ${terminalId} not found`);
}
const capture = await terminalManager.captureTerminal(terminalId, {
@@ -2478,7 +2576,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
const terminal = terminalManager.getTerminal(terminalId);
if (!terminal) {
throw new Error(`Terminal ${terminalId} not found`);
throw new PaseoToolInputError(`Terminal ${terminalId} not found`);
}
terminal.send({
@@ -2498,24 +2596,26 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
{
title: "Create schedule",
description: "Create a recurring schedule that starts a new agent on a cron cadence.",
inputSchema: {
prompt: z.string().trim().min(1, "prompt is required"),
cron: z.string().trim().min(1, "cron is required"),
timezone: z
.string()
.trim()
.min(1)
.optional()
.describe("IANA time zone for the cron cadence. For example: America/New_York."),
name: z.string().optional(),
provider: (callerAgentId ? AgentProviderEnum.optional() : AgentProviderEnum).describe(
"Provider, or provider/model (for example: codex or codex/gpt-5.4). Defaults to the caller's provider in an agent-scoped session.",
),
cwd: z.string().optional(),
isolation: z.enum(["local", "worktree"]).optional(),
maxRuns: z.number().int().positive().optional(),
expiresIn: z.string().optional(),
},
inputSchema: z
.object({
prompt: z.string().trim().min(1, "prompt is required"),
cron: z.string().trim().min(1, "cron is required"),
timezone: z
.string()
.trim()
.min(1)
.optional()
.describe("IANA time zone for the cron cadence. For example: America/New_York."),
name: z.string().optional(),
provider: (callerAgentId ? AgentProviderEnum.optional() : AgentProviderEnum).describe(
"Provider, or provider/model (for example: codex or codex/gpt-5.4). Defaults to the caller's provider in an agent-scoped session.",
),
cwd: z.string().optional(),
isolation: z.enum(["local", "worktree"]).optional(),
maxRuns: z.number().int().positive().optional(),
expiresIn: scheduleDurationInputSchema.optional(),
})
.superRefine(addScheduleCadenceIssue),
outputSchema: ScheduleSummarySchema.shape,
},
async ({ prompt, cron, timezone, name, provider, cwd, isolation, maxRuns, expiresIn }) => {
@@ -2548,19 +2648,21 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
{
title: "Create heartbeat",
description: "Create a recurring heartbeat that sends you a prompt on a cron cadence.",
inputSchema: {
prompt: z.string().trim().min(1, "prompt is required"),
cron: z.string().trim().min(1, "cron is required"),
timezone: z
.string()
.trim()
.min(1)
.optional()
.describe("IANA time zone for the cron cadence. For example: America/New_York."),
name: z.string().optional(),
maxRuns: z.number().int().positive().optional(),
expiresIn: z.string().optional(),
},
inputSchema: z
.object({
prompt: z.string().trim().min(1, "prompt is required"),
cron: z.string().trim().min(1, "cron is required"),
timezone: z
.string()
.trim()
.min(1)
.optional()
.describe("IANA time zone for the cron cadence. For example: America/New_York."),
name: z.string().optional(),
maxRuns: z.number().int().positive().optional(),
expiresIn: scheduleDurationInputSchema.optional(),
})
.superRefine(addScheduleCadenceIssue),
outputSchema: ScheduleSummarySchema.shape,
},
async ({ prompt, cron, timezone, name, maxRuns, expiresIn }) => {
@@ -2568,7 +2670,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
throw new Error("Schedule service is not configured");
}
if (!callerAgentId) {
throw new Error("create_heartbeat requires an agent-scoped session");
throw new PaseoToolInputError("create_heartbeat requires an agent-scoped session");
}
resolveCallerAgent();
@@ -2679,7 +2781,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
}
await requireScheduleTarget(id, "new-agent");
await scheduleService.pause(id);
await runScheduleAction(() => scheduleService.pause(id));
return {
content: [],
structuredContent: ensureValidJson({ success: true }),
@@ -2705,7 +2807,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
}
await requireScheduleTarget(id, "new-agent");
await scheduleService.resume(id);
await runScheduleAction(() => scheduleService.resume(id));
return {
content: [],
structuredContent: ensureValidJson({ success: true }),
@@ -2787,13 +2889,13 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
.optional()
.describe("New mode for new-agent target (null to clear)."),
cwd: z.string().trim().min(1).optional().describe("New cwd for new-agent target."),
expiresIn: z
.string()
expiresIn: scheduleDurationInputSchema
.optional()
.describe("New relative expiry duration (for example: 1h, 2d)."),
clearExpires: z.boolean().optional().describe("Clear any schedule expiry."),
})
.passthrough(),
.passthrough()
.superRefine(addScheduleCadenceIssue),
outputSchema: StoredScheduleSchema.shape,
},
async (input) => {
@@ -2850,7 +2952,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
throw new Error("Schedule service is not configured");
}
await requireScheduleTarget(id, "new-agent");
const schedule = await scheduleService.runOnce(id);
const schedule = await runScheduleAction(() => scheduleService.runOnce(id));
return {
content: [],
structuredContent: ensureValidJson(schedule),
@@ -2963,10 +3065,10 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
});
const summary = toProviderSummary(entry);
if (!entry.enabled) {
throw new Error(`Provider '${providerId}' is disabled`);
throw new PaseoToolInputError(`Provider '${providerId}' is disabled`);
}
if (entry.status !== "ready") {
throw new Error(entry.error ?? `Provider '${providerId}' is unavailable`);
throw new PaseoToolInputError(entry.error ?? `Provider '${providerId}' is unavailable`);
}
const selectedModel = settings?.model ?? resolvedProviderModel.model;
const features = await agentManager.listDraftFeatures({
@@ -1,6 +1,18 @@
import type { z } from "zod";
import type { ProviderPaseoToolsPolicy } from "@getpaseo/protocol/provider-config";
export class PaseoToolInputError extends Error {
readonly status = 400;
constructor(
message: string,
readonly issues?: z.ZodError["issues"],
) {
super(message);
this.name = "PaseoToolInputError";
}
}
export interface PaseoToolExecutionContext {
signal?: AbortSignal;
sendUpdate?: (update: PaseoToolResult) => void;
@@ -0,0 +1,60 @@
import { expect, test } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { existsSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
test("changing thinking then mode preserves a foreground Claude command", async () => {
const logger = pino({ level: "silent" });
const cwd = await mkdtemp(path.join(tmpdir(), "claude-settings-mid-turn-"));
const daemon = await createTestPaseoDaemon({
logger,
agentClients: { claude: new ClaudeAgentClient({ logger }) },
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.fetchAgents({ subscribe: {} });
const agent = await client.createAgent({
provider: "claude",
model: "claude-haiku-5-5",
modeId: "auto",
thinkingOptionId: "low",
cwd,
});
await client.subscribeAgentTimeline(agent.id, () => {}).ready;
await client.sendMessage(
agent.id,
"Run exactly these three Bash commands in the foreground, one per tool call, in order: python3 -c \"from pathlib import Path; import time; Path('started').write_text('started'); time.sleep(20); print(1)\", then the same with print(2), then print(3). Then reply DONE.",
);
// A tool-call row is emitted before Claude's permission check finishes.
// Wait for the command itself, so changing to acceptEdits cannot gate its start.
await expect.poll(() => existsSync(path.join(cwd, "started")), { timeout: 90_000 }).toBe(true);
const notice = await client.setAgentThinkingOption(agent.id, "medium");
expect(notice).toEqual({ type: "warning", message: "Thinking level applies next turn" });
await client.setAgentMode(agent.id, "acceptEdits");
await expect
.poll(
async () => {
const timeline = await client.fetchAgentTimeline(agent.id, { limit: 100 });
return timeline.entries.some(
({ item }) =>
item.type === "tool_call" &&
item.detail.type === "shell" &&
item.detail.command.includes("print(1)") &&
item.status === "completed",
);
},
{ timeout: 45_000, interval: 100 },
)
.toBe(true);
} finally {
await client.close();
await daemon.close();
await rm(cwd, { recursive: true, force: true });
}
}, 180_000);
@@ -5,6 +5,8 @@ import path from "node:path";
import { promisify } from "node:util";
import pino from "pino";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { afterEach, beforeAll, describe, expect, test } from "vitest";
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
@@ -131,6 +133,14 @@ async function timeline(client: DaemonClient, agentId: string): Promise<AgentTim
return result.entries.map((entry) => entry.item);
}
async function readAssistantText(harness: Harness, agentId: string): Promise<string> {
const items = await timeline(harness.client, agentId);
return items
.filter((item) => item.type === "assistant_message")
.map((item) => item.text)
.join("");
}
function compactionFiller(records = 1200): string {
const teas = ["assam", "darjeeling", "sencha", "oolong"];
return Array.from(
@@ -257,8 +267,80 @@ async function contextWindowUsedTokens(client: DaemonClient, agentId: string) {
describe("daemon E2E (real OMP)", () => {
test(
"a prompt OMP rejects before its agent runs fails the turn with OMP's error",
"an idle out-of-band follow-up starts a turn",
async () => {
const harness = await createHarness();
try {
const agent = await createAgent(harness, "idle-follow-up");
await harness.client.sendMessage(agent.id, "/follow-up Reply exactly IDLE_FOLLOWUP_OK");
await expect
.poll(() => readAssistantText(harness, agent.id), { timeout: 30_000 })
.toContain("IDLE_FOLLOWUP_OK");
const finish = await harness.client.waitForFinish(agent.id, TIMEOUT_MS);
expect(finish.status).toBe("idle");
} finally {
await closeHarness(harness);
}
},
TIMEOUT_MS,
);
test(
"an agent-sent idle follow-up starts a turn and notifies its parent",
async () => {
const harness = await createHarness();
const mcp = new Client({ name: "idle-follow-up-test", version: "1.0.0" });
try {
const parent = await createAgent(harness, "follow-up-parent");
const child = await createAgent(harness, "follow-up-child");
await mcp.connect(
new StreamableHTTPClientTransport(
new URL(
`http://127.0.0.1:${harness.daemon.port}/mcp/agents?callerAgentId=${parent.id}`,
),
),
);
const response = await mcp.callTool({
name: "send_agent_prompt",
arguments: { agentId: child.id, prompt: "/follow-up Reply exactly MCP_IDLE_FOLLOWUP_OK" },
});
expect(response.isError).not.toBe(true);
expect(response.structuredContent).toMatchObject({
success: true,
disposition: "out_of_band",
});
await expect
.poll(() => readAssistantText(harness, child.id), { timeout: 30_000 })
.toContain("MCP_IDLE_FOLLOWUP_OK");
await expect
.poll(
async () => {
const items = await timeline(harness.client, parent.id);
let notifications = 0;
for (const item of items) {
if (
item.type === "tool_call" &&
item.agentMessage?.sender.id === child.id &&
item.agentMessage.event === "finished"
)
notifications++;
}
return notifications;
},
{ timeout: 15_000 },
)
.toBe(1);
} finally {
await mcp.close();
await closeHarness(harness);
}
},
TIMEOUT_MS,
);
test.each(["Reply with ok.", "/follow-up Reply with ok."])(
"a rejected %s fails the turn with OMP's error",
async (prompt) => {
const harness = await createHarness({ ompHome: createOfflineOmpHome() });
try {
const agent = await harness.client.createAgent({
@@ -268,7 +350,7 @@ describe("daemon E2E (real OMP)", () => {
model: "anthropic/claude-haiku-4-5",
modeId: "full",
});
await harness.client.sendMessage(agent.id, "Reply with ok.");
await harness.client.sendMessage(agent.id, prompt);
const finish = await harness.client.waitForFinish(agent.id, 60_000);
expect(finish.status).toBe("error");
expect(finish.error).toContain("No API key found for anthropic");
+20 -9
View File
@@ -38,6 +38,20 @@ export class ScheduleTargetGoneError extends Error {
}
}
export class ScheduleNotFoundError extends Error {
constructor(id: string) {
super(`Schedule not found: ${id}`);
this.name = "ScheduleNotFoundError";
}
}
export class ScheduleStateConflictError extends Error {
constructor(message: string) {
super(message);
this.name = "ScheduleStateConflictError";
}
}
function advanceNextRunAtPast(
cadence: StoredSchedule["cadence"],
nextRunAt: Date,
@@ -158,7 +172,7 @@ function shouldCompleteSchedule(schedule: StoredSchedule, now: Date): boolean {
function requireSchedule(schedule: StoredSchedule | null, id: string): StoredSchedule {
if (!schedule) {
throw new Error(`Schedule not found: ${id}`);
throw new ScheduleNotFoundError(id);
}
return schedule;
}
@@ -392,10 +406,7 @@ export class ScheduleService {
async inspect(id: string): Promise<StoredSchedule> {
const schedule = await this.store.get(id);
if (!schedule) {
throw new Error(`Schedule not found: ${id}`);
}
return schedule;
return requireSchedule(schedule, id);
}
async logs(id: string): Promise<ScheduleRun[]> {
@@ -406,7 +417,7 @@ export class ScheduleService {
async pause(id: string): Promise<StoredSchedule> {
const paused = await this.store.update(id, (schedule) => {
if (schedule.status === "completed") {
throw new Error(`Schedule ${id} is already completed`);
throw new ScheduleStateConflictError(`Schedule ${id} is already completed`);
}
if (schedule.status === "paused") {
return schedule;
@@ -426,7 +437,7 @@ export class ScheduleService {
async resume(id: string): Promise<StoredSchedule> {
const resumed = await this.store.update(id, (schedule) => {
if (schedule.status === "completed") {
throw new Error(`Schedule ${id} is already completed`);
throw new ScheduleStateConflictError(`Schedule ${id} is already completed`);
}
if (schedule.status === "active") {
return schedule;
@@ -546,10 +557,10 @@ export class ScheduleService {
async runOnce(id: string): Promise<StoredSchedule> {
const schedule = await this.inspect(id);
if (schedule.status === "completed") {
throw new Error(`Schedule ${id} is already completed`);
throw new ScheduleStateConflictError(`Schedule ${id} is already completed`);
}
if (this.runningScheduleIds.has(id)) {
throw new Error(`Schedule ${id} is already running`);
throw new ScheduleStateConflictError(`Schedule ${id} is already running`);
}
await this.runSchedule(schedule, this.now(), { manual: true });
return this.inspect(id);
@@ -0,0 +1,69 @@
import { randomUUID } from "node:crypto";
import {
MockLoadTestAgentClient,
MockLoadTestAgentSession,
} from "../agent/providers/mock-load-test-agent.js";
import { createTestPaseoDaemon } from "./paseo-daemon.js";
const firstResponse = Promise.withResolvers<void>();
class RetryQuestionSession extends MockLoadTestAgentSession {
private attempts = 0;
override async respondToPermission(
requestId: string,
response: Parameters<MockLoadTestAgentSession["respondToPermission"]>[1],
) {
this.attempts += 1;
process.send?.({ type: "attempt", requestId, response, sessionId: this.id });
if (this.attempts === 1) {
await firstResponse.promise;
throw new Error("Synthetic question response failure");
}
return super.respondToPermission(requestId, response);
}
}
class RetryQuestionClient extends MockLoadTestAgentClient {
override async createSession(config: Parameters<MockLoadTestAgentClient["createSession"]>[0]) {
const session = new RetryQuestionSession({ config, sessionId: randomUUID() });
session.subscribe((event) => {
if (event.type === "permission_resolved") {
process.send?.({
type: "resolved",
requestId: event.requestId,
response: event.resolution,
sessionId: session.id,
pendingRequestIds: session.getPendingPermissions().map((request) => request.id),
});
}
});
return session;
}
}
async function main(): Promise<void> {
const daemon = await createTestPaseoDaemon({
agentClients: { mock: new RetryQuestionClient() },
isDev: true,
mcpEnabled: false,
corsAllowedOrigins: [`http://localhost:${process.env.E2E_METRO_PORT}`],
});
process.on("message", (message: unknown) => {
if (message === "release") firstResponse.resolve();
});
const shutdown = async () => {
firstResponse.resolve();
await daemon.close();
process.exit(0);
};
process.once("SIGINT", () => void shutdown());
process.once("SIGTERM", () => void shutdown());
process.once("disconnect", () => void shutdown());
process.send?.({ type: "ready", port: daemon.port, serverId: daemon.daemon.getServerId() });
}
void main().catch((error: unknown) => {
process.send?.({ type: "error", error: error instanceof Error ? error.message : String(error) });
process.exit(1);
});
+7
View File
@@ -244,6 +244,13 @@ paseo plugin enable my-plugin
paseo plugin remove my-plugin
```
`paseo plugin reload <id>` reports the result of the reload itself: it waits while the daemon stops
and starts the plugin, however long that takes, and it queues behind the daemon's other plugin work.
Cancelling the command or losing the connection does not stop the daemon's work, and no listing or
log entry settles what a cancelled reload did — the [Plugin reference](/docs/plugins/reference)
covers those interim states. Running the reload again is what reports the result: it waits behind the
outstanding one, then stops and starts the plugin once more.
Bare `owner/slug` installs a reviewed registry listing. Use `git:owner/repository` for GitHub
source, or `./plugin` for a local directory. Append `:<directory>` for a plugin in a monorepo.
+6
View File
@@ -2269,6 +2269,12 @@ remove Command Center items and clear the installation's query state. An already
remains as unavailable until its matching contribution returns or the user closes it. Panel render
failures stay inside the plugin error boundary.
Reload queues behind the daemon's other plugin work, such as a running installation or preparation
command, and the daemon answers when the plugin is running again or has failed. Nothing reads the
plugin's status as the reload's outcome: while the reload is queued, `plugin ls`, Settings, and the
logs show the plugin's previous state, and while the daemon stops and starts it, a status read
reports `failed` because the plugin is absent from the runtime catalog until its start completes.
## Plugin sources
Paste one of these source identifiers into **Settings → Plugins**, or pass it to
+29 -1
View File
@@ -37,7 +37,7 @@ function jobBlocks(source) {
let currentJob;
for (const line of source.split("\n")) {
const jobMatch = /^ ([a-z0-9-]+):\s*$/.exec(line);
const jobMatch = /^ ([a-z0-9_-]+):\s*$/.exec(line);
if (jobMatch) {
currentJob = jobMatch[1];
jobs.set(currentJob, []);
@@ -303,3 +303,31 @@ test("desktop packaging smokes main pushes and only the pull requests that touch
assert.match(source, new RegExp(`${action}@[0-9a-f]{40} # v\\d+\\.\\d+\\.\\d+`));
}
});
test("stable iOS releases upload once and distribute to external testers before App Store review", () => {
const source = readFileSync(
new URL("packages/app/.eas/workflows/release-mobile.yml", repoRoot),
"utf8",
);
const jobs = jobBlocks(source);
const uploads = [...jobs.entries()].filter(
([, lines]) =>
lines.some((line) => /^ type: (submit|testflight)$/.test(line)) &&
lines.some((line) => line.includes("needs.build_ios.outputs.build_id")),
);
assert.equal(uploads.length, 1, "the iOS binary must be uploaded exactly once");
const [distributionId, distributionLines] = uploads[0];
const distribution = distributionLines.join("\n");
assert.match(distribution, /^ type: testflight$/m);
assert.match(distribution, /external_groups:\s*\n\s+- Paseo Beta/);
assert.match(distribution, /^ submit_beta_review: true$/m);
const review = jobs.get("submit_ios_for_review").join("\n");
assert.match(review, new RegExp(`needs: \\[build_ios, ${distributionId}\\]`));
assert.match(review, /APP_VERSION="\$\{\{ needs\.build_ios\.outputs\.app_version \}\}"/);
assert.match(
review,
/APP_BUILD_VERSION="\$\{\{ needs\.build_ios\.outputs\.app_build_version \}\}"/,
);
assert.match(review, /bundle exec fastlane ios submit_review/);
});
+1 -1
View File
@@ -21,7 +21,7 @@ For a local daemon, `project create` defaults to the current directory and resol
## Workspaces
**`create_workspace`** — create a workspace independently of any agent. Required: `isolation` (`local` or `worktree`). Worktree isolation supports `mode: "branch-off" | "checkout-branch" | "checkout-pr"`: use `branchName`/`baseBranch` for a new branch, `branch` for an existing branch, or `prNumber` plus optional `forge`/`projectPath` for a change request. `worktreeSlug` controls the managed path. Returns the workspace descriptor centered on `workspaceId`.
**`create_workspace`** — create a workspace independently of any agent. Required: `isolation` (`local` or `worktree`). Worktree isolation supports `mode: "branch-off" | "checkout-branch" | "checkout-pr"`: use `branchName`/`baseBranch` for a new branch, `branch` for an existing branch, or `prNumber` plus optional `forge` for a change request. Use `path` for a local directory or source checkout and `projectId` for the owning project; when no path is supplied, a worktree may resolve its source from `projectId`. `worktreeSlug` controls the managed path. Returns the workspace descriptor centered on `workspaceId`.
`background: true` hides a workspace from default lists and the sidebar while its contents remain durable and accessible; omit `background` to inherit your current workspace’s value (false without a caller), and override it only when the user explicitly asks.