Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 6973525959 test(app): reproduce synchronous layout while typing
Retain an intentionally failing diagnostic for #4810. Chromium traces count forced layouts inside real input events; the sizing contract needs a maintainer decision before changing production behavior.
2026-10-16 18:28:12 +02:00
127 changed files with 751 additions and 4850 deletions
-2
View File
@@ -85,8 +85,6 @@ valknut-report.json/
packages/server/src/server/fixtures/dictation/dictation-debug-largest.wav
packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript.txt
packages/protocol/src/generated/validation/*.aot.ts
packages/protocol/src/generated/validation/ws-outbound-schema-metadata.ts
packages/protocol/codegen/generated/
/artifacts
packages/desktop/.cache/
-134
View File
@@ -1,139 +1,5 @@
# Changelog
## 0.12.0-beta.1 - 2026-10-16
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and close to 100 fixes.
**Before upgrading:** `paseo run --background` is deprecated in favor of `--no-wait`, and `paseo permit allow` and `deny` now exit non-zero when the daemon doesn't confirm within 15 seconds
### Added
- Added background workspaces, hidden from the sidebar unless Show background is on ([#5039](https://github.com/getpaseo/paseo/pull/5039), [#7141](https://github.com/getpaseo/paseo/pull/7141) by [@gpambrozio](https://github.com/gpambrozio), [@marvin-ambrozio](https://github.com/marvin-ambrozio))
- Added a Send feedback button to the diff pane, so review comments reach an agent without opening its chat ([#6455](https://github.com/getpaseo/paseo/pull/6455))
- Added a fullscreen composer on phones ([#6531](https://github.com/getpaseo/paseo/pull/6531), [#6902](https://github.com/getpaseo/paseo/pull/6902), [#6945](https://github.com/getpaseo/paseo/pull/6945), [#6987](https://github.com/getpaseo/paseo/pull/6987), [#7130](https://github.com/getpaseo/paseo/pull/7130), [#7170](https://github.com/getpaseo/paseo/pull/7170))
- Added the sender's name to messages one agent sends another ([#6373](https://github.com/getpaseo/paseo/pull/6373))
- Added Claude Mythos 5.1 when the installed Claude Code advertises it ([#6403](https://github.com/getpaseo/paseo/pull/6403))
- Added option previews to Claude question cards ([#6171](https://github.com/getpaseo/paseo/pull/6171) by [@gabrielgiordan](https://github.com/gabrielgiordan))
- Added `paseo usage` to show account quotas, balances, and reset times from the CLI ([#6474](https://github.com/getpaseo/paseo/pull/6474))
### Improved
- Changed text selection in replies on iOS and Android to cross paragraphs, headings, and lists ([#6973](https://github.com/getpaseo/paseo/pull/6973))
- Added a copy button to code blocks on iOS and Android ([#6973](https://github.com/getpaseo/paseo/pull/6973))
- Changed wide Markdown tables on iOS and Android to scroll sideways ([#6973](https://github.com/getpaseo/paseo/pull/6973))
- Changed phone layouts to a floating header ([#6431](https://github.com/getpaseo/paseo/pull/6431))
- Moved New agent and New terminal on phones into the tab switcher ([#6431](https://github.com/getpaseo/paseo/pull/6431))
- Moved workspace scripts on phones into ⋯ → Scripts, which shows what is running ([#7122](https://github.com/getpaseo/paseo/pull/7122))
- Changed phones to open files and subagents in a closable sheet, with Open as tab for subagents ([#6827](https://github.com/getpaseo/paseo/pull/6827), [#6885](https://github.com/getpaseo/paseo/pull/6885), [#6958](https://github.com/getpaseo/paseo/pull/6958))
- Changed diff comments on phones to open in a sheet instead of an editor inside the code ([#6455](https://github.com/getpaseo/paseo/pull/6455))
- Changed Settings on phones to large tiles grouped into App and Host ([#7129](https://github.com/getpaseo/paseo/pull/7129))
- Reduced rendering work in long chats while a reply streams ([#7052](https://github.com/getpaseo/paseo/pull/7052) by [@colonelpanic8](https://github.com/colonelpanic8))
- Made reopening the sidebar about 45% faster ([#7157](https://github.com/getpaseo/paseo/pull/7157))
- Changed Gitea and Forgejo fork PR checkouts to include the fork owner in the branch name, such as `bob/patch-1` ([#6829](https://github.com/getpaseo/paseo/pull/6829) by [@alexanderadam](https://github.com/alexanderadam))
### Plugins
- Fixed plugins that import Paseo protocol types failing to install with "Could not resolve type dependency" ([#7023](https://github.com/getpaseo/paseo/pull/7023))
- Fixed `providers.waitForReady` throwing in plugins on the mobile app ([#6760](https://github.com/getpaseo/paseo/pull/6760) by [@GeorgeR](https://github.com/GeorgeR))
- Fixed the chat outline not jumping to prompts rendered by a timeline plugin ([#6690](https://github.com/getpaseo/paseo/pull/6690) by [@lukeRRD](https://github.com/lukeRRD))
- Fixed Escape stopping the running agent instead of first closing an open plugin preview ([#7110](https://github.com/getpaseo/paseo/pull/7110) by [@RRD-dev](https://github.com/RRD-dev), [@luoph](https://github.com/luoph))
### Fixed
- Fixed iOS replies with list items that render blank, and text that keeps a stale width after a resize or rotation ([#6973](https://github.com/getpaseo/paseo/pull/6973))
- Fixed plugin hosts, the CLI, and the desktop app growing to about 1 GB of memory after running for 30 to 75 minutes ([#6847](https://github.com/getpaseo/paseo/pull/6847) by [@babo-jeonguk](https://github.com/babo-jeonguk))
- Fixed the daemon running out of memory during long OpenCode v2 reply or reasoning streams ([#6262](https://github.com/getpaseo/paseo/pull/6262))
- Fixed archived ACP agents starting a provider process that kept running each time their timeline loaded ([#6766](https://github.com/getpaseo/paseo/pull/6766) by [@apple-ouyang](https://github.com/apple-ouyang))
- Fixed oversized attachments disconnecting the host in a loop instead of failing with a size error ([#6285](https://github.com/getpaseo/paseo/pull/6285))
- Fixed Download in Files failing on hosts connected over relay, SSH, or a socket ([#6595](https://github.com/getpaseo/paseo/pull/6595) by [@odoo-mvds](https://github.com/odoo-mvds), [@YASICJUNWOO](https://github.com/YASICJUNWOO), [@JrDw0](https://github.com/JrDw0))
- Fixed Changes showing no diff lines with custom Git diff settings, such as `color.diff=always` or an external diff command ([#6536](https://github.com/getpaseo/paseo/pull/6536), [#6562](https://github.com/getpaseo/paseo/pull/6562) by [@Mar-garet](https://github.com/Mar-garet))
- Fixed an incomplete font name in Appearance breaking the app's styling ([#6695](https://github.com/getpaseo/paseo/pull/6695))
- Fixed the Linux desktop window never appearing when the hidden window renders no frames ([#6726](https://github.com/getpaseo/paseo/pull/6726) by [@EhEo](https://github.com/EhEo))
- Fixed opening another agent or workspace stalling while a different agent on the same host is streaming ([#7074](https://github.com/getpaseo/paseo/pull/7074) by [@colonelpanic8](https://github.com/colonelpanic8))
- Fixed agent creation showing an error and a retry draft when the provider rejected the first prompt ([#6379](https://github.com/getpaseo/paseo/pull/6379))
- Fixed parent agents missing the finish notification when a child finished its first turn very quickly ([#6008](https://github.com/getpaseo/paseo/pull/6008) by [@ahmet-izgi](https://github.com/ahmet-izgi))
- Fixed parent agents being told a child finished when its task was stopped or reloaded ([#6660](https://github.com/getpaseo/paseo/pull/6660) by [@Mar-garet](https://github.com/Mar-garet))
- Fixed Codex chats containing U+2028 or U+2029 failing to reopen on Node 24 ([#5999](https://github.com/getpaseo/paseo/pull/5999) by [@grahameger](https://github.com/grahameger), [@jtsang4](https://github.com/jtsang4), [@kotusenka](https://github.com/kotusenka))
- Fixed Add Project directory search hanging the daemon on macOS when it entered `~/Library` ([#5789](https://github.com/getpaseo/paseo/pull/5789) by [@lardissone](https://github.com/lardissone))
- Fixed New workspace failing with "Invalid mode" when the saved mode isn't offered by the provider ([#5847](https://github.com/getpaseo/paseo/pull/5847) by [@mcowger](https://github.com/mcowger))
- Fixed answering multi-select OpenCode v2 questions failing with "Invalid answer" ([#6149](https://github.com/getpaseo/paseo/pull/6149) by [@gammons](https://github.com/gammons))
- Fixed browser screenshots failing the turn on OpenCode v2 agents ([#6330](https://github.com/getpaseo/paseo/pull/6330))
- Fixed OpenCode v2 agents receiving "HTTP 500" instead of the Paseo tool error ([#6519](https://github.com/getpaseo/paseo/pull/6519))
- Fixed rewinding a Claude conversation failing with "Session not found" when the provider sets `CLAUDE_CONFIG_DIR` ([#6325](https://github.com/getpaseo/paseo/pull/6325))
- Fixed imported Claude Code sessions taking their first prompt as the name instead of their `/rename` title ([#6930](https://github.com/getpaseo/paseo/pull/6930) by [@platanus-kr](https://github.com/platanus-kr))
- Fixed a wrongly shaped answer to a Claude question reporting success and discarding the question ([#6936](https://github.com/getpaseo/paseo/pull/6936) by [@thomasvan](https://github.com/thomasvan))
- Fixed Cursor agents showing no plan usage in their context details ([#6353](https://github.com/getpaseo/paseo/pull/6353) by [@gengjiawen](https://github.com/gengjiawen))
- Fixed the Claude context meter counting only output tokens when a proxy reports input usage at the end of the response ([#6703](https://github.com/getpaseo/paseo/pull/6703) by [@kywoo26](https://github.com/kywoo26))
- Fixed the Claude context meter showing a 1M maximum after switching back to a 200k model ([#6814](https://github.com/getpaseo/paseo/pull/6814))
- Fixed the context meter staying empty during the first turn on custom Claude `[1m]` models ([#6767](https://github.com/getpaseo/paseo/pull/6767))
- Fixed a schedule never running again after one of its runs could not be recorded ([#6334](https://github.com/getpaseo/paseo/pull/6334))
- Fixed schedules sharing a cron minute running a slot twice and skipping the next occurrence ([#6532](https://github.com/getpaseo/paseo/pull/6532) by [@keithpk](https://github.com/keithpk))
- Fixed Committed showing no changes for worktrees created from an existing branch ([#6462](https://github.com/getpaseo/paseo/pull/6462) by [@cr-2takumax](https://github.com/cr-2takumax))
- Fixed Discard and commit diffs in Changes for workspaces opened at a repository subdirectory ([#6777](https://github.com/getpaseo/paseo/pull/6777), [#6801](https://github.com/getpaseo/paseo/pull/6801), [#6882](https://github.com/getpaseo/paseo/pull/6882) by [@Mar-garet](https://github.com/Mar-garet))
- Fixed renamed and edited files showing every line as added in Changes ([#6559](https://github.com/getpaseo/paseo/pull/6559))
- Fixed renamed files opened from a commit showing as new files ([#6895](https://github.com/getpaseo/paseo/pull/6895))
- Fixed Changes showing no diff for filenames containing quotes, backslashes, or tabs ([#6583](https://github.com/getpaseo/paseo/pull/6583) by [@Mar-garet](https://github.com/Mar-garet))
- Fixed Changes dropping removed lines that start with `--`, such as SQL comments ([#6569](https://github.com/getpaseo/paseo/pull/6569) by [@Mar-garet](https://github.com/Mar-garet))
- Fixed the Diff tab staying on a spinner after the first edit in a clean workspace ([#6745](https://github.com/getpaseo/paseo/pull/6745))
- Fixed worktree creation failing after running `git init` in a project added before Git ([#6785](https://github.com/getpaseo/paseo/pull/6785))
- Fixed new worktrees not opening the terminals configured in `paseo.json` after setup ([#6999](https://github.com/getpaseo/paseo/pull/6999) by [@davidyang](https://github.com/davidyang))
- Fixed a service newly added to `paseo.json` failing to start until the daemon restarted ([#6542](https://github.com/getpaseo/paseo/pull/6542))
- Fixed workspace service URLs returning 502 when the service listens only on IPv6 localhost ([#6753](https://github.com/getpaseo/paseo/pull/6753) by [@marcelocastrobarbosa](https://github.com/marcelocastrobarbosa))
- Fixed terminals that could not be closed when their command was missing or exited immediately ([#6792](https://github.com/getpaseo/paseo/pull/6792) by [@KiraPC](https://github.com/KiraPC))
- Fixed CJK terminal text gaining spaces between characters after returning to a workspace ([#6452](https://github.com/getpaseo/paseo/pull/6452) by [@jasonhnd](https://github.com/jasonhnd), [@liujin0506](https://github.com/liujin0506), [@seob717](https://github.com/seob717))
- Fixed the self-hosted web app failing to connect through a reverse proxy on port 80 or 443 ([#6638](https://github.com/getpaseo/paseo/pull/6638) by [@kiawin](https://github.com/kiawin))
- Fixed assistant file links with spaces or non-ASCII characters failing with ENOENT ([#6520](https://github.com/getpaseo/paseo/pull/6520) by [@apple-ouyang](https://github.com/apple-ouyang), [@sunshinehomeyu-blip](https://github.com/sunshinehomeyu-blip))
- Fixed local images in agent replies failing to load when their path contains spaces or non-ASCII characters ([#6877](https://github.com/getpaseo/paseo/pull/6877))
- Fixed Markdown file previews not showing images referenced by relative paths ([#6750](https://github.com/getpaseo/paseo/pull/6750) by [@apple-ouyang](https://github.com/apple-ouyang))
- Fixed iOS sidebar rows staying highlighted and swallowing the next tap after a brief hold ([#7159](https://github.com/getpaseo/paseo/pull/7159) by [@kassamkhoja](https://github.com/kassamkhoja))
- Fixed Android chats scrolling when you long-press to select text ([#7131](https://github.com/getpaseo/paseo/pull/7131) by [@RRD-dev](https://github.com/RRD-dev), [@luoph](https://github.com/luoph))
- Fixed sidebar rows, footer buttons, and Settings items showing no feedback when tapped on touch devices ([#7150](https://github.com/getpaseo/paseo/pull/7150))
- Fixed Page Up and Page Down not scrolling the chat after clicking a message ([#7033](https://github.com/getpaseo/paseo/pull/7033) by [@panta82](https://github.com/panta82))
- Fixed Add Project keyboard selection moving to directories scrolled out of view ([#7081](https://github.com/getpaseo/paseo/pull/7081) by [@superheher](https://github.com/superheher))
- Fixed Enter replacing an email address in the composer with a file mention ([#6471](https://github.com/getpaseo/paseo/pull/6471) by [@jasonhnd](https://github.com/jasonhnd))
- Fixed letter shortcuts not running with non-Latin keyboard layouts such as Russian ([#6755](https://github.com/getpaseo/paseo/pull/6755))
- Fixed shortcut recording saving the QWERTY key instead of the typed letter on layouts such as Dvorak ([#6652](https://github.com/getpaseo/paseo/pull/6652))
- Fixed rebinding Jump to tab or Jump to workspace keeping only the pressed number instead of 1–9; record existing overrides again ([#7065](https://github.com/getpaseo/paseo/pull/7065) by [@dbunk903](https://github.com/dbunk903))
- Fixed Previous and Next workspace shortcuts stopping at workspace 9 ([#6586](https://github.com/getpaseo/paseo/pull/6586) by [@IEatCodeDaily](https://github.com/IEatCodeDaily))
- Fixed unreadable Android status bar icons in the Light theme and after the system theme changes ([#6707](https://github.com/getpaseo/paseo/pull/6707) by [@terayang](https://github.com/terayang))
- Fixed the pairing QR code getting clipped on iOS and Android ([#6758](https://github.com/getpaseo/paseo/pull/6758) by [@fdkevin0](https://github.com/fdkevin0))
- Fixed a `write EPIPE` error dialog on Linux desktop when the launcher stops reading console output ([#6639](https://github.com/getpaseo/paseo/pull/6639))
- Fixed `paseo permit allow` and `deny` reporting success over SSH while the request stayed pending ([#6607](https://github.com/getpaseo/paseo/pull/6607) by [@budivoogt](https://github.com/budivoogt))
- Fixed files and folders whose names start with two dots, like `..notes.txt`, being hidden and rejected ([#6604](https://github.com/getpaseo/paseo/pull/6604), [#6720](https://github.com/getpaseo/paseo/pull/6720) by [@Mar-garet](https://github.com/Mar-garet))
- Fixed Markdown previews dropping table rows after a `<` that isn't HTML, such as `List<Item>` ([#6370](https://github.com/getpaseo/paseo/pull/6370) by [@gengjiawen](https://github.com/gengjiawen))
- Fixed a literal heading tag appearing when an HTML heading contains a linked image ([#6650](https://github.com/getpaseo/paseo/pull/6650))
- Fixed Astro diffs with an empty `{}` expression failing to render ([#6763](https://github.com/getpaseo/paseo/pull/6763))
- Fixed slash command argument hints missing from autocomplete for ACP providers ([#6920](https://github.com/getpaseo/paseo/pull/6920) by [@bwestlund17](https://github.com/bwestlund17))
- Fixed terminal profiles configured with the `agy` icon showing a generic bot instead of the Antigravity icon ([#6938](https://github.com/getpaseo/paseo/pull/6938) by [@noahg9](https://github.com/noahg9))
- Fixed the Pi thinking level not updating when a Pi extension changes it ([#6741](https://github.com/getpaseo/paseo/pull/6741))
- Fixed Pi subagents from `@tintinweb/pi-subagents` staying running after their turn limit ([#6594](https://github.com/getpaseo/paseo/pull/6594) by [@Chen-DaYi](https://github.com/Chen-DaYi))
- Fixed manual `/compact` on OMP agents showing no progress and leaving the context meter stale ([#6106](https://github.com/getpaseo/paseo/pull/6106))
- Fixed voice mode TTS rejecting model and voice names from OpenAI-compatible servers ([#6060](https://github.com/getpaseo/paseo/pull/6060) by [@khimaros](https://github.com/khimaros))
- Fixed the xhigh thinking option showing "Extra high" instead of its configured label ([#6005](https://github.com/getpaseo/paseo/pull/6005) by [@EliorFureraj](https://github.com/EliorFureraj))
- Fixed the Remove button going off screen for long custom model names in provider settings ([#6507](https://github.com/getpaseo/paseo/pull/6507))
- Fixed the starting-ref picker not finding GitLab merge requests and issues by number ([#6525](https://github.com/getpaseo/paseo/pull/6525))
- Fixed the starting-ref picker hiding change requests that match only in their description ([#6558](https://github.com/getpaseo/paseo/pull/6558))
- Fixed @-mentions not finding plan files under `.cursor/plans` ([#6641](https://github.com/getpaseo/paseo/pull/6641) by [@chyendongnhanh338](https://github.com/chyendongnhanh338))
- Fixed Paseo replacing a symlinked `config.json` with a regular file when saving settings ([#6023](https://github.com/getpaseo/paseo/pull/6023))
- Fixed the published config schema rejecting valid settings such as `agentProfiles` and `plugins` ([#6621](https://github.com/getpaseo/paseo/pull/6621) by [@ragokan](https://github.com/ragokan))
- Fixed duplicating a folder turning its relative symlinks into absolute links to the original ([#6771](https://github.com/getpaseo/paseo/pull/6771) by [@Mar-garet](https://github.com/Mar-garet))
- Fixed renaming an untracked file like `[id].tsx` failing when a tracked file matches it as a pattern ([#6573](https://github.com/getpaseo/paseo/pull/6573) by [@Mar-garet](https://github.com/Mar-garet))
- Fixed saving workspace files with long names failing with `ENAMETOOLONG` ([#6658](https://github.com/getpaseo/paseo/pull/6658) by [@Mar-garet](https://github.com/Mar-garet))
- Fixed worktree creation and Changes failing for projects whose folder name ends in a space ([#6686](https://github.com/getpaseo/paseo/pull/6686))
- Fixed merging a worktree when the base branch's worktree path contains a newline or ends in a space ([#6668](https://github.com/getpaseo/paseo/pull/6668) by [@Mar-garet](https://github.com/Mar-garet))
- Fixed `paseo agent ls` listing only the first 200 agents ([#6033](https://github.com/getpaseo/paseo/pull/6033) by [@hteo1337](https://github.com/hteo1337))
- Fixed `paseo worktree ls` and `paseo worktree archive` failing with "cwd or repoRoot is required" ([#6467](https://github.com/getpaseo/paseo/pull/6467) by [@jasonhnd](https://github.com/jasonhnd))
- Fixed `paseo worktree ls` listing an archived worktree for up to 15 seconds ([#6545](https://github.com/getpaseo/paseo/pull/6545) by [@Mar-garet](https://github.com/Mar-garet))
- Fixed `paseo logs --since` printing activity from before the requested timestamp ([#6514](https://github.com/getpaseo/paseo/pull/6514) by [@Mar-garet](https://github.com/Mar-garet))
- Fixed `paseo logs --follow` splitting words across lines as text streams in ([#6528](https://github.com/getpaseo/paseo/pull/6528) by [@fiorelorenzo](https://github.com/fiorelorenzo))
- Fixed `paseo logs` printing reasoning after the reply that followed it ([#6546](https://github.com/getpaseo/paseo/pull/6546))
- Fixed the daemon keeping files open after a canceled HTTP download ([#6567](https://github.com/getpaseo/paseo/pull/6567) by [@Mar-garet](https://github.com/Mar-garet))
- Fixed Linux desktop windows showing a generic icon instead of the Paseo icon ([#6645](https://github.com/getpaseo/paseo/pull/6645) by [@whme](https://github.com/whme), [@panta82](https://github.com/panta82))
- Fixed Google Play listing Android devices without a camera as incompatible ([#6339](https://github.com/getpaseo/paseo/pull/6339))
- Fixed `nix build .#desktop` running out of memory on Macs with less than 15 GB of RAM ([#6537](https://github.com/getpaseo/paseo/pull/6537) by [@truelecter](https://github.com/truelecter))
- Fixed usage providers changing places after Refresh all ([#6947](https://github.com/getpaseo/paseo/pull/6947) by [@sleroq](https://github.com/sleroq))
- Fixed the OpenCode Go usage icon being nearly invisible on dark themes ([#6362](https://github.com/getpaseo/paseo/pull/6362) by [@az33zy](https://github.com/az33zy))
## 0.11.4 - 2026-10-15
[Hoplite](https://hoplite.sh) joins Paseo as our first sponsor through the company sponsorship program. Hoplite builds cloud coding agents that turn tasks from GitHub, Linear, Slack and more into pull requests. Thank you to the Hoplite team for supporting Paseo.
+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, using `packages/app/.eas/workflows/release-mobile.yml` (iOS + Android production builds and distribution).
- The EAS GitHub app on Expo servers (iOS + Android production builds + store submit). There is no workflow file in this repo for it.
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release).
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.
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.
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.
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.
`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.
+1 -1
View File
@@ -8,7 +8,7 @@ Native selection belongs to the library's text view. Keep an assistant message i
Workspace file links use the shared file-link resolver. The workspace supplies its client, root and open-file action once; chat can supply the agent's more specific context. URLs use the same link press path. Recognized inline-code paths stay tappable. The library callback carries the tapped occurrence’s syntax context. Preserve it when opening files: explicit Markdown links decode URL escapes, while recognized inline-code paths preserve literal percent characters and use suffix lookup. Identical URL strings in different occurrences must stay distinct. Explicit absolute workspace links receive a relative hover title; existing authored titles remain intact.
Resolve workspace image paths over the existing session RPC, then give the library a cached native file URL or web blob URL. HTTP(S) images pass through unchanged. This preserves relay, SSH and local socket connections, which do not expose a daemon HTTP endpoint. Each mounted renderer owns its image acquisition identity, so a later reply reads a rewritten path again while the earlier mounted reply retains its snapshot. Image taps open the attachment lightbox. File previews resolve relative images against the Markdown file's directory. An image alone on its line becomes its own paragraph before the library sees it, so it renders as a block on every platform; the library only treats sole-child paragraph images as blocks, and with hard soft breaks every newline is a break node, which left own-line images inline on iOS and web. Images that share a line with text stay inline.
Resolve workspace image paths over the existing session RPC, then give the library a cached native file URL or web blob URL. HTTP(S) images pass through unchanged. This preserves relay, SSH and local socket connections, which do not expose a daemon HTTP endpoint. Each mounted renderer owns its image acquisition identity, so a later reply reads a rewritten path again while the earlier mounted reply retains its snapshot. Image taps open the attachment lightbox. File previews resolve relative images against the Markdown file's directory.
## Engine constraints
+5 -22
View File
@@ -300,33 +300,16 @@ done
### Android audio focus interruptions
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.
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:
```bash
# Start voice mode in an existing composer, then background Paseo.
adb shell am start -n sh.paseo/.MainActivity
# Start voice mode in an existing composer, then background Paseo with Home.
adb emu gsm call 5551234
# Foreground Paseo while the call is still ringing, then end the simulated call.
adb emu gsm cancel 5551234
# Foreground Paseo while the call is still ringing.
```
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.
Expected result: Paseo does not throw `RuntimeException: Audio focus request failed`; native audio reports an interruption and voice mode stops or pauses coherently.
### Releasing the audio session when idle
+5 -6
View File
@@ -6,9 +6,7 @@ The reason is mobile performance. A captured 353 KB provider snapshot cost about
## Runtime Path
`packages/protocol/src/validation/ws-outbound.ts` is the shipped boundary. It validates the envelope with a generated validator that checks only each session message's `type` tag, then runs the generated validator for that one message type and returns the validated data. Issue paths from the message validator, including nested union branches, are prefixed with `message`, so callers see the same issues as when one validator covered the whole envelope. It does not normalize, repair, or re-validate the generated result.
Keep one generated function per message type. zod-aot inlines every nested schema into the function it compiles, so compiling `WSOutboundMessageSchema` as a whole produced one 11 MB function. On V8 (daemon clients, plugin hosts, the CLI, and Electron), the Maglev tier compiled that function once it was hot, allocating about 800 MB and stalling for seconds, and compiled it again after each deoptimization. Hermes has no JIT, so mobile never showed it. `packages/protocol/tests/validation/ws-outbound.test.ts` fails if a generated function grows past 1 MB.
`packages/protocol/src/validation/ws-outbound.ts` is the shipped boundary. It calls the generated `WSOutboundMessageSchema.safeParse` and returns the validated data. It does not normalize, repair, or re-validate the generated result.
Generated validators preserve unknown keys where Zod object parsing strips them. The client dispatch path uses known `type` and payload fields, so this passthrough behavior is accepted for inbound messages. The wire format is unchanged.
@@ -18,10 +16,11 @@ Provider model normalization is a parser-side compatibility shim in the client c
The protocol package owns generation.
- `packages/protocol/src/validation/ws-outbound-envelope.ts` defines the tag-only envelope schema and exposes the session message schemas that get their own validators.
- `packages/protocol/scripts/generate-validation-aot.mjs` writes the zod-aot discovery entry and the runtime schema metadata, runs the exact-pinned compiler, and applies the small local compiler patches before generation. It also emits the `type` → validator map the boundary dispatches through.
- `packages/protocol/codegen/ws-outbound.compile.ts` is the build-time zod-aot discovery entry.
- `packages/protocol/scripts/generate-validation-aot.mjs` runs the exact-pinned compiler and applies the small local compiler patches before generation.
- `packages/protocol/scripts/watch-validation-aot.mjs` reruns generation while editing protocol sources.
- `packages/protocol/codegen/generated/ws-outbound.compile.ts`, `packages/protocol/src/generated/validation/ws-outbound.aot.ts`, and `packages/protocol/src/generated/validation/ws-outbound-schema-metadata.ts` are generated and gitignored. The metadata module lets generated code reach schema fallback/default values.
- `packages/protocol/src/generated/validation/ws-outbound.aot.ts` is generated runtime code and is gitignored.
- `packages/protocol/src/validation/ws-outbound-schema-metadata.ts` is runtime schema metadata for zod-aot fallback/default references.
Generation runs from protocol-owned lifecycle hooks: `prebuild`, `pretypecheck`, `pretest`, and `watch`. Installs do not run generation: published packages consume protocol from prebuilt `dist`, and local build/typecheck/test flows generate the source file at the point it is actually needed.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

+10 -13
View File
@@ -305,14 +305,12 @@ 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 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.
- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads to TestFlight, and a Fastlane lane submits the 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.
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.
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.
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.
@@ -336,7 +334,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 distribute_ios, submit_android,
# Inspect the full release workflow, including submit_ios, submit_android,
# and submit_ios_for_review.
npx eas workflow:view <workflow-run-id> --json
@@ -349,17 +347,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 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.
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.
For the `Release Mobile` EAS workflow, these jobs must pass:
- `build_ios` — iOS binary built
- `distribute_ios` — iOS binary uploaded once, added to `Paseo Beta`, and submitted for Beta App Review
- `submit_ios` — iOS binary uploaded to App Store Connect/TestFlight
- `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 `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.
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.
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.
@@ -382,9 +380,8 @@ 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, external
TestFlight distribution, Beta App Review submission, and store submission job
for the release commit. For betas, confirm
For stable releases, also confirm every required mobile build, upload, store
submission, and review-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.
@@ -399,7 +396,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, 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.",
"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.",
}
```
@@ -683,7 +680,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 `distribute_ios` succeeds, uploading the binary once, distributing it to `Paseo Beta`, and submitting it for Beta App Review
- [ ] EAS iOS `submit_ios` succeeds, uploading the build to App Store Connect/TestFlight
- [ ] 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-1WmrHvE68Lzyil4On7xtsPMQTODXZ3Jb3/juUg3nHi4=
sha256-Sw9WSOnvebpXgty0WACJrRq+pkKuynLMxJg6D/SnyRk=
+28 -28
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.12.0-beta.1",
"version": "0.11.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.12.0-beta.1",
"version": "0.11.2",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -36349,7 +36349,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.12.0-beta.1",
"version": "0.11.2",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37469,12 +37469,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.12.0-beta.1",
"version": "0.11.2",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.12.0-beta.1",
"@getpaseo/protocol": "0.12.0-beta.1",
"@getpaseo/server": "0.12.0-beta.1",
"@getpaseo/client": "0.11.2",
"@getpaseo/protocol": "0.11.2",
"@getpaseo/server": "0.11.2",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37721,10 +37721,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.12.0-beta.1",
"version": "0.11.2",
"dependencies": {
"@getpaseo/protocol": "0.12.0-beta.1",
"@getpaseo/relay": "0.12.0-beta.1",
"@getpaseo/protocol": "0.11.2",
"@getpaseo/relay": "0.11.2",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37735,7 +37735,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.12.0-beta.1",
"version": "0.11.2",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37979,7 +37979,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.12.0-beta.1",
"version": "0.11.2",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38875,7 +38875,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.12.0-beta.1",
"version": "0.11.2",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38904,14 +38904,14 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.12.0-beta.1",
"version": "0.11.2",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.12.0-beta.1",
"@getpaseo/protocol": "0.12.0-beta.1",
"@getpaseo/client": "0.11.2",
"@getpaseo/protocol": "0.11.2",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -38921,8 +38921,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.12.0-beta.1",
"@getpaseo/protocol": "0.12.0-beta.1",
"@getpaseo/client": "0.11.2",
"@getpaseo/protocol": "0.11.2",
"react": "~19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -38944,7 +38944,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.12.0-beta.1",
"version": "0.11.2",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -38971,7 +38971,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.12.0-beta.1",
"version": "0.11.2",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -39189,16 +39189,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.12.0-beta.1",
"version": "0.11.2",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.12.0-beta.1",
"@getpaseo/highlight": "0.12.0-beta.1",
"@getpaseo/plugin": "0.12.0-beta.1",
"@getpaseo/protocol": "0.12.0-beta.1",
"@getpaseo/relay": "0.12.0-beta.1",
"@getpaseo/client": "0.11.2",
"@getpaseo/highlight": "0.11.2",
"@getpaseo/plugin": "0.11.2",
"@getpaseo/protocol": "0.11.2",
"@getpaseo/relay": "0.11.2",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -40119,7 +40119,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.12.0-beta.1",
"version": "0.11.2",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -41727,7 +41727,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.12.0-beta.1",
"version": "0.11.2",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.12.0-beta.1",
"version": "0.11.2",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -23,16 +23,13 @@ jobs:
platform: android
profile: production
distribute_ios:
name: Distribute iOS to TestFlight
submit_ios:
name: Submit iOS
needs: [build_ios]
type: testflight
type: submit
params:
build_id: ${{ needs.build_ios.outputs.build_id }}
profile: production
external_groups:
- Paseo Beta
submit_beta_review: true
submit_android:
name: Submit Android
@@ -44,7 +41,7 @@ jobs:
submit_ios_for_review:
name: Submit iOS for App Store review
needs: [build_ios, distribute_ios]
needs: [build_ios, submit_ios]
environment: production
runs_on: macos-medium
steps:
@@ -0,0 +1,98 @@
import { writeFile } from "node:fs/promises";
import { expect, test, type Page } from "../support/fixtures";
import { seedMockAgentWorkspace, openAgentRoute } from "../support/helpers/mock-agent";
import { composerLocator, expectComposerVisible } from "../support/helpers/composer";
import { awaitAssistantMessage, expectAgentIdle } from "../support/helpers/agent-stream";
interface TraceEvent {
name: string;
ph: string;
ts: number;
dur?: number;
tid: number;
args?: { data?: { type?: string } };
}
/** Diagnostic target for #4810. The sizing contract still needs a maintainer decision. */
test("typing a sentence does not force synchronous layout in the input handler", async ({
page,
}, testInfo) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-layout-",
title: "Composer layout",
initialPrompt: "Populate conversation",
model: "ten-second-stream",
});
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await awaitAssistantMessage(page);
await expectAgentIdle(page);
await warmComposer(page);
const result = await traceTypingSentence(page);
await writeFile(testInfo.outputPath("chromium.json"), result.trace);
await testInfo.attach("synchronous-input-layout", {
body: JSON.stringify(result.counts),
contentType: "application/json",
});
expect(result.counts.inputEvents).toBe(result.text.length);
expect(result.counts.layouts, "Input handlers must not synchronously force layout").toBe(0);
} finally {
await agent.cleanup();
}
});
async function warmComposer(page: Page): Promise<void> {
const composer = composerLocator(page);
await composer.fill("Warm up");
await composer.fill("");
await composer.focus();
await page.evaluate(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
),
);
}
async function traceTypingSentence(page: Page) {
const cdp = await page.context().newCDPSession(page);
await cdp.send("Tracing.start", {
categories: "devtools.timeline",
transferMode: "ReturnAsStream",
});
const text = "The quick brown fox jumps over the lazy dog. ".repeat(3);
await composerLocator(page).pressSequentially(text);
await expect(composerLocator(page)).toHaveValue(text);
const completed = new Promise<string>((resolve) =>
cdp.once("Tracing.tracingComplete", (e) => resolve(e.stream!)),
);
await cdp.send("Tracing.end");
const stream = await completed;
let trace = "";
for (;;) {
const chunk = await cdp.send("IO.read", { handle: stream });
trace += chunk.data;
if (chunk.eof) break;
}
await cdp.send("IO.close", { handle: stream });
await cdp.detach();
const events = (JSON.parse(trace) as { traceEvents: TraceEvent[] }).traceEvents;
const inputs = events.filter((e) => e.name === "EventDispatch" && e.args?.data?.type === "input");
const nestedEvents = events.filter(
(e) =>
e.ph === "X" &&
inputs.some(
(input) => input.tid === e.tid && e.ts >= input.ts && e.ts < input.ts + (input.dur ?? 0),
),
);
return {
text,
trace,
counts: {
inputEvents: inputs.length,
layouts: nestedEvents.filter((e) => e.name === "Layout").length,
styleRecalculations: nestedEvents.filter((e) => e.name === "UpdateLayoutTree").length,
},
};
}
@@ -105,37 +105,6 @@ 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-",
@@ -1,110 +0,0 @@
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();
}
});
}
@@ -1,42 +0,0 @@
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,4 +1,3 @@
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";
@@ -317,47 +316,6 @@ 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>>,
@@ -722,155 +680,3 @@ 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,16 +1,5 @@
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 { 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 { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import {
chooseQuestionOption,
continueToNextQuestion,
@@ -141,204 +130,3 @@ 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") });
},
);
@@ -21,15 +21,6 @@ test("project and workspace kebabs open action sheets on compact layouts", async
await page.getByRole("button", { name: "Open menu", exact: true }).click();
await waitForSidebarHydration(page);
await page.getByTestId("sidebar-display-preferences-menu").click();
await expect(page.getByRole("menuitem")).toHaveText([
/^Grouping/,
/^Title/,
"Show",
"Show background",
]);
await closeMenuSheet(page);
await page
.getByTestId(`sidebar-project-kebab-${projectEquivalenceViewKey(seeded.projectKey)}`)
.click();
@@ -29,16 +29,6 @@ test.describe("Sidebar project filter", () => {
await expect(alphaRow).toBeVisible({ timeout: 30_000 });
await expect(betaRow).toBeVisible({ timeout: 30_000 });
await page.getByTestId("sidebar-display-preferences-menu").click();
await expect(page.getByRole("menuitem")).toHaveText([
/^Grouping/,
/^Title/,
"Show",
"Project",
"Show background",
]);
await closeSidebarDisplayPreferences(page);
await openSidebarProjectFilter(page);
await expect(page.getByTestId("sidebar-project-filter-all")).toBeVisible();
await toggleProjectFilter(page, alpha.projectKey);
@@ -1,71 +0,0 @@
import { ACP_PROVIDER_ICON_SVGS } from "../../src/assets/acp-provider-icons";
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { openNewWorkspaceComposer } from "../support/helpers/new-workspace";
import { openLaunchMenu } from "../support/helpers/new-workspace-launch";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
// Harmless commands: this journey opens the profile menu without launching them.
test.use({
e2eDaemonConfig: {
version: 1,
daemon: {
terminalProfiles: [
{ id: "antigravity", name: "Antigravity artwork", command: "/bin/sh", icon: "agy" },
{ id: "cursor", name: "Cursor artwork", command: "/bin/sh", icon: "cursor" },
{ id: "unknown", name: "Unknown artwork", command: "/bin/sh", icon: "unknown-icon" },
],
},
},
});
function profileIcon(page: Page, name: string) {
return page.getByRole("menuitem", { name: new RegExp(`^${name} artwork`) }).locator("svg");
}
async function expectBundledArtwork(page: Page, name: string, artwork: string) {
const icon = profileIcon(page, name);
await expect(icon).toBeVisible();
const expectedPaths = await page.evaluate((xml) => {
const document = new DOMParser().parseFromString(xml, "image/svg+xml");
return Array.from(document.querySelectorAll("path"), (path) => path.getAttribute("d")!);
}, artwork);
await expect(icon.locator("path")).toHaveCount(expectedPaths.length);
for (const [index, path] of expectedPaths.entries()) {
await expect(icon.locator("path").nth(index)).toHaveAttribute("d", path);
}
await expect(icon).toHaveAttribute("width", "14");
await expect(icon).toHaveAttribute("height", "14");
const terminalColor = await page
.getByRole("menuitem", { name: "Terminal", exact: true })
.locator("svg")
.evaluate((element) => getComputedStyle(element).stroke);
await expect(icon.locator("path").first()).toHaveCSS("fill", terminalColor);
}
async function expectUnknownArtworkFallback(page: Page) {
const icon = profileIcon(page, "Unknown");
await expect(icon).toBeVisible();
await expect(icon.locator('path[d="M12 8V4H8"]')).toHaveCount(1);
}
test("terminal profiles show bundled artwork while unknown icons keep the Bot fallback", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "terminal-icons-" });
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: workspace.projectKey,
projectDisplayName: workspace.projectDisplayName,
});
await openLaunchMenu(page);
await expectBundledArtwork(page, "Antigravity", ACP_PROVIDER_ICON_SVGS.agy);
await expectBundledArtwork(page, "Cursor", ACP_PROVIDER_ICON_SVGS.cursor);
await expectUnknownArtworkFallback(page);
} finally {
await workspace.cleanup();
}
});
@@ -1,18 +0,0 @@
context platform=ios timeout=60000 retries=0
# Start with an empty composer in an idle chat or new-agent draft on an iPhone with the software keyboard enabled.
# Expand a long draft with the keyboard open; Exit fullscreen must remain reachable.
open sh.paseo.debug
wait "editable=true" 60000
fill "editable=true" "iOS fullscreen composer smoke.\n\nLine 1: Check that the composer keeps this draft when expanded and collapsed.\nLine 2: Check that the composer keeps this draft when expanded and collapsed.\nLine 3: Check that the composer keeps this draft when expanded and collapsed.\nLine 4: Check that the composer keeps this draft when expanded and collapsed.\nLine 5: Check that the composer keeps this draft when expanded and collapsed.\nLine 6: Check that the composer keeps this draft when expanded and collapsed.\nLine 7: Check that the composer keeps this draft when expanded and collapsed.\nLine 8: Check that the composer keeps this draft when expanded and collapsed.\nLine 9: Check that the composer keeps this draft when expanded and collapsed.\nLine 10: Check that the composer keeps this draft when expanded and collapsed.\nLine 11: Check that the composer keeps this draft when expanded and collapsed.\nLine 12: Check that the composer keeps this draft when expanded and collapsed.\nLine 13: Check that the composer keeps this draft when expanded and collapsed.\nLine 14: Check that the composer keeps this draft when expanded and collapsed."
press "label=\"Fullscreen\""
wait "label=\"Exit fullscreen\" role=button" 5000
is visible "label=\"Exit fullscreen\" role=button"
press "label=\"Exit fullscreen\" role=button"
wait "label=\"Fullscreen\" role=button" 5000
is visible "label=\"Fullscreen\" role=button"
press "label=\"Fullscreen\""
wait "label=\"Exit fullscreen\" role=button" 5000
is visible "label=\"Exit fullscreen\" role=button"
press "label=\"Exit fullscreen\" role=button"
wait "label=\"Fullscreen\" role=button" 5000
is visible "label=\"Fullscreen\" role=button"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.12.0-beta.1",
"version": "0.11.2",
"private": true,
"main": "index.ts",
"scripts": {
+62 -54
View File
@@ -87,10 +87,13 @@ import {
} from "./bottom-anchor-controller";
import { createAssistantImageOccurrenceKey } from "@/assistant-image/acquisition-cache";
import { AssistantSelectionCopySurface } from "@/assistant-selection-copy/surface";
import { AssistantFileLinkResolverProvider, createInlinePathOpener } from "@/assistant-file-links";
import {
AssistantFileLinkResolverProvider,
normalizeInlinePathTarget,
} from "@/assistant-file-links";
import {
createWorkspaceFileTabTarget,
type WorkspaceFileLocation,
normalizeWorkspaceFileLocation,
type OpenFileDisposition,
type WorkspaceFileOpenRequest,
} from "@/workspace/file-open";
@@ -384,7 +387,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
);
const workspaceRoot = context.cwd?.trim() || "";
const { revealDirectory } = useFileExplorerActions({
const { requestDirectoryListing } = useFileExplorerActions({
serverId: resolvedServerId,
workspaceId: context.workspaceId,
workspaceRoot,
@@ -423,53 +426,63 @@ 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 (isActive) void inlinePathOpener.open(target, disposition);
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",
});
},
);
@@ -506,6 +519,7 @@ 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);
@@ -1517,17 +1531,11 @@ 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,5 +10,3 @@ export {
} from "./provider";
export type { AssistantFileLinkSource } from "./resolver";
export { useAssistantFileLinkActions, useFileLink } from "./use-file-link";
export { createInlinePathOpener } from "./path-open";
@@ -23,8 +23,6 @@ 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
@@ -236,7 +234,7 @@ export function classifyAssistantFileLink(
return null;
}
if (!options.explicitLink && isAmbiguousWorkspaceCandidate(trimmed, target, options)) {
if (isAmbiguousWorkspaceCandidate(trimmed, target, options)) {
return {
kind: "ambiguousFileCandidate",
target,
@@ -280,7 +278,6 @@ export function parseAssistantFileLink(
const relativeTarget = parseWorkspaceRelativeFileLink(trimmed, {
workspaceRoot: options.workspaceRoot,
decodeHref: options.decodeHref,
explicitLink: options.explicitLink,
});
if (relativeTarget) {
return relativeTarget;
@@ -414,7 +411,7 @@ function parseWorkspaceRelativeFileLink(
function parseLocalPathParts(
value: string,
options: Pick<AssistantHrefParseOptions, "decodeHref" | "explicitLink"> = {},
options: Pick<AssistantHrefParseOptions, "decodeHref"> = {},
): {
path: string;
lines: Pick<InlinePathTarget, "lineStart" | "lineEnd">;
@@ -440,7 +437,7 @@ function parseLocalPathParts(
if (options.decodeHref) {
pathPart = safeDecodeURIComponent(pathPart);
}
if (!options.explicitLink && !isPlausibleAssistantLocalPath(pathPart)) {
if (!isPlausibleAssistantLocalPath(pathPart)) {
return null;
}
return {
@@ -461,7 +458,7 @@ function parseLocalPathParts(
decodedBeforeHash = safeDecodeURIComponent(beforeHash);
}
const plausiblePath = parseInlinePathToken(decodedBeforeHash)?.path ?? decodedBeforeHash;
if (!options.explicitLink && !isPlausibleAssistantLocalPath(plausiblePath)) {
if (!isPlausibleAssistantLocalPath(plausiblePath)) {
return null;
}
@@ -1,104 +0,0 @@
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([]);
});
@@ -1,74 +0,0 @@
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,63 +18,6 @@ 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,7 +117,6 @@ 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,7 +2,6 @@ export interface AudioEngineCallbacks {
onCaptureData(pcm: Uint8Array): void;
onVolumeLevel(level: number): void;
onInterruption?(): void;
onCaptureAvailabilityChange?(available: boolean): void;
onError?(error: Error): void;
}
@@ -26,8 +25,3 @@ export interface AudioEngine {
clearQueue(): void;
isPlaying(): boolean;
}
export interface AudioEngineOptions {
traceLabel?: string;
captureMode?: "conversation" | "dictation";
}
+3 -18
View File
@@ -1,9 +1,4 @@
import type {
AudioEngine,
AudioEngineCallbacks,
AudioEngineOptions,
AudioPlaybackSource,
} from "./audio-engine-types";
import type { AudioEngine, AudioEngineCallbacks, AudioPlaybackSource } from "./audio-engine-types";
import { createAudioPlayer, setAudioModeAsync } from "expo-audio";
import { File, Paths } from "expo-file-system";
@@ -13,7 +8,7 @@ import { playPcm16 } from "./pcm";
export function createAudioEngine(
callbacks: AudioEngineCallbacks,
options?: AudioEngineOptions,
_options?: { traceLabel?: string },
): AudioEngine {
const native = require("@getpaseo/expo-two-way-audio");
@@ -52,12 +47,6 @@ 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;
}
@@ -191,10 +180,7 @@ export function createAudioEngine(
try {
await ensureMicrophonePermission();
await ensureInitialized();
const isRecording =
options?.captureMode === "dictation"
? native.startDictation()
: native.toggleRecording(true);
const isRecording = native.toggleRecording(true);
if (!isRecording) {
throw new Error(
"Microphone capture could not start because Android audio focus is unavailable.",
@@ -215,7 +201,6 @@ export function createAudioEngine(
refs.captureActive = false;
refs.muted = false;
callbacks.onVolumeLevel(0);
callbacks.onCaptureAvailabilityChange?.(true);
releaseSessionIfIdle();
},
+2 -7
View File
@@ -1,11 +1,6 @@
import { parsePcmSampleRate, resampleToPcm16 } from "./pcm";
import { isElectronRuntime } from "@/desktop/host";
import type {
AudioEngine,
AudioEngineCallbacks,
AudioEngineOptions,
AudioPlaybackSource,
} from "./audio-engine-types";
import type { AudioEngine, AudioEngineCallbacks, AudioPlaybackSource } from "./audio-engine-types";
import { createPlaybackQueue } from "./playback";
@@ -51,7 +46,7 @@ async function decodeAudioData(context: AudioContext, buffer: ArrayBuffer): Prom
export function createAudioEngine(
callbacks: AudioEngineCallbacks,
_options?: AudioEngineOptions,
_options?: { traceLabel?: string },
): AudioEngine {
const refs: {
playbackContext: AudioContext | null;
@@ -87,9 +87,7 @@ export function DictationControls({
<View style={styles.meterWrapper}>
<VolumeMeter volume={volume} isMuted={false} isSpeaking={false} orientation="horizontal" />
</View>
<Text style={timerTextStyle}>
{status === "paused" ? t("message.dictation.paused") : formatDuration(duration)}
</Text>
<Text style={timerTextStyle}>{formatDuration(duration)}</Text>
<View style={styles.actionGroup}>
<Pressable
onPress={handleCancel}
@@ -212,11 +210,6 @@ 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
@@ -1,8 +1,5 @@
import { fromMarkdown } from "mdast-util-from-markdown";
import { toNativeLinkRecognition } from "react-native-enriched-markdown/src/utils/regexParser";
import { parseMarkdown } from "react-native-enriched-markdown/src/web/parseMarkdown";
import { markInlineImages } from "react-native-enriched-markdown/src/web/utils";
import type { ASTNode } from "react-native-enriched-markdown/src/web/types";
import { QueryClient } from "@tanstack/react-query";
import { decodeMarkdownImage, getMarkdownImageStatus } from "./image-decoding";
import { describe, expect, it } from "vitest";
@@ -34,56 +31,6 @@ describe("shared Markdown source adaptation", () => {
"> - ![Image](<file:///cache/a.png>)\n\n[picture]: ../a.png",
);
});
it("gives an image alone on its line its own paragraph", () => {
expect(
prepareMarkdownSource("Before the shot:\n![Shot](https://x/a.png)\nAfter it.").render(),
).toBe("Before the shot:\n\n![Shot](https://x/a.png)\n\nAfter it.");
expect(prepareMarkdownSource("![A](https://x/a.png)\n![B](https://x/b.png)").render()).toBe(
"![A](https://x/a.png)\n\n![B](https://x/b.png)",
);
expect(
prepareMarkdownSource(
"Line one\\\n[![Linked](https://x/a.png)](https://x)\nLine three",
).render(),
).toBe("Line one\n\n[![Linked](https://x/a.png)](https://x)\n\nLine three");
});
it("keeps images that share a line with text inline", () => {
const source = "Icon ![i](https://x/i.png) in text\n![a](https://x/a.png) caption\nNext line";
expect(prepareMarkdownSource(source).render()).toBe(source);
});
it("splits own-line images inside quotes and list items without ending the container", () => {
expect(prepareMarkdownSource("> Quote text\n> ![Shot](https://x/a.png)\n> More").render()).toBe(
"> Quote text\n>\n> ![Shot](https://x/a.png)\n>\n> More",
);
expect(
prepareMarkdownSource("- Item text\n ![Shot](https://x/a.png)\n More\n- Next").render(),
).toBe("- Item text\n\n ![Shot](https://x/a.png)\n\n More\n- Next");
expect(prepareMarkdownSource("- ![Shot](https://x/a.png)\n Caption line").render()).toBe(
"- ![Shot](https://x/a.png)\n\n Caption line",
);
});
it("renders own-line images as blocks through the library parser", async () => {
const source =
"Before:\n![Shot](https://x/a.png)\nIcon ![i](https://x/i.png) in text\n> Quoted\n> ![Q](https://x/q.png)";
const tree = await parseMarkdown(prepareMarkdownSource(source).render(), {
hardSoftBreaks: true,
latexMath: false,
});
markInlineImages(tree);
const images: ASTNode[] = [];
const collect = (node: ASTNode) => {
if (node.type === "Image") images.push(node);
node.children?.forEach(collect);
};
collect(tree);
expect(
images.map((image) => [image.attributes?.url, image.attributes?.isInline === true]),
).toEqual([
["https://x/a.png", false],
["https://x/i.png", true],
["https://x/q.png", false],
]);
});
it("escapes spoiler delimiters only in prose", () => {
expect(prepareMarkdownSource("Show ||words|| and `||code||`.").render()).toBe(
"Show \\|\\|words\\|\\| and `||code||`.",
+1 -41
View File
@@ -5,7 +5,7 @@ import { gfmTableFromMarkdown } from "mdast-util-gfm-table";
import type { AssistantFileLinkSource } from "@/assistant-file-links";
import { decodeString } from "micromark-util-decode-string";
import { fromMarkdown } from "mdast-util-from-markdown";
import type { Link, Nodes, Paragraph, PhrasingContent, Root } from "mdast";
import type { Link, Nodes, Root } from "mdast";
// Keep this regex in the syntax shared by JavaScript, ICU and java.util.regex.
// The Unicode flag makes Metro expand it to escapes rejected by native engines.
@@ -130,50 +130,10 @@ export function prepareMarkdownSource(
end,
});
}
// Line boundaries already turned into paragraph breaks, by offset of their newline.
const splitBoundaries = new Set<number>();
function isImageLike(node: PhrasingContent): boolean {
if (node.type === "image" || node.type === "imageReference") return true;
return (
(node.type === "link" || node.type === "linkReference") &&
node.children.length === 1 &&
isImageLike(node.children[0])
);
}
// An image alone on its line becomes its own paragraph, so every platform renders
// it as a block. The library only treats sole-child paragraph images as blocks;
// with hardSoftBreaks every newline is a break node, which made these inline.
function splitStandaloneImages(paragraph: Paragraph, start: number, end: number) {
for (const child of paragraph.children) {
const childStart = child.position?.start.offset;
const childEnd = child.position?.end.offset;
if (childStart === undefined || childEnd === undefined || !isImageLike(child)) continue;
const lineStart = source.lastIndexOf("\n", childStart - 1) + 1;
const newline = source.indexOf("\n", childEnd);
const lineEnd = newline === -1 ? source.length : newline;
const prefix = source.slice(lineStart, childStart);
const suffix = source.slice(childEnd, lineEnd);
const aloneBefore = childStart === start || /^[\s>]*$/.test(prefix);
const aloneAfter = /^\s*\\?$/.test(suffix);
if (!aloneBefore || !aloneAfter) continue;
// Continuation lines inside quotes and lists keep their quote markers on the blank line.
const blank = prefix.replace(/[^>]/g, " ").trimEnd();
const split = (boundary: number) => {
if (splitBoundaries.has(boundary)) return;
splitBoundaries.add(boundary);
// A trailing backslash was a hard break; at a paragraph end it would render literally.
const cut = source[boundary - 1] === "\\" ? boundary - 1 : boundary;
edits.push({ start: cut, end: boundary + 1, text: `\n${blank}\n` });
};
if (lineStart > start) split(lineStart - 1);
if (lineEnd < end) split(lineEnd);
}
}
function visit(node: Nodes) {
const start = node.position?.start.offset;
const end = node.position?.end.offset;
if (start !== undefined && end !== undefined) {
if (node.type === "paragraph") splitStandaloneImages(node, start, end);
if (node.type === "link") {
const title = workspaceHoverTitle(node, source.slice(start, end), workspaceRoot);
if (title !== null)
+1 -10
View File
@@ -3,7 +3,6 @@ import { providerSnapshotIcons } from "@/data/provider-icons";
import { Bot, PackagePlus } from "lucide-react-native";
import { createElement, useCallback, type ComponentType } from "react";
import { SvgXml } from "react-native-svg";
import { TERMINAL_PROFILE_ICON_NAMES } from "@getpaseo/protocol/provider-icon-names";
import { ClaudeIcon } from "@/components/icons/claude-icon";
import { CodexIcon } from "@/components/icons/codex-icon";
import { CopilotIcon } from "@/components/icons/copilot-icon";
@@ -11,7 +10,6 @@ import { MiniMaxIcon } from "@/components/icons/minimax-icon";
import { OpenCodeIcon } from "@/components/icons/opencode-icon";
import { OmpIcon } from "@/components/icons/omp-icon";
import { PiIcon } from "@/components/icons/pi-icon";
import { ACP_PROVIDER_ICON_SVGS } from "@/assets/acp-provider-icons";
import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog";
import { resolveProviderIconName } from "@/components/provider-icon-name";
@@ -33,17 +31,10 @@ const BUILTIN_PROVIDER_ICONS: Record<string, ProviderIconComponent> = {
pi: PiIcon as unknown as ProviderIconComponent,
};
const CATALOG_ICON_SVGS = new Map<string, string>(
const CATALOG_ICON_SVGS = new Map(
ACP_PROVIDER_CATALOG.flatMap((entry) => (entry.iconSvg ? [[entry.id, entry.iconSvg]] : [])),
);
for (const name of TERMINAL_PROFILE_ICON_NAMES) {
const iconSvg = (ACP_PROVIDER_ICON_SVGS as Record<string, string | undefined>)[name];
if (iconSvg) {
CATALOG_ICON_SVGS.set(name, iconSvg);
}
}
const catalogIconComponents = new Map<string, ProviderIconComponent>();
const snapshotIconComponents = new Map<string, ProviderIconComponent>();
@@ -58,7 +58,6 @@ function mountCard(question: Record<string, unknown>) {
permission={buildPermission(question)}
onRespond={onRespond}
isResponding={false}
responseError={null}
/>,
),
);
@@ -1,4 +1,3 @@
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";
@@ -28,7 +27,6 @@ interface QuestionFormCardProps {
permission: PendingPermission;
onRespond: (response: AgentPermissionResponse) => void;
isResponding: boolean;
responseError: string | null;
}
const IS_WEB = isWeb;
@@ -341,12 +339,7 @@ function QuestionOtherInput({
);
}
export function QuestionFormCard({
permission,
onRespond,
isResponding,
responseError,
}: QuestionFormCardProps) {
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor();
@@ -445,7 +438,7 @@ export function QuestionFormCard({
]);
const handleDeny = useCallback(() => {
if (!questions || isResponding) return;
if (!questions) return;
setRespondingAction("dismiss");
if (shouldSubmitEmptyOnDismiss(questions)) {
onRespond({
@@ -461,7 +454,7 @@ export function QuestionFormCard({
behavior: "deny",
message: "Dismissed by user",
});
}, [questions, isResponding, onRespond, otherTexts, permission.request.input, selections]);
}, [questions, onRespond, otherTexts, permission.request.input, selections]);
const handleSelectQuestion = useCallback((index: number) => {
setActiveQuestionIndex(index);
@@ -494,7 +487,6 @@ export function QuestionFormCard({
[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")
@@ -558,7 +550,7 @@ export function QuestionFormCard({
const dismissLabel = resolveDismissLabel(questions, t("common.actions.dismiss"));
const selected = selections[resolvedActiveQuestionIndex] ?? new Set<number>();
const otherText = otherTexts[resolvedActiveQuestionIndex] ?? "";
const showTextInput = questionShowsTextInput(questions[resolvedActiveQuestionIndex]);
const showTextInput = activeQuestion ? questionShowsTextInput(activeQuestion) : false;
return (
<View style={containerStyle} testID="question-form-card">
@@ -612,8 +604,6 @@ export function QuestionFormCard({
</View>
) : null}
{responseError ? <Alert variant="error" description={responseError} size="sm" /> : null}
<View style={actionsContainerStyle}>
<Pressable
style={dismissButtonStyle}
@@ -623,7 +613,7 @@ export function QuestionFormCard({
accessibilityLabel={dismissLabel}
testID="question-form-dismiss"
>
{pendingAction === "dismiss" ? (
{respondingAction === "dismiss" ? (
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
) : (
<View style={styles.actionContent}>
@@ -641,7 +631,7 @@ export function QuestionFormCard({
accessibilityLabel={primaryActionLabel}
testID="question-form-primary-action"
>
{pendingAction === "submit" ? (
{respondingAction === "submit" ? (
<LoadingSpinner size="small" color={theme.colors.accentForeground} />
) : (
<View style={styles.actionContent}>
@@ -301,6 +301,13 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
sheetTitle={t("sidebar.display.heading")}
testID="sidebar-display-preferences-content"
>
<MenuItem
selected={preferences.showBackground}
closeOnSelect={false}
onSelect={preferences.toggleBackground}
>
{t("sidebar.display.showBackground")}
</MenuItem>
<MenuSubTrigger
id="grouping"
value={t(GROUPING_LABEL_KEYS[preferences.grouping])}
@@ -359,14 +366,6 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
</MenuSubTrigger>
</>
) : null}
<MenuSeparator />
<MenuItem
selected={preferences.showBackground}
closeOnSelect={false}
onSelect={preferences.toggleBackground}
>
{t("sidebar.display.showBackground")}
</MenuItem>
</MenuSurface>
</MenuRoot>
<WorkspaceLabelManagerModal visible={managerOpen} onClose={closeManager} />
+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: {
...theme.shadow.sm,
boxShadow: "0px 4px 16px rgba(0, 0, 0, 0.2)",
},
// 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.
+1 -22
View File
@@ -1,6 +1,6 @@
import type { ExplorerDirectory, ExplorerEntry } from "@/stores/session-store";
import type { SortOption } from "@/stores/panel-store/state";
import { filterVisibleExplorerEntries, isHiddenExplorerPath } from "./visibility";
import { filterVisibleExplorerEntries } from "./visibility";
export const MAX_AUTO_EXPANDED_DIRECTORY_DEPTH = 5;
@@ -201,24 +201,3 @@ 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,7 +14,6 @@ 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(() => {
@@ -22,24 +21,20 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
return engineRef.current;
}
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),
engineRef.current = createAudioEngine({
onCaptureData: (pcm) => {
onPcmSegmentRef.current(Buffer.from(pcm).toString("base64"));
},
{ captureMode: "dictation" },
);
onVolumeLevel: (level) => {
setVolume(level);
},
onError: (error) => {
onErrorRef.current?.(error);
},
onInterruption: () => {
onInterruptionRef.current?.();
},
});
return engineRef.current;
}, []);
@@ -72,6 +67,5 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
start,
stop,
volume,
isPaused,
};
}
@@ -8,5 +8,4 @@ export interface DictationAudioSource {
start: () => Promise<void>;
stop: () => Promise<void>;
volume: number;
isPaused: boolean;
}
@@ -442,7 +442,6 @@ 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" | "paused" | "uploading" | "failed";
export type DictationStatus = "idle" | "recording" | "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 === "recording" && audio.isPaused ? "paused" : status,
status,
startDictation,
cancelDictation,
confirmDictation,
@@ -6,8 +6,6 @@ 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 {
@@ -174,27 +172,6 @@ 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) {
@@ -377,7 +354,6 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
return {
workspaceStateKey,
revealDirectory,
requestDirectoryListing,
requestFilePreview,
requestFileDownloadToken,
-2
View File
@@ -360,7 +360,6 @@ export const ar: TranslationResources = {
details: "تفاصيل",
},
dictation: {
paused: "تمت مقاطعة الميكروفون. في انتظار الاستئناف…",
start: "بدء الإملاء الصوتي",
cancel: "إلغاء الإملاء",
retry: "أعد محاولة الإملاء",
@@ -370,7 +369,6 @@ export const ar: TranslationResources = {
failedRetry: "فشل الإملاء. اضغط على إعادة المحاولة.",
},
question: {
responseFailed: "تعذر إرسال ردك. حاول مرة أخرى.",
submit: "يُقدِّم",
next: "التالي",
answerPlaceholder: "اكتب إجابتك...",
-2
View File
@@ -357,7 +357,6 @@ export const en = {
details: "Details",
},
dictation: {
paused: "Microphone interrupted. Waiting to resume…",
start: "Start voice dictation",
cancel: "Cancel dictation",
retry: "Retry dictation",
@@ -367,7 +366,6 @@ 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,7 +365,6 @@ 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",
@@ -375,7 +374,6 @@ 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,7 +365,6 @@ 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",
@@ -375,7 +374,6 @@ 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,7 +365,6 @@ export const ja: TranslationResources = {
details: "詳細",
},
dictation: {
paused: "マイクが中断されました。再開を待っています…",
start: "音声入力を開始",
cancel: "音声入力をキャンセル",
retry: "音声入力を再試行",
@@ -375,7 +374,6 @@ export const ja: TranslationResources = {
failedRetry: "音声入力に失敗しました。再試行をタップしてください。",
},
question: {
responseFailed: "回答を送信できませんでした。もう一度お試しください。",
submit: "送信",
next: "次へ",
answerPlaceholder: "回答を入力...",
-2
View File
@@ -361,7 +361,6 @@ export const ko: TranslationResources = {
details: "세부 정보",
},
dictation: {
paused: "마이크가 중단되었습니다. 재개를 기다리는 중…",
start: "음성 받아쓰기 시작",
cancel: "받아쓰기 취소",
retry: "받아쓰기 다시 시도",
@@ -371,7 +370,6 @@ export const ko: TranslationResources = {
failedRetry: "받아쓰기에 실패했습니다. 다시 시도하려면 누르세요.",
},
question: {
responseFailed: "응답을 보내지 못했습니다. 다시 시도해 주세요.",
submit: "제출",
next: "다음",
answerPlaceholder: "답변을 입력하세요...",
-2
View File
@@ -364,7 +364,6 @@ 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",
@@ -374,7 +373,6 @@ 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,7 +364,6 @@ export const ru: TranslationResources = {
details: "Подробности",
},
dictation: {
paused: "Микрофон прерван. Ожидание возобновления…",
start: "Начать диктовку голосом",
cancel: "Отменить диктовку",
retry: "Повторить диктовку",
@@ -374,7 +373,6 @@ export const ru: TranslationResources = {
failedRetry: "Диктовка не удалась. Нажмите «Повторить».",
},
question: {
responseFailed: "Не удалось отправить ответ. Попробуйте ещё раз.",
submit: "Отправить",
next: "Далее",
answerPlaceholder: "Введите ответ...",
-2
View File
@@ -360,7 +360,6 @@ export const zhCN: TranslationResources = {
details: "详情",
},
dictation: {
paused: "麦克风已中断,正在等待恢复…",
start: "开始语音听写",
cancel: "取消听写",
retry: "重试听写",
@@ -370,7 +369,6 @@ export const zhCN: TranslationResources = {
failedRetry: "听写失败。点按重试。",
},
question: {
responseFailed: "未能发送你的回复,请重试。",
submit: "提交",
next: "下一步",
answerPlaceholder: "输入你的回答...",
@@ -1,5 +1,5 @@
import { useMemo, useEffect, type ReactNode } from "react";
import { Animated, useAnimatedValue, type ViewProps } from "react-native";
import { useMemo, type ReactNode } from "react";
import { Animated, type ViewProps } from "react-native";
import { useKeyboardAnimation } from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -32,23 +32,9 @@ export function KeyboardTranslateView({
}),
[bottomClearance, height, insets.bottom, progress],
);
// Fullscreen resizes this view while disabling translation. iOS Fabric can
// reapply its previous transform during that layout commit. Keep the graph
// attached and apply the mode on the native animation frame; a static zero or
// setValue during the React commit gets overwritten by the resize.
const enabledValue = useAnimatedValue(enabled ? 1 : 0);
useEffect(() => {
const animation = Animated.timing(enabledValue, {
toValue: enabled ? 1 : 0,
duration: 0,
useNativeDriver: true,
});
animation.start();
return () => animation.stop();
}, [enabled, enabledValue]);
const keyboardStyle = useMemo(
() => ({ transform: [{ translateY: Animated.multiply(translateY, enabledValue) }] }),
[enabledValue, translateY],
() => ({ transform: [{ translateY: enabled ? translateY : 0 }] }),
[enabled, translateY],
);
return (
@@ -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, ScrollView } from "react-native";
import { View, Text, Pressable } 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,12 +18,11 @@ 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();
@@ -62,56 +61,51 @@ export function OpenProjectScreen() {
return (
<View style={styles.container}>
<MenuHeader borderless />
<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={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 ? (
<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 style={styles.content}>
<TitlebarDragRegion />
<View style={styles.logo}>
<PaseoLogo size={52} />
</View>
<View style={styles.communityRow}>
<CommunityLinks />
{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 ? (
<HomeTile
icon={Smartphone}
title={t("openProject.tiles.pairDevice.title")}
description={t("openProject.tiles.pairDevice.description")}
onPress={handleOpenPairDevice}
testID="open-project-pair-device"
/>
) : null}
</View>
</ScrollView>
</View>
<View style={styles.communityRow}>
<CommunityLinks />
</View>
<PairDeviceModal
serverId={localServerId ?? ""}
visible={isPairDeviceOpen}
@@ -182,21 +176,18 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface0,
userSelect: "none",
},
scroll: {
flex: 1,
},
scrollContent: {
flexGrow: 1,
},
content: {
position: "relative",
flexGrow: 1,
flexShrink: 0,
flex: 1,
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],
@@ -247,8 +238,10 @@ const styles = StyleSheet.create((theme) => ({
lineHeight: 18,
},
communityRow: {
flexShrink: 0,
paddingBottom: {
position: "absolute",
left: 0,
right: 0,
bottom: {
xs: HEADER_INNER_HEIGHT_MOBILE + HEADER_TOP_PADDING_MOBILE + theme.spacing[2],
md: HEADER_INNER_HEIGHT + theme.spacing[2],
},
@@ -1,9 +1,4 @@
import {
AssistantFileLinkResolverProvider,
createInlinePathOpener,
type InlinePathTarget,
} from "@/assistant-file-links";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { AssistantFileLinkResolverProvider, type InlinePathTarget } from "@/assistant-file-links";
import {
WorkspaceComposerEditingSessionsProvider,
useComposerTabFullscreen,
@@ -208,7 +203,6 @@ import { PluginHeaderButtons } from "@/plugins";
import {
createWorkspaceFileTabTarget,
normalizeWorkspaceFileLocation,
type OpenFileDisposition,
type WorkspaceFileLocation,
type WorkspaceFileOpenRequest,
} from "@/workspace/file-open";
@@ -3664,42 +3658,9 @@ function WorkspaceScreenContent({
</View>
);
const { revealDirectory } = useFileExplorerActions({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
workspaceRoot: workspaceDirectory,
const openMarkdownFile = useStableEvent((target: InlinePathTarget) => {
handleOpenPreferredAssistantFile({ location: target, parentTabId: activeTabId });
});
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">
@@ -3744,7 +3705,7 @@ function WorkspaceScreenContent({
client={client}
serverId={normalizedServerId}
workspaceRoot={workspaceDirectory ?? undefined}
onOpenWorkspaceFile={openMarkdownPath}
onOpenWorkspaceFile={openMarkdownFile}
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.08)",
shadowColor: "rgba(0, 0, 0, 0.02)",
shadowOffset: { width: 0, height: 2 },
shadowRadius: 8,
elevation: 2,
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.12.0-beta.1",
"version": "0.11.2",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -28,9 +28,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.12.0-beta.1",
"@getpaseo/protocol": "0.12.0-beta.1",
"@getpaseo/server": "0.12.0-beta.1",
"@getpaseo/client": "0.11.2",
"@getpaseo/protocol": "0.11.2",
"@getpaseo/server": "0.11.2",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
-16
View File
@@ -2,22 +2,6 @@ import { describe, expect, it } from "vitest";
import { createCli } from "./cli.js";
describe("canonical CLI surface", () => {
it.each(["send", "agent send"])("%s accepts an explicit behavior override", async (path) => {
const cli = createCli();
const owner =
path === "send" ? cli : cli.commands.find((command) => command.name() === "agent")!;
const send = owner.commands.find((command) => command.name() === "send")!;
send.action(() => {});
await send.parseAsync(["target", "message", "--behavior", "interrupt"], { from: "user" });
expect(send.opts().behavior).toBe("interrupt");
await send.parseAsync(["target", "message", "--behavior", "steer"], { from: "user" });
expect(send.opts().behavior).toBe("steer");
send.exitOverride().configureOutput({ writeErr: () => {} });
await expect(
send.parseAsync(["target", "message", "--behavior", "queue"], { from: "user" }),
).rejects.toThrow();
});
it("offers daemon host selection as a global option", () => {
expect(createCli().helpInformation()).toContain("--host <host>");
});
+2 -12
View File
@@ -1,5 +1,4 @@
import { type Command, Option } from "commander";
import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages";
import { type Command } from "commander";
import { collectMultiple } from "../../utils/command-options.js";
import { connectToDaemon } from "../../utils/client.js";
import type {
@@ -29,7 +28,6 @@ export const agentSendSchema: OutputSchema<AgentSendResult> = {
};
export interface AgentSendOptions extends CommandOptions {
behavior?: ActiveTurnBehavior;
wait?: boolean;
image?: string[];
prompt?: string;
@@ -44,12 +42,6 @@ export function addSendOptions(cmd: Command): Command {
.option("--prompt <text>", "Provide the message inline as a flag")
.option("--prompt-file <path>", "Read the message from a UTF-8 text file")
.option("--image <path>", "Attach image(s) to the message", collectMultiple, [])
.addOption(
new Option(
"--behavior <behavior>",
"Active-turn behavior (agent default: steer; otherwise: interrupt)",
).choices(["interrupt", "steer"]),
)
.option("--no-wait", "Return immediately without waiting for completion");
}
@@ -199,12 +191,10 @@ export async function runSendCommand(
const images =
options.image && options.image.length > 0 ? await readImageFiles(options.image) : undefined;
const sourceAgentId = process.env.PASEO_AGENT_ID?.trim() || undefined;
// Send the message
await client.sendAgentMessage(agentIdArg, promptInput, {
images,
sourceAgentId,
activeTurnBehavior: options.behavior ?? (sourceAgentId ? "steer" : "interrupt"),
sourceAgentId: process.env.PASEO_AGENT_ID?.trim() || undefined,
});
// If --no-wait, return immediately
+33 -250
View File
@@ -29,116 +29,6 @@ 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-"));
@@ -158,11 +48,31 @@ 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 observer = await startRequestObserver(context.port);
const installedSources = () =>
observer.requests
.filter((entry) => entry.type === "plugin.source.install.request")
.map((entry) => entry.message.source);
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 sibling = path.join(context.workDir, "x");
const caller = path.join(context.workDir, "caller");
await mkdir(caller);
@@ -186,12 +96,12 @@ async function main(): Promise<void> {
["~", context.paseoHome, context.paseoHome, []],
["~/.:.", `${context.paseoHome}:.`, context.paseoHome, []],
] as const) {
observer.requests.length = 0;
sources.length = 0;
const result = await context.paseo(
["plugin", "add", source, ...extraArgs, "--host", `127.0.0.1:${observer.port}`, "--json"],
["plugin", "add", source, ...extraArgs, "--host", `127.0.0.1:${address.port}`, "--json"],
{ cwd: caller },
);
assert.deepEqual(installedSources(), [expectedSource]);
assert.deepEqual(sources, [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"]);
@@ -199,16 +109,17 @@ 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`;
observer.requests.length = 0;
sources.length = 0;
const missing = await context.paseo(
["plugin", "add", windowsSource, "--host", `127.0.0.1:${observer.port}`, "--json"],
["plugin", "add", windowsSource, "--host", `127.0.0.1:${address.port}`, "--json"],
{ cwd: caller },
);
assert.deepEqual(installedSources(), [windowsSource]);
assert.deepEqual(sources, [windowsSource]);
assert.equal(missing.exitCode, 1);
assert.match(missing.stderr, /Plugin directory does not exist/);
} finally {
await observer.close();
for (const socket of proxy.clients) socket.close();
await promisify(proxy.close.bind(proxy))();
}
const install = await context.paseo(["plugin", "install", directory, "--json"]);
@@ -331,134 +242,6 @@ 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"]);
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.12.0-beta.1",
"version": "0.11.2",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -39,8 +39,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.12.0-beta.1",
"@getpaseo/relay": "0.12.0-beta.1",
"@getpaseo/protocol": "0.11.2",
"@getpaseo/relay": "0.11.2",
"zod": "^4.4.3"
},
"devDependencies": {
-194
View File
@@ -1963,200 +1963,6 @@ 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,10 +5692,6 @@ 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;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.12.0-beta.1",
"version": "0.11.2",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
@@ -36,14 +36,8 @@ 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'
}
@@ -1,8 +0,0 @@
<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>
@@ -1,223 +0,0 @@
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,14 +6,12 @@ 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.Handler
import android.os.Looper
import android.os.PowerManager
import android.util.Log
import androidx.annotation.RequiresApi
import java.util.LinkedList
@@ -36,26 +34,7 @@ class AudioEngine (context: Context) {
private var noiseSuppressor: NoiseSuppressor? = null
private val executorServiceMicrophone = Executors.newFixedThreadPool(1)
private val executorServicePlayback = Executors.newFixedThreadPool(1)
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 speakerDevice: AudioDeviceInfo? = null
private var communicationRouteActive = false
private var bridgeWindowStartedAtMs = System.currentTimeMillis()
private var micEvents = 0
@@ -65,7 +44,7 @@ class AudioEngine (context: Context) {
private var playbackWrites = 0
private var playbackWriteBytes = 0L
@Volatile var isRecording = false
var isRecording = false
private var isRecordingBeforePause = false
var isPlaying = false
@@ -104,9 +83,28 @@ class AudioEngine (context: Context) {
@SuppressLint("NewApi")
private fun initializeAudio(context:Context) {
audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
// Initialization must not interrupt music before a capture/playback operation owns a session.
audioManager.registerAudioDeviceCallback(deviceCallback, mainHandler)
audioManager.registerAudioRecordingCallback(recordingCallback, mainHandler)
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)
val bufferSize = AudioTrack.getMinBufferSize(
SAMPLE_RATE,
@@ -127,42 +125,49 @@ 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) {
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
// 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)
}
}
if (!routed) audioManager.clearCommunicationDevice()
} else {
// Fall back to deprecated method for older Android versions
@Suppress("DEPRECATION")
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
}
audioManager.isSpeakerphoneOn = !isExternalDeviceConnected
}
}
@@ -181,8 +186,6 @@ class AudioEngine (context: Context) {
} else {
@Suppress("DEPRECATION")
audioManager.isSpeakerphoneOn = false
@Suppress("DEPRECATION")
audioManager.stopBluetoothSco()
}
audioManager.mode = AudioManager.MODE_NORMAL
}
@@ -242,8 +245,8 @@ class AudioEngine (context: Context) {
audioFocusRequest = null
}
lateinit var focusRequest: AudioFocusRequest
focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE)
val focusRequest =
AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
@@ -252,7 +255,6 @@ class AudioEngine (context: Context) {
)
.setAcceptsDelayedFocusGain(true)
.setOnAudioFocusChangeListener { focusChange ->
if (audioFocusRequest !== focusRequest) return@setOnAudioFocusChangeListener
when (focusChange) {
AudioManager.AUDIOFOCUS_GAIN -> {
Log.d("AudioEngine", "Audio focus gained")
@@ -284,14 +286,6 @@ 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()
}
@@ -312,11 +306,11 @@ class AudioEngine (context: Context) {
@RequiresApi(Build.VERSION_CODES.Q)
@SuppressLint("MissingPermission")
private fun startRecording(requestFocus: Boolean = true): Boolean {
private fun startRecording(): Boolean {
activateCommunicationRoute()
if (requestFocus && !requestAudioFocus()) {
if (!requestAudioFocus()) {
handleAudioFocusBlocked()
if (captureMode == CaptureMode.CONVERSATION) return false
return false
}
val bufferSize = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT)
@@ -350,27 +344,17 @@ class AudioEngine (context: Context) {
audioRecord.startRecording()
isRecording = true
setCaptureSilenced(audioRecord.activeRecordingConfiguration?.isClientSilenced ?: false)
startMicSampleTap(audioRecord)
startMicSampleTap()
return true
}
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){
private fun startMicSampleTap(){
executorServiceMicrophone.execute {
val buffer = ByteArray(1024)
try {
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) {
while (isRecording) {
val read = audioRecord.read(buffer, 0, buffer.size)
if (read > 0) {
val data = buffer.copyOf(read)
micEvents += 1
micBytes += data.size.toLong()
@@ -383,13 +367,9 @@ class AudioEngine (context: Context) {
Log.d("AudioEngine", "Mic sample tap stopped.")
}catch (e: Exception){
Log.e("AudioEngine", "Error reading mic sample data", e)
mainHandler.post {
if (isRecording && audioRecord === recorder) {
stopRecording()
releaseAudioSession()
onAudioInterruptionCallback?.invoke("blocked")
}
}
isRecording = false
tearDown()
throw e
}
}
}
@@ -399,31 +379,16 @@ 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()
@@ -436,7 +401,6 @@ class AudioEngine (context: Context) {
@SuppressLint("NewApi")
fun playPCMData(data: ByteArray) {
acquireAudioSessionIfNeeded()
audioTrack.play()
audioSampleQueue.add(data)
playbackEvents += 1
playbackQueuedBytes += data.size.toLong()
@@ -500,7 +464,7 @@ class AudioEngine (context: Context) {
@RequiresApi(Build.VERSION_CODES.Q)
fun pauseRecordingAndPlayer() {
isRecordingBeforePause = isRecording
stopRecording()
isRecording = toggleRecording(false)
audioTrack.pause()
}
@@ -514,18 +478,16 @@ class AudioEngine (context: Context) {
if (!wasRecordingBeforePause && !isPlaying) {
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
}
activateCommunicationRoute()
if (!requestAudioFocus()) {
handleAudioFocusBlocked()
return
}
if (captureMode == CaptureMode.CONVERSATION) audioTrack.play()
isRecording = toggleRecording(wasRecordingBeforePause)
if (wasRecordingBeforePause && !isRecording) {
return
}
audioTrack.play()
}
fun stopPlayback() {
@@ -558,11 +520,7 @@ class AudioEngine (context: Context) {
audioManager.abandonAudioFocusRequest(request)
}
audioFocusRequest = null
audioManager.unregisterAudioDeviceCallback(deviceCallback)
audioManager.unregisterAudioRecordingCallback(recordingCallback)
audioTrack.release()
executorServiceMicrophone.shutdownNow()
executorServicePlayback.shutdownNow()
}
@@ -42,12 +42,6 @@ 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,12 +59,6 @@ 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")
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.12.0-beta.1",
"version": "0.11.2",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
-10
View File
@@ -17,16 +17,6 @@ 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();
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.12.0-beta.1",
"version": "0.11.2",
"files": [
"dist",
"!dist/**/*.map",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.12.0-beta.1",
"version": "0.11.2",
"description": "Paseo plugin SDK package",
"files": [
"dist",
@@ -70,8 +70,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.12.0-beta.1",
"@getpaseo/protocol": "0.12.0-beta.1",
"@getpaseo/client": "0.11.2",
"@getpaseo/protocol": "0.11.2",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -81,8 +81,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.12.0-beta.1",
"@getpaseo/protocol": "0.12.0-beta.1",
"@getpaseo/client": "0.11.2",
"@getpaseo/protocol": "0.11.2",
"react": "~19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
+2 -2
View File
@@ -1,7 +1,7 @@
# Protocol Validator Codegen
This directory is build-time only. The generator writes `generated/ws-outbound.compile.ts`, the zod-aot discovery entry for the inbound WebSocket validators: one for the envelope and one per session message type.
This directory is build-time only. `ws-outbound.compile.ts` is the zod-aot discovery entry for the inbound WebSocket validator.
The discovery entry and the generated runtime files under `../src/generated/validation/` are not committed. The protocol package owns every generation trigger through its npm lifecycle scripts.
The generated runtime file is written to `../src/generated/validation/ws-outbound.aot.ts` and is not committed. The protocol package owns every generation trigger through its npm lifecycle scripts.
`zod-aot` is exact-pinned, and the protocol generator applies the small compiler patches it requires before generation. Treat changes to those patches like compiler changes: regenerate, inspect the output, and run the protocol validation regression tests before shipping.
@@ -0,0 +1,4 @@
import { compile } from "zod-aot";
import { WSOutboundMessageSchema as SourceWSOutboundMessageSchema } from "../src/messages.js";
export const WSOutboundMessageSchema = compile(SourceWSOutboundMessageSchema);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.12.0-beta.1",
"version": "0.11.2",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
@@ -4,12 +4,8 @@ import { dirname, relative, resolve, sep } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const envelopeSource = resolve(packageRoot, "src/validation/ws-outbound-envelope.ts");
const source = resolve(packageRoot, "codegen/generated/ws-outbound.compile.ts");
const runtimeSchemaMetadata = resolve(
packageRoot,
"src/generated/validation/ws-outbound-schema-metadata.ts",
);
const source = resolve(packageRoot, "codegen/ws-outbound.compile.ts");
const runtimeSchemaMetadata = resolve(packageRoot, "src/validation/ws-outbound-schema-metadata.ts");
const output = resolve(packageRoot, "src/generated/validation/ws-outbound.aot.ts");
const require = createRequire(import.meta.url);
@@ -76,69 +72,16 @@ await Promise.all([
ensureZodAotDiscriminatedUnionOutputPatch(),
]);
const [
{ loadSourceFile },
{ discoverSchemas },
{ compileSchemas },
{ generateCompiledFileContent },
] = await Promise.all([
import(pathToFileURL(resolve(zodAotRoot, "dist/loader.js")).href),
import(pathToFileURL(resolve(zodAotRoot, "dist/discovery.js")).href),
import(pathToFileURL(resolve(zodAotRoot, "dist/core/pipeline.js")).href),
import(pathToFileURL(resolve(zodAotRoot, "dist/cli/emitter.js")).href),
]);
// One compiled validator per session message plus a tag-only envelope. zod-aot emits one function
// per compiled schema, so this keeps every generated function small enough for V8 to optimize cheaply.
const { sessionOutboundMessageOptions } = await loadSourceFile(envelopeSource, { cacheBust: true });
const sessionTags = sessionOutboundMessageOptions.map((option) => option.shape.type.value);
function sessionExport(index) {
return `SessionOutboundMessage${index}`;
}
const envelopeImport = relative(dirname(source), envelopeSource)
.replace(/\.ts$/, ".js")
.split(sep)
.join("/");
const metadataImport = relative(dirname(runtimeSchemaMetadata), envelopeSource)
.replace(/\.ts$/, ".js")
.split(sep)
.join("/");
await mkdir(dirname(source), { recursive: true });
await writeFile(
source,
[
"// AUTO-GENERATED by generate-validation-aot.mjs — DO NOT EDIT",
'import { compile } from "zod-aot";',
`import { WSOutboundEnvelopeSchema, sessionOutboundMessageOptions } from "${envelopeImport}";`,
"export const WSOutboundEnvelope = compile(WSOutboundEnvelopeSchema);",
...sessionTags.map(
(_, index) =>
`export const ${sessionExport(index)} = compile(sessionOutboundMessageOptions[${index}]);`,
),
"",
].join("\n"),
);
await mkdir(dirname(runtimeSchemaMetadata), { recursive: true });
await writeFile(
runtimeSchemaMetadata,
[
"// AUTO-GENERATED by generate-validation-aot.mjs — DO NOT EDIT",
`import { WSOutboundEnvelopeSchema, sessionOutboundMessageOptions } from "${metadataImport}";`,
"export const WSOutboundEnvelope = { schema: WSOutboundEnvelopeSchema };",
...sessionTags.map(
(_, index) =>
`export const ${sessionExport(index)} = { schema: sessionOutboundMessageOptions[${index}] };`,
),
"",
].join("\n"),
);
const [{ discoverSchemas }, { compileSchemas }, { generateCompiledFileContent }] =
await Promise.all([
import(pathToFileURL(resolve(zodAotRoot, "dist/discovery.js")).href),
import(pathToFileURL(resolve(zodAotRoot, "dist/core/pipeline.js")).href),
import(pathToFileURL(resolve(zodAotRoot, "dist/cli/emitter.js")).href),
]);
const schemas = await discoverSchemas(source, { cacheBust: true });
if (schemas.length !== sessionTags.length + 1) {
throw new Error(
`Expected ${sessionTags.length + 1} zod-aot compile() exports in ${relative(packageRoot, source)}, found ${schemas.length}`,
);
if (schemas.length === 0) {
throw new Error(`No zod-aot compile() exports found in ${relative(packageRoot, source)}`);
}
const compiled = compileSchemas(schemas, { mode: "inline" });
@@ -152,16 +95,12 @@ const content = generateCompiledFileContent(compiled, runtimeImportPath, {
"// AUTO-GENERATED by zod-aot — DO NOT EDIT",
"// @ts-nocheck\n// AUTO-GENERATED by zod-aot — DO NOT EDIT",
);
const sessionMessageValidators = [
"export const SessionOutboundMessageValidators = {",
...sessionTags.map((tag, index) => ` ${JSON.stringify(tag)}: ${sessionExport(index)},`),
"};",
"",
].join("\n");
await mkdir(dirname(output), { recursive: true });
await writeFile(output, `${content}\n${sessionMessageValidators}`);
await writeFile(output, content);
console.info(
`generated ${relative(packageRoot, output)}: envelope + ${sessionTags.length} session message validators`,
`generated ${relative(packageRoot, output)} from ${relative(packageRoot, source)} (${schemas
.map((schema) => schema.exportName)
.join(", ")})`,
);
@@ -1,6 +1,6 @@
# Generated protocol validators
`ws-outbound.aot.ts` is generated by zod-aot from `packages/protocol/codegen/generated/ws-outbound.compile.ts`, which the generator writes along with `ws-outbound-schema-metadata.ts`.
`ws-outbound.aot.ts` is generated by zod-aot from `packages/protocol/codegen/ws-outbound.compile.ts`.
Run:
@@ -1,25 +0,0 @@
import { z } from "zod";
import {
SessionOutboundMessageSchema,
WSHelloRejectedMessageSchema,
WSPongMessageSchema,
} from "../messages.js";
export const sessionOutboundMessageOptions = SessionOutboundMessageSchema.options;
const [firstSessionTag, ...otherSessionTags] = sessionOutboundMessageOptions.map((option) =>
z.object({ type: option.shape.type }),
);
// WSOutboundMessageSchema with every session message reduced to its tag. It reports the same
// envelope and discriminator issues, while each session message gets its own generated validator:
// one function for the whole protocol is large enough that V8's Maglev tier spends hundreds of MB
// and seconds compiling it.
export const WSOutboundEnvelopeSchema = z.discriminatedUnion("type", [
WSPongMessageSchema,
z.object({
type: z.literal("session"),
message: z.discriminatedUnion("type", [firstSessionTag, ...otherSessionTags]),
}),
WSHelloRejectedMessageSchema,
]);
@@ -0,0 +1,3 @@
import { WSOutboundMessageSchema as SourceWSOutboundMessageSchema } from "../messages.js";
export const WSOutboundMessageSchema = { schema: SourceWSOutboundMessageSchema };
+11 -46
View File
@@ -1,54 +1,19 @@
import { ZodRealError } from "zod";
import type { z } from "zod";
import {
SessionOutboundMessageValidators,
WSOutboundEnvelope,
} from "../generated/validation/ws-outbound.aot.js";
import type { SessionOutboundMessage, WSOutboundMessage } from "../messages.js";
import { WSOutboundMessageSchema } from "../generated/validation/ws-outbound.aot.js";
import type { WSOutboundMessage } from "../messages.js";
type ValidationResult<T> = { success: true; data: T } | { success: false; error: z.ZodError };
type WSOutboundValidationResult =
| { success: true; data: WSOutboundMessage }
| { success: false; error: z.ZodError };
interface GeneratedValidator<T> {
safeParse(input: unknown): ValidationResult<T>;
interface WSOutboundGeneratedValidator {
safeParse(input: unknown): WSOutboundValidationResult;
}
// zod-aot emits runtime JavaScript from the envelope and message schemas but not its TypeScript surface.
// zod-aot emits runtime JavaScript from WSOutboundMessageSchema but not its TypeScript surface.
// Protocol regression tests cover the patched compiler cases behind this boundary.
const envelopeValidator = WSOutboundEnvelope as GeneratedValidator<WSOutboundMessage>;
const sessionMessageValidators = SessionOutboundMessageValidators as Record<
SessionOutboundMessage["type"],
GeneratedValidator<SessionOutboundMessage>
>;
const wsOutboundValidator = WSOutboundMessageSchema as WSOutboundGeneratedValidator;
// The message validator reports paths relative to the message, nested issues included.
function underMessage(issue: z.core.$ZodIssue): z.core.$ZodIssue {
const path = ["message", ...issue.path];
switch (issue.code) {
case "invalid_union":
return issue.inclusive === false
? { ...issue, path }
: { ...issue, path, errors: issue.errors.map((branch) => branch.map(underMessage)) };
case "invalid_key":
case "invalid_element":
return { ...issue, path, issues: issue.issues.map(underMessage) };
default:
return { ...issue, path };
}
}
export function validateWSOutboundMessage(input: unknown): ValidationResult<WSOutboundMessage> {
const envelope = envelopeValidator.safeParse(input);
if (!envelope.success || envelope.data.type !== "session") {
return envelope;
}
const message = envelope.data.message;
const result = sessionMessageValidators[message.type].safeParse(message);
if (!result.success) {
return { success: false, error: new ZodRealError(result.error.issues.map(underMessage)) };
}
// Message validators return a new object when they apply defaults.
if (result.data === message) {
return envelope;
}
return { success: true, data: { ...envelope.data, message: result.data } };
export function validateWSOutboundMessage(input: unknown): WSOutboundValidationResult {
return wsOutboundValidator.safeParse(input);
}
@@ -3,10 +3,8 @@ import { createRequire } from "node:module";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { createJiti } from "jiti";
import type { z } from "zod";
import { describe, expect, it } from "vitest";
import { WSOutboundMessageSchema } from "../../src/messages.js";
import { validateWSOutboundMessage } from "../../src/validation/ws-outbound.js";
import { WSOutboundMessageSchema as GeneratedWSOutboundMessageSchema } from "../../src/generated/validation/ws-outbound.aot.js";
interface GeneratedSchema {
safeParse(input: unknown): { success: boolean; data?: unknown };
@@ -134,8 +132,10 @@ const SourceSchema = z.object({
});
it("accepts a minimal valid envelope and rejects a corrupted envelope", () => {
expect(validateWSOutboundMessage({ type: "pong" }).success).toBe(true);
expect(validateWSOutboundMessage({ type: "not_a_message" }).success).toBe(false);
expect(GeneratedWSOutboundMessageSchema.safeParse({ type: "pong" }).success).toBe(true);
expect(GeneratedWSOutboundMessageSchema.safeParse({ type: "not_a_message" }).success).toBe(
false,
);
});
it("accepts project config responses with and without setup commit status", () => {
@@ -158,10 +158,11 @@ const SourceSchema = z.object({
},
});
expect(validateWSOutboundMessage(envelope(payload)).success).toBe(true);
expect(GeneratedWSOutboundMessageSchema.safeParse(envelope(payload)).success).toBe(true);
expect(
validateWSOutboundMessage(envelope({ ...payload, hasUncommittedWorktreeSetupChanges: true }))
.success,
GeneratedWSOutboundMessageSchema.safeParse(
envelope({ ...payload, hasUncommittedWorktreeSetupChanges: true }),
).success,
).toBe(true);
});
@@ -195,7 +196,7 @@ const SourceSchema = z.object({
},
};
expect(validateWSOutboundMessage(envelope)).toEqual({
expect(GeneratedWSOutboundMessageSchema.safeParse(envelope)).toEqual({
success: true,
data: envelope,
});
@@ -254,7 +255,7 @@ const SourceSchema = z.object({
])("preserves workspaceId in a $name", ({ message }) => {
const envelope = { type: "session", message };
expect(validateWSOutboundMessage(envelope)).toEqual({
expect(GeneratedWSOutboundMessageSchema.safeParse(envelope)).toEqual({
success: true,
data: envelope,
});
@@ -262,11 +263,11 @@ const SourceSchema = z.object({
it("emits runtime imports with .js extensions", async () => {
const generated = await readFile(generatedWSOutboundPath, "utf8");
expect(generated).toContain('from "./ws-outbound-schema-metadata.js"');
expect(generated).toContain('from "../../validation/ws-outbound-schema-metadata.js"');
});
it("accepts a forge.search.response envelope", () => {
const result = validateWSOutboundMessage({
const result = GeneratedWSOutboundMessageSchema.safeParse({
type: "session",
message: {
type: "forge.search.response",
@@ -292,7 +293,7 @@ const SourceSchema = z.object({
});
it("accepts a legacy github_search_response envelope", () => {
const result = validateWSOutboundMessage({
const result = GeneratedWSOutboundMessageSchema.safeParse({
type: "session",
message: {
type: "github_search_response",
@@ -318,86 +319,4 @@ const SourceSchema = z.object({
});
expect(result.success).toBe(true);
});
it("keeps each generated validator function small", async () => {
// A single function for the whole protocol made V8's Maglev tier spend ~800 MB and seconds compiling it.
const generated = await readFile(generatedWSOutboundPath, "utf8");
const starts = [...generated.matchAll(/function safeParse_\w+\(/g)].map((match) => match.index);
const sizes = starts.map((start, index) => (starts[index + 1] ?? generated.length) - start);
expect(Math.max(...sizes)).toBeLessThan(1_000_000);
expect(sizes.length).toBeGreaterThan(1);
});
it("keeps nested union issue paths rooted at the envelope", () => {
// Generated validators root branch issues at the input; runtime Zod reports them relative to the union.
const envelope = {
type: "session",
message: {
type: "read_project_config_response",
payload: { repoRoot: "/repo", ok: true, config: {}, revision: { mtimeMs: 1, size: 1 } },
},
};
const paths = (issues: z.core.$ZodIssue[]): PropertyKey[][] =>
issues.flatMap((issue) => [
issue.path,
...(issue.code === "invalid_union" ? paths(issue.errors.flat()) : []),
]);
const result = validateWSOutboundMessage(envelope);
expect(result.success).toBe(false);
if (result.success) return;
expect(paths(result.error.issues)).toEqual([
["message", "payload"],
["message", "payload", "requestId"],
["message", "payload", "requestId"],
["message", "payload", "ok"],
["message", "payload", "error"],
]);
});
it("applies defaults from the matching session message schema", () => {
const envelope = {
type: "session",
message: {
type: "agent_update",
payload: {
kind: "upsert",
agent: {
id: "agent-1",
provider: "claude",
cwd: "/repo",
model: "model",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
lastUserMessageAt: "2026-01-01T00:00:00.000Z",
status: "initializing",
capabilities: {
supportsStreaming: false,
supportsSessionPersistence: false,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
},
currentModeId: "default",
availableModes: [],
pendingPermissions: [],
persistence: { provider: "claude", sessionId: "session-1" },
title: "Agent",
},
},
},
};
const result = validateWSOutboundMessage(envelope);
const expected = WSOutboundMessageSchema.safeParse(envelope);
expect(expected.success).toBe(true);
expect(result).toEqual(expected);
expect(result.success && result.data).toMatchObject({
message: { payload: { agent: { capabilities: { supportsRewindConversation: false } } } },
});
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.12.0-beta.1",
"version": "0.11.2",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.12.0-beta.1",
"version": "0.11.2",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -107,11 +107,11 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.12.0-beta.1",
"@getpaseo/highlight": "0.12.0-beta.1",
"@getpaseo/plugin": "0.12.0-beta.1",
"@getpaseo/protocol": "0.12.0-beta.1",
"@getpaseo/relay": "0.12.0-beta.1",
"@getpaseo/client": "0.11.2",
"@getpaseo/highlight": "0.11.2",
"@getpaseo/plugin": "0.11.2",
"@getpaseo/protocol": "0.11.2",
"@getpaseo/relay": "0.11.2",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -12,13 +12,6 @@ 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 };
@@ -101,7 +94,7 @@ export async function ensureAgentLoaded(
const initPromise = (async () => {
const record = await deps.agentStorage.get(agentId);
if (!record) {
throw new AgentNotFoundError(agentId);
throw new Error(`Agent not found: ${agentId}`);
}
const validProviders = deps.validProviders ?? deps.agentManager.getRegisteredProviderIds();
@@ -10929,41 +10929,6 @@ 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 { parseAgentMessage, projectAgentMessage } from "./agent-messages/index.js";
import { 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,12 +2459,7 @@ export class AgentManager {
*/
tryRunOutOfBand(agentId: string, prompt: AgentPromptInput, options?: AgentRunOptions): boolean {
const agent = this.requireSessionAgent(agentId);
// 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,
);
const handler = agent.session.tryHandleOutOfBand?.(prompt);
if (!handler) {
return false;
}
@@ -402,8 +402,6 @@ export interface SetupFinishNotificationParams {
childAgentId: string;
callerAgentId: string;
requireParentOwnership?: boolean;
/** Retain pre-dispatch outcomes only when dispatch keeps the existing turn. */
dispatchResult?: Promise<PromptDispatchDisposition | false>;
/** A replacement must observe its new start, not the displaced running snapshot. */
waitForTurnStart?: boolean;
logger: Logger;
@@ -468,17 +466,10 @@ interface NotifySafelyOptions {
permissionRequest?: AgentPermissionRequest;
}
// Pending follow-ups coexist until acknowledgement. A successful dispatch replaces
// every earlier observation for the caller/child pair; a failure removes only itself.
interface ArmedFinishNotification {
stop(): void;
supersedeWhen(accepted: Promise<boolean>): void;
}
const armedFinishNotifications = new WeakMap<
AgentManager,
Map<string, Set<ArmedFinishNotification>>
>();
// A caller waits on a child through one armed notification. Arming again, such as a
// follow-up prompt while the child still runs, replaces the earlier one so the child's
// next finish reaches the caller once.
const armedFinishNotifications = new WeakMap<AgentManager, Map<string, () => void>>();
export function setupFinishNotification(params: SetupFinishNotificationParams): () => void {
const {
@@ -500,49 +491,15 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
const armedByManager = armedFinishNotifications.get(agentManager) ?? new Map();
armedFinishNotifications.set(agentManager, armedByManager);
const armedKey = JSON.stringify([childAgentId, callerAgentId]);
const registrations = armedByManager.get(armedKey) ?? new Set<ArmedFinishNotification>();
armedByManager.set(armedKey, registrations);
const previous = [...registrations];
let superseded = Promise.resolve(false);
let dispatchPending = Boolean(params.dispatchResult);
const registration: ArmedFinishNotification = {
stop,
supersedeWhen(accepted) {
superseded = superseded.then(
async (alreadySuperseded) => alreadySuperseded || (await accepted),
);
},
};
registrations.add(registration);
if (params.dispatchResult) {
const dispatchAccepted = params.dispatchResult.then((result) => result !== false);
for (const observer of previous) observer.supersedeWhen(dispatchAccepted);
void params.dispatchResult.then((accepted) => {
dispatchPending = false;
if (accepted) {
for (const observer of previous) observer.stop();
if (stopped || accepted === "turn_started") stop();
} else {
stop();
}
return undefined;
});
} else {
for (const observer of previous) {
observer.supersedeWhen(Promise.resolve(true));
observer.stop();
}
}
armedByManager.get(armedKey)?.();
armedByManager.set(armedKey, stop);
function stop(): void {
if (stopped) return;
stopped = true;
unsubscribe?.();
// Pending observations remain visible to overlapping sends until acknowledgement.
if (!dispatchPending) {
registrations.delete(registration);
if (registrations.size === 0 && armedByManager.get(armedKey) === registrations) {
armedByManager.delete(armedKey);
}
if (armedByManager.get(armedKey) === stop) {
armedByManager.delete(armedKey);
}
}
@@ -550,11 +507,6 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
reason: FinishNotificationReason,
permissionRequest?: AgentPermissionRequest,
): Promise<void> {
if (params.dispatchResult) {
const result = await params.dispatchResult;
if (result === false || result === "turn_started") return;
}
if (await superseded) return;
const callerRecord = await agentStorage.get(callerAgentId);
if (callerRecord?.archivedAt) {
return;
@@ -1,4 +1,3 @@
import { setupFinishNotification, type PromptDispatchDisposition } from "./agent-prompt.js";
import { parseAgentMessage } from "./agent-messages/index.js";
import { execFileSync } from "node:child_process";
import { randomUUID } from "node:crypto";
@@ -6,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, readFile, rm, writeFile } from "node:fs/promises";
import { access, mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { join, resolve as resolvePath } from "node:path";
import { tmpdir } from "node:os";
import { z } from "zod";
@@ -16,8 +15,6 @@ 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,
@@ -243,7 +240,6 @@ function buildAgentManagerSpies() {
appendTimelineItem: vi.fn().mockResolvedValue(undefined),
emitLiveTimelineItem: vi.fn().mockResolvedValue(undefined),
hasInFlightRun: vi.fn().mockReturnValue(false),
steerOrReplaceActiveTurn: async () => ({ status: "inactive" as const }),
tryRunOutOfBand: vi.fn().mockReturnValue(false),
subscribe: vi.fn().mockReturnValue(() => {}),
streamAgent: vi.fn(() => (async function* noop() {})()),
@@ -845,49 +841,6 @@ 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();
@@ -3862,9 +3815,6 @@ const HELD_TURN_CAPABILITIES = {
} as const;
interface HeldTurnOptions {
finishDuringSteer?: boolean;
rejectSteer?: boolean;
steerResult?: "accepted" | "unavailable";
/** Run the whole turn before the turn-start request returns. */
finishTurnsDuringStart?: boolean;
}
@@ -3877,7 +3827,6 @@ class HeldTurnAgentSession implements AgentSession {
readonly capabilities = HELD_TURN_CAPABILITIES;
readonly id = randomUUID();
readonly prompts: string[] = [];
readonly steeredPrompts: AgentPromptInput[] = [];
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private activeTurnId: string | null = null;
private turnStartGate: Promise<void> | null = null;
@@ -3907,15 +3856,8 @@ class HeldTurnAgentSession implements AgentSession {
}
tryHandleOutOfBand(prompt: AgentPromptInput) {
return prompt === "/side-command" ? { run: async () => {} } : null;
}
async steerActiveTurn(prompt: AgentPromptInput) {
const status = this.options.steerResult ?? "unavailable";
if (status === "accepted") this.steeredPrompts.push(prompt);
if (status === "accepted" && this.options.finishDuringSteer) this.finishTurn();
if (this.options.rejectSteer) throw new Error("Steer rejected");
return { status };
const text = typeof prompt === "string" ? (parseAgentMessage(prompt)?.text ?? prompt) : null;
return text === "/side-command" ? { run: async () => {} } : null;
}
async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
@@ -4064,295 +4006,6 @@ class HeldTurnAgentClient implements AgentClient {
}
describe("send_agent_prompt MCP tool", () => {
it.each([
{ agentScoped: true, behavior: undefined, available: true, steered: true },
{ agentScoped: true, behavior: "interrupt", available: true, steered: false },
{ agentScoped: true, behavior: "steer", available: true, steered: true },
{ agentScoped: false, behavior: undefined, available: true, steered: false },
{ agentScoped: false, behavior: "steer", available: true, steered: true },
{ agentScoped: true, behavior: undefined, available: false, steered: false },
] as const)(
"dispatches behavior $behavior for agent-scoped=$agentScoped, steering available=$available",
async ({ agentScoped, behavior, available, steered }) => {
const workdir = await mkdtemp(join(tmpdir(), "mcp-send-behavior-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const client = new HeldTurnAgentClient("codex", true, {
steerResult: available ? "accepted" : "unavailable",
});
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
try {
const parent = await manager.createAgent(
{ provider: "codex", cwd: existingCwd },
undefined,
{ workspaceId: "wks_parent" },
);
const child = await manager.createAgent(
{ provider: "codex", cwd: existingCwd },
undefined,
{ workspaceId: "wks_parent" },
);
const server = await createAgentMcpServer({
agentManager: manager,
agentStorage: storage,
callerAgentId: agentScoped ? parent.id : undefined,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
const tool = registeredTool(server, "send_agent_prompt");
await invokeToolWithParsedInput(tool, {
agentId: child.id,
prompt: "Original work",
notifyOnFinish: false,
});
const originalTurn = manager.getAgent(child.id)!.activeTurnId;
const response = await invokeToolWithParsedInput(tool, {
agentId: child.id,
prompt: "New message",
notifyOnFinish: false,
...(behavior ? { activeTurnBehavior: behavior } : {}),
});
expect(response.structuredContent).toMatchObject({
success: true,
disposition: steered ? "steered" : "turn_started",
});
expect(client.sessions[1]!.prompts).toHaveLength(steered ? 1 : 2);
expect(client.sessions[1]!.steeredPrompts).toHaveLength(steered ? 1 : 0);
expect(manager.getAgent(child.id)!.activeTurnId === originalTurn).toBe(steered);
} finally {
await removeAgentStateDir(manager, storage, workdir);
}
},
);
it.each([
{ rejected: false, priorNotification: false },
{ rejected: true, priorNotification: false },
{ rejected: false, priorNotification: true },
])(
"buffers completion until steer acknowledgement (rejected: $rejected, prior notification: $priorNotification)",
async ({ rejected, priorNotification }) => {
const workdir = await mkdtemp(join(tmpdir(), "mcp-send-behavior-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const client = new HeldTurnAgentClient("codex", true, {
steerResult: "accepted",
finishDuringSteer: true,
rejectSteer: rejected,
});
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
let mcpClient: Client | undefined;
try {
const parent = await manager.createAgent(
{ provider: "codex", cwd: existingCwd },
undefined,
{ workspaceId: "wks_parent" },
);
const child = await manager.createAgent(
{ provider: "codex", cwd: existingCwd },
undefined,
{ workspaceId: "wks_parent" },
);
const server = await createAgentMcpServer({
agentManager: manager,
agentStorage: storage,
callerAgentId: parent.id,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
mcpClient = await connectInMemoryMcpClient(server);
const send = (input: Record<string, unknown>) =>
mcpClient!.callTool({ name: "send_agent_prompt", arguments: input });
await send({
agentId: child.id,
prompt: "Original work",
notifyOnFinish: priorNotification,
});
const sending = send({
agentId: child.id,
prompt: "New message",
notifyOnFinish: true,
});
if (rejected) {
expect(await sending).toMatchObject({
isError: true,
content: [{ type: "text", text: expect.stringContaining("Steer rejected") }],
});
// Flush asynchronous notification work before checking that a rejected
// dispatch did not notify. Then prove a later successful send still can.
await new Promise((resolve) => setTimeout(resolve, 50));
expect(client.sessions[0]!.prompts).toHaveLength(0);
await send({
agentId: child.id,
prompt: "Retry after rejection",
});
client.sessions[1]!.finishTurn();
} else {
const response = await sending;
expect(response.structuredContent).toMatchObject({
success: true,
disposition: "steered",
});
}
await vi.waitFor(() => expect(client.sessions[0]!.prompts).toHaveLength(1));
expect(parseAgentMessage(client.sessions[0]!.prompts[0])).toMatchObject({
source: { kind: "agent-notification", agentId: child.id, event: "finished" },
});
} finally {
await mcpClient?.close();
await removeAgentStateDir(manager, storage, workdir);
}
},
);
it.each([false, true])(
"preserves the original notification after rejection (completion during send: %s)",
async (finishDuringSteer) => {
const workdir = await mkdtemp(join(tmpdir(), "mcp-send-behavior-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const client = new HeldTurnAgentClient("codex", true, {
steerResult: "accepted",
finishDuringSteer,
rejectSteer: true,
});
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
let mcpClient: Client | undefined;
try {
const parent = await manager.createAgent(
{ provider: "codex", cwd: existingCwd },
undefined,
{
workspaceId: "wks_parent",
},
);
const child = await manager.createAgent(
{ provider: "codex", cwd: existingCwd },
undefined,
{
workspaceId: "wks_parent",
},
);
const server = await createAgentMcpServer({
agentManager: manager,
agentStorage: storage,
callerAgentId: parent.id,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
mcpClient = await connectInMemoryMcpClient(server);
const send = (input: Record<string, unknown>) =>
mcpClient!.callTool({ name: "send_agent_prompt", arguments: input });
await send({
agentId: child.id,
prompt: "Original work",
notifyOnFinish: true,
});
const sending = send({
agentId: child.id,
prompt: "New message",
notifyOnFinish: true,
});
expect(await sending).toMatchObject({
isError: true,
content: [{ type: "text", text: expect.stringContaining("Steer rejected") }],
});
if (!finishDuringSteer) {
await new Promise((resolve) => setTimeout(resolve, 50));
expect(client.sessions[0]!.prompts).toHaveLength(0);
client.sessions[1]!.finishTurn();
}
await vi.waitFor(() => expect(client.sessions[0]!.prompts).toHaveLength(1));
expect(parseAgentMessage(client.sessions[0]!.prompts[0])).toMatchObject({
source: { kind: "agent-notification", agentId: child.id, event: "finished" },
});
} finally {
await mcpClient?.close();
await removeAgentStateDir(manager, storage, workdir);
}
},
);
it.each([
[false, true],
[true, false],
[false, false],
[true, true],
])(
"overlapping follow-ups notify once (first accepted: %s, second accepted: %s)",
async (firstAccepted, secondAccepted) => {
const workdir = await mkdtemp(join(tmpdir(), "mcp-send-behavior-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const client = new HeldTurnAgentClient("codex", true, {
steerResult: "accepted",
finishDuringSteer: false,
rejectSteer: true,
});
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
let mcpClient: Client | undefined;
try {
const parent = await manager.createAgent(
{ provider: "codex", cwd: existingCwd },
undefined,
{
workspaceId: "wks_parent",
},
);
const child = await manager.createAgent(
{ provider: "codex", cwd: existingCwd },
undefined,
{
workspaceId: "wks_parent",
},
);
const server = await createAgentMcpServer({
agentManager: manager,
agentStorage: storage,
callerAgentId: parent.id,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
mcpClient = await connectInMemoryMcpClient(server);
const send = (input: Record<string, unknown>) =>
mcpClient!.callTool({ name: "send_agent_prompt", arguments: input });
await send({
agentId: child.id,
prompt: "Original work",
notifyOnFinish: true,
});
let firstResolve!: (result: PromptDispatchDisposition | false) => void;
let secondResolve!: (result: PromptDispatchDisposition | false) => void;
const first = new Promise<PromptDispatchDisposition | false>((resolve) => {
firstResolve = resolve;
});
const second = new Promise<PromptDispatchDisposition | false>((resolve) => {
secondResolve = resolve;
});
for (const dispatchResult of [first, second]) {
setupFinishNotification({
agentManager: manager,
agentStorage: storage,
childAgentId: child.id,
callerAgentId: parent.id,
dispatchResult,
logger,
});
}
firstResolve(firstAccepted ? "steered" : false);
await first;
secondResolve(secondAccepted ? "steered" : false);
await second;
client.sessions[1]!.finishTurn();
await vi.waitFor(() => expect(client.sessions[0]!.prompts).toHaveLength(1));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(client.sessions[0]!.prompts).toHaveLength(1);
expect(parseAgentMessage(client.sessions[0]!.prompts[0])).toMatchObject({
source: { kind: "agent-notification", agentId: child.id, event: "finished" },
});
} finally {
await mcpClient?.close();
await removeAgentStateDir(manager, storage, workdir);
}
},
);
const logger = createTestLogger();
const existingCwd = process.cwd();
@@ -4403,7 +4056,7 @@ describe("send_agent_prompt MCP tool", () => {
const response = await tool.handler(parsed.data as Record<string, unknown>);
expect(spies.agentManager.subscribe).toHaveBeenCalled();
expect(spies.agentManager.subscribe).toHaveBeenCalledTimes(1);
expect(spies.agentManager.waitForAgentEvent).not.toHaveBeenCalled();
expect(response.structuredContent.guidance).toBe(
"You will get notified when the prompted agent finishes, errors, or needs permission. Do not poll for status; continue with other work until the notification arrives.",
@@ -4573,7 +4226,7 @@ describe("send_agent_prompt MCP tool", () => {
},
);
it.each(["out_of_band", "out_of_band_interrupt", "startup_failure"] as const)(
it.each(["out_of_band", "startup_failure"] as const)(
"preserves %s dispatch notification semantics",
async (scenario) => {
const workdir = await mkdtemp(join(tmpdir(), "mcp-dispatch-notification-"));
@@ -4609,11 +4262,10 @@ describe("send_agent_prompt MCP tool", () => {
prompt: "Ongoing task",
notifyOnFinish: false,
});
if (scenario !== "startup_failure") {
if (scenario === "out_of_band") {
const response = await invokeToolWithParsedInput(tool, {
agentId: child.id,
prompt: "/side-command",
activeTurnBehavior: scenario === "out_of_band_interrupt" ? "interrupt" : "steer",
});
expect(response.structuredContent).toMatchObject({
success: true,
@@ -4905,180 +4557,6 @@ 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 { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js";
import type { 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,39 +105,6 @@ 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();
@@ -464,124 +431,6 @@ 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",
},
});
});
@@ -1,157 +0,0 @@
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,36 +575,6 @@ 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,13 +1839,7 @@ export async function resolveClaudeCodeVersion(
timeout: 5_000,
signal,
});
// 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;
const version = parseClaudeCodeVersion(`${stdout}\n${stderr}`);
if (!version) {
throw new Error("Unable to parse Claude Code version from --version output");
}
@@ -2234,37 +2228,18 @@ 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 ??
this.settledSubagentToolCallInCurrentTurn(toolUseId)?.input ??
null,
getToolInput: (toolUseId) => this.toolUseCache.get(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,
// 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.
// 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.
needsSyntheticParentToolCard: (toolUseId) =>
(this.toolUseCache.has(toolUseId) ||
this.settledSubagentToolCallInCurrentTurn(toolUseId) !== undefined) &&
this.toolUseCache.has(toolUseId) &&
this.taskProtocolSource.needsSyntheticParentToolCard(toolUseId),
getSettledParentToolCall: (toolUseId) =>
this.settledSubagentToolCallInCurrentTurn(toolUseId)?.item,
});
private persistedHistory: PersistedTimelineEntry[] = [];
private persistedProviderSubagentEvents: Extract<
@@ -2616,13 +2591,13 @@ class ClaudeAgentSession implements AgentSession {
const normalized = isPermissionMode(modeId) ? modeId : "default";
assertClaudeModeCanRun(normalized, this.harnessEnvironment);
const previousMode = this.currentMode;
const previousQuery = this.query;
const launchesQuery = !this.query || this.queryRestartNeeded;
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 (activeQuery !== previousQuery) this.queryRestartNeeded = true;
if (launchesQuery) this.queryRestartNeeded = true;
throw error;
}
if (normalized === "plan") {
@@ -2901,7 +2876,6 @@ 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?.();
@@ -3320,9 +3294,7 @@ class ClaudeAgentSession implements AgentSession {
}
private async ensureQuery(launchMode: PermissionMode = this.currentMode): Promise<Query> {
// 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)) {
if (this.query && !this.queryRestartNeeded) {
return this.query;
}
@@ -4389,7 +4361,6 @@ 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;
@@ -5577,22 +5548,28 @@ class ClaudeAgentSession implements AgentSession {
const { images, text } = splitClaudeToolResultImages(block.content);
const output = this.buildToolOutput(text, block, entry);
const settledToolCall = block.is_error
? mapClaudeFailedToolCall({
if (block.is_error) {
this.pushToolCall(
mapClaudeFailedToolCall({
name: toolName,
callId,
input: entry?.input ?? null,
output: output ?? null,
error: { ...block, content: text },
})
: mapClaudeCompletedToolCall({
}),
items,
);
} else {
this.pushToolCall(
mapClaudeCompletedToolCall({
name: toolName,
callId,
input: entry?.input ?? null,
output: output ?? null,
});
this.pushToolCall(settledToolCall, items);
this.rememberSettledSubagentToolCall(settledToolCall, entry);
}),
items,
);
}
for (const image of images) {
const imageItem = renderProviderImageOutputAsAssistantMarkdown(image, {
@@ -5608,34 +5585,6 @@ 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,

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