Compare commits

...
Author SHA1 Message Date
Mohamed Boudra bce306238b fix(server): keep a gotgenes foreground answer on replay
A foreground @gotgenes/pi-subagents child finds its session file only
through the live runtime marker, which history does not keep. Without a
file, emit the spawn result text as the child's assistant message.
2026-10-30 12:01:23 +01:00
Mohamed Boudra c1bcfd930b fix(server): settle child compactions and keep sub-agent cards stable
- Codex: a child compaction shown as loading now completes on the
  child's thread/compacted or turn end, once.
- Claude: a streamed child tool call's empty start input is a
  placeholder, so the assistant message's input reaches its card.
- OMP: the task card follows only its first child, so later children
  no longer re-send an unchanged card.
2026-10-30 11:47:33 +01:00
Mohamed Boudra 6953ba6506 fix(server): put a tintinweb foreground answer in the child timeline
A foreground @tintinweb/pi-subagents child has no session file, so its
answer only exists in the Agent tool result. Emit that text as the
child's assistant message when the child finishes. Tag every sub_agent
producer's `log: ""` with COMPAT(subagent-log) so the shim removal
finds them.
2026-10-30 09:49:51 +01:00
Mohamed Boudra a68a0c2c5f fix: make sub-agent cards launch markers
Sub-agent tool calls no longer accumulate the child's activity into
`log`. Claude, Codex, OpenCode, OMP and Pi emit the card when the child
starts and when its label or status changes; the child's messages and
tool calls stay in the sub-agent's own timeline. The app no longer
renders a sub-agent log.

The wire schema makes `log` optional. Daemons still send `log: ""` so
apps that require the field keep parsing the card.
2026-10-30 08:35:04 +01:00
a8e0effb32 Follow system rotation on mobile devices (#8256)
The app no longer locks to portrait. Phones and tablets on Android and iOS follow the device rotation and respect the system rotation lock, so Android tablets fill the display in landscape instead of letterboxing.

Modals pass all orientations explicitly because React Native presents iPhone modals in portrait by default.

Closes #1669

Co-authored-by: yz <43207690+yzim@users.noreply.github.com>
Co-authored-by: magiclaw <930198090@qq.com>
2026-10-30 13:42:38 +07:00
c663630bbd fix(server): read Codex history in pages instead of one whole-thread reply (#8171)
Opening a Codex agent whose timeline the daemon doesn't hold asked Codex
for the whole thread in one thread/read reply. Past the 128 MiB provider
line cap that reply killed the session; before the cap it crashed the
daemon. Codex 0.145 and newer reject that read on paginated threads, so
importing them failed.

Paginated threads now load through thread/items/list pages. A legacy
thread keeps its single read. A reply over the line cap fails only its own
request: for a legacy read, a warning row names the archive,
`codex migrate-rollouts`, unarchive steps; for a page, a warning row ends
the history there. Items are capped as they are projected, and rewind's
fork no longer returns the thread's turns.

Co-authored-by: hunterricks <hr@savagestrategic.io>
Co-authored-by: dundunHa <lixinpengde@gmail.com>
2026-10-30 12:57:21 +07:00
paseo-bot[bot] deb1bdc266 test(plugins): wait for global recovery without an extra restart (#8250)
* test(plugins): observe global recovery without reloading again

* test(plugins): leave time for recovery failure cleanup

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-30 03:38:37 +00:00
Matěj Pešl 9c7925de58 fix(skills): make invocation input portable across harnesses (#8252)
Read invocation input and conversation context without Claude argument substitution. Recognize Pi skill references when forwarding advisor requests.
2026-10-30 03:01:41 +00:00
4351445467 Keep other windows in sync when an agent is deleted (#8248)
* fix(server): broadcast agent removals across logical sessions

* test(server): make subscription fixtures portable on Windows

The newly enabled integration suite assumed /bin/cat and a one-second reply deadline, so Windows CI failed before completing its subscription coverage. Use the running Node executable for echo terminals and let diagnostics finish their bounded external tool probes.

Co-authored-by: loganmeyer <50949542+loganmeyer@users.noreply.github.com>

---------

Co-authored-by: loganmeyer <50949542+loganmeyer@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <paseo-bot[bot]@users.noreply.github.com>
2026-10-30 00:05:34 +00:00
paseo-bot[bot] 9503537041 test(server): exclude loader activity from config responsiveness (#8218)
* test(server): trace recurring config responsiveness failures

* test(server): keep loader messages out of config responsiveness probes

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-29 21:02:58 +00:00
paseo-bot[bot] 1aef1ec662 test(server): isolate timeline fetch responsiveness from runner work (#8197)
* test(server): isolate timeline fetch responsiveness from runner work

Measure the existing cold tail workload in a separate process after fixture collection. Keep the 50ms gate and row assertions, and reproduce unrelated callback CPU contaminating the old measurement.

* test(server): keep timeline probe private and exercise overlapping CPU

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-29 12:21:29 +00:00
946b6e58fd Keep transcript colors in sync after a hidden theme change (#8199)
* fix(app): preserve adaptive theme events while hidden

Read the current scheme through fresh matchMedia lists so hidden runtime
reads do not consume the pending events on listener-owned lists.

Carry Dan Sabin's fix from #7172 into the current combined Unistyles patch,
and cover both transition directions with a real Electron regression in
the existing desktop browser suite.

Co-authored-by: Dan Sabin <dan@freethinking.it>

* test(desktop): run adaptive theme regression on Linux

Electron retains --no-sandbox in argv, so the positional lookup used the script filename as the artifact directory. Read the final argument so the same real Electron regression exercises Linux as well as macOS.

Co-authored-by: Nurşat Bülbül <85260051+Nurshot@users.noreply.github.com>
Co-authored-by: Dan Sabin <dan@freethinking.it>

---------

Co-authored-by: Nurshot <85260051+Nurshot@users.noreply.github.com>
Co-authored-by: Dan Sabin <dan@freethinking.it>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-29 11:16:48 +00:00
Mohamed Boudra ac6e4e9f96 Fix flaky 64 MiB image materialization test on Windows CI (#8198)
The responsiveness test timed wall-clock gaps between 1 ms ticks, which reached 50.35 ms on the shared Windows runner for the same code. It now measures main-thread CPU time between ticks and still fails when the payload decodes in one chunk.
2026-10-29 16:56:12 +07:00
paseo-bot[bot] c4b4516890 Keep split dividers under the pointer across mouse and gesture input (#6011)
* fix(app): keep split dividers under the mouse before Explorer is first opened

The main row only left the Explorer pane out once the Explorer had been
shown, because the split container read the registered pane id directly.
Before that, the hidden Explorer stayed in the row with its share of the
sizes, and every divider drag moved about 1.28 times the mouse distance.
Resolve the pane the same way the workspace screen does, so the default
Explorer pane is left out from the start.

Closes #5998

* fix(app): keep split dividers under gesture input

Measure the gesture from its initial press so activation does not discard the movement before its threshold. Cover coarse-pointer mouse and touch input alongside app-created workspaces, Explorer changes and reload.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-29 09:10:25 +00:00
Mohamed Boudra 75f58c4fe7 fix(release): upload only en-US screenshots to Google Play (#8195)
supply uploads every locale folder under fastlane/metadata/android in one
edit. Play rejects the F-Droid-only ja locale, which failed the whole edit in
the 0.14.0 release ("ja - Invalid request"), so no screenshots were replaced.
Stage only the en-US images, add a standalone workflow to rerun the upload for
the current release, and document the App Store associatedErrors gotcha.
2026-10-29 15:57:08 +07:00
paseo-ai[bot] fb69bf3314 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-29 08:49:01 +00:00
Mohamed Boudra f7eef9dd0c chore(release): reconcile main with v0.14.0 (#8193)
Bring in what v0.14.0 shipped: the 0.14.0 entry in CHANGELOG.md and
CHANGELOG.zh-CN.md exactly as tagged (replacing the 0.14.0-beta.2 series
entry), the 0.14.0 version in every package.json and package-lock.json, and
the generated F-Droid changelogs. The unreleased notes on main stay above the
entry and now say they are not in 0.14.0.

Release commit: 8c6de842a3 (tag v0.14.0)
2026-10-29 15:40:29 +07:00
Mohamed Boudra 7d74ae5d6c docs(release): release from tags with selected cherry-picks while main stays open (#8188)
Replace the next-branch discipline with the main-first workflow: normal PRs
always merge into main, each release builds in a short-lived release workspace
from origin/main or a release tag plus approved cherry-picks, promotions take
an inventory that defaults to defer, and a reconcile PR brings the shipped
changelog, version and F-Droid files back into main.
2026-10-29 15:08:36 +07:00
babo-jeonguk 61841d1ed5 fix(app): stop web menus snapping back to their entering-animation rect (#8180)
Reanimated's web path saves a custom keyframe's element rect when the
animation ends, and writes it back as inline top/left/width/height when
it cleans the keyframe up 750ms after the start. A menu whose content
resized in between, such as a plugin popover whose data had just
arrived, was left clipped to its loading-state size or hanging over its
anchor.

Play the same keyframes through the Web Animations API on web, which
leaves no inline style, and drop the height release that only undid
part of that write. Native keeps the Reanimated Keyframe.
2026-10-29 07:58:09 +00:00
122 changed files with 2717 additions and 1596 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ name: CI
on:
push:
branches: [main]
# Every base branch, so stacked PRs and PRs into `next` get the same checks.
# Every base branch, so stacked PRs and PRs into any base get the same checks.
pull_request:
merge_group:
workflow_dispatch:
+4 -4
View File
@@ -1,6 +1,6 @@
# Changelog
Not released yet. These changes are on `main` but not in 0.14.0-beta.2; the next release entry includes them.
Not released yet. These changes are on `main` but not in 0.14.0; the next release entry includes them.
Added:
@@ -11,7 +11,7 @@ Improved:
- Added `server.addHook` with `subject.operation.position` names and the awaited `worktree.create.after` hook before setup and agents; deprecated the previous plugin hook registration methods without changing their behavior ([#8081](https://github.com/getpaseo/paseo/pull/8081))
## 0.14.0-beta.2 - 2026-10-28
## 0.14.0 - 2026-10-29
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
@@ -43,7 +43,6 @@ Voice settings now let you configure speech providers and download local models
- Reduced daemon stalls when opening and paging large Claude and OMP histories ([#7789](https://github.com/getpaseo/paseo/pull/7789), [#7940](https://github.com/getpaseo/paseo/pull/7940), [#7948](https://github.com/getpaseo/paseo/pull/7948), [#7958](https://github.com/getpaseo/paseo/pull/7958) by [@ragokan](https://github.com/ragokan))
- Reduced daemon stalls during plugin builds, image processing and config reloads ([#7967](https://github.com/getpaseo/paseo/pull/7967), [#7954](https://github.com/getpaseo/paseo/pull/7954), [#7959](https://github.com/getpaseo/paseo/pull/7959))
- Reduced blocking filesystem work during workspace and project refreshes ([#7941](https://github.com/getpaseo/paseo/pull/7941), [#7943](https://github.com/getpaseo/paseo/pull/7943), [#7944](https://github.com/getpaseo/paseo/pull/7944))
- Added a workaround for Claude Code sessions consuming subscription limits faster in Paseo ([#7976](https://github.com/getpaseo/paseo/pull/7976) by [@david828019-oss](https://github.com/david828019-oss))
### Fixed
@@ -72,7 +71,8 @@ Voice settings now let you configure speech providers and download local models
- Voice mode now defaults to off; enable it in Voice settings
- If environment variables select OpenAI speech, move keys and endpoints stored only in legacy `providers.openai` into Voice settings
- Before downgrading to 0.13.2, restore a backed-up `config.json` or convert speech credentials and idle-cleanup settings saved by the beta
- Before downgrading to 0.13.2, restore a backed-up `config.json` or convert speech credentials and idle-cleanup settings saved by 0.14.0
- Install the optional [Claude Code entrypoint plugin](https://paseo.sh/plugins/getpaseo/claude-code-entrypoint-env) to launch Claude sessions with `CLAUDE_CODE_ENTRYPOINT=Paseo`, a community workaround for subscription limits draining faster ([#7976](https://github.com/getpaseo/paseo/pull/7976), [#8164](https://github.com/getpaseo/paseo/pull/8164) by [@david828019-oss](https://github.com/david828019-oss))
## 0.13.2 - 2026-10-26
+4 -4
View File
@@ -1,6 +1,6 @@
# 更新日志
尚未发布。以下变更已合入 `main`,但不包含在 0.14.0-beta.2 中;下一个版本的条目会包含它们。
尚未发布。以下变更已合入 `main`,但不包含在 0.14.0 中;下一个版本的条目会包含它们。
新增:
@@ -11,7 +11,7 @@
- 新增采用 `subject.operation.position` 命名的 `server.addHook` 和在 setup 及 Agent 之前等待的 `worktree.create.after` 钩子,弃用原有的插件钩子注册方法并保留原有行为 ([#8081](https://github.com/getpaseo/paseo/pull/8081))
## 0.14.0-beta.2 - 2026-10-28
## 0.14.0 - 2026-10-29
插件现在可以为单个 Agent 添加工具,并在会话打开时配置其 MCP 服务器。
@@ -43,7 +43,6 @@
- 减少打开和分页加载大型 Claude 和 OMP 历史时的 daemon 卡顿 ([#7789](https://github.com/getpaseo/paseo/pull/7789), [#7940](https://github.com/getpaseo/paseo/pull/7940), [#7948](https://github.com/getpaseo/paseo/pull/7948), [#7958](https://github.com/getpaseo/paseo/pull/7958) by [@ragokan](https://github.com/ragokan))
- 减少插件构建、图片处理和重新加载配置时的 daemon 卡顿 ([#7967](https://github.com/getpaseo/paseo/pull/7967), [#7954](https://github.com/getpaseo/paseo/pull/7954), [#7959](https://github.com/getpaseo/paseo/pull/7959))
- 减少刷新工作区和项目时由文件系统操作造成的阻塞 ([#7941](https://github.com/getpaseo/paseo/pull/7941), [#7943](https://github.com/getpaseo/paseo/pull/7943), [#7944](https://github.com/getpaseo/paseo/pull/7944))
- 为 Claude Code 会话在 Paseo 中更快消耗订阅额度的问题添加了变通方案 ([#7976](https://github.com/getpaseo/paseo/pull/7976) by [@david828019-oss](https://github.com/david828019-oss))
### 修复
@@ -72,7 +71,8 @@
- 语音模式默认关闭,可在语音设置中启用
- 如果通过环境变量选择 OpenAI 语音,请将仅保存在旧版 `providers.openai` 中的密钥和服务地址移至语音设置
- 降级至 0.13.2 前,请恢复备份的 `config.json` 或转换 beta 保存的语音凭据和空闲清理设置
- 降级至 0.13.2 前,请恢复备份的 `config.json` 或转换 0.14.0 保存的语音凭据和空闲清理设置
- 安装可选的 [Claude Code entrypoint 插件](https://paseo.sh/plugins/getpaseo/claude-code-entrypoint-env),即可使用 `CLAUDE_CODE_ENTRYPOINT=Paseo` 启动 Claude 会话;这是针对订阅额度消耗过快问题的社区变通方案 ([#7976](https://github.com/getpaseo/paseo/pull/7976), [#8164](https://github.com/getpaseo/paseo/pull/8164) by [@david828019-oss](https://github.com/david828019-oss))
## 0.13.2 - 2026-10-26
+3 -4
View File
@@ -117,10 +117,9 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
## Release branches
When the user says "this goes to next", create or
retarget the PR to `next` and preserve that destination through delivery. Follow
[release branch discipline](docs/release.md#release-branch-discipline) for creating
and updating `next`, integrating it after a release, and releasing a hotfix from a tag.
Merge normal PRs into `main`, including while a beta soaks or a release is being finalized. Each
release builds in a short-lived release workspace from `origin/main` or a release tag; see
[release base and source](docs/release.md#release-base-and-source).
## Critical rules
+9 -7
View File
@@ -200,13 +200,15 @@ its own.
## Gotchas
- **Released height.** Reanimated's web entering animation leaves an inline height snapshot on
the surface. `AnchoredSurface` clears it, and a `revision` prop re-clears it when content
identity changes — a pushed page taller than the one it replaced is clipped without that.
- **Animate only once placed.** The same snapshot carries top/left, and Reanimated writes it back
750ms after mount. `AnchoredSurface` remounts the surface when its position resolves so the
entering animation never ends at the off-screen measuring position; on a slow machine that
write moved the open menu back off-screen.
- **Web enters without Reanimated.** For a custom keyframe, Reanimated's web path snapshots the
surface's rect when the animation ends and writes it back as inline top/left/width/height 750ms
after the start. Whatever moved or resized the surface in between was undone: content that
grew once its data arrived left the menu clipped, or hanging over its anchor, until it moved
again. `AnchoredSurface` plays the same keyframes through the Web Animations API on web, which
leaves no inline style; native keeps the Reanimated `Keyframe`.
- **Animate only once placed.** `AnchoredSurface` remounts the surface when its position resolves,
so the entering animation runs at the final position rather than ending unseen at the
off-screen measuring position.
- **Sheets size to content.** `enableDynamicSizing`, not fixed snap points. A pushed page is
rarely the height of the page before it. A fill page is the one exception.
- **The sheet's content is teleported out of the menu's subtree**, so `MenuSheetSurface` rebuilds
+135 -73
View File
@@ -6,10 +6,10 @@ All workspaces share one version and release together.
A release has exactly two steps. The agent does the first, the user authorizes the second.
**Preparation** (local, reversible — agent does this):
**Preparation** (reversible — agent does this):
- format, lint, typecheck all green
- resolve the release source to one commit and confirm that commit's existing CI is green
- resolve the [release base](#release-base-and-source), get the [release inventory](#release-inventory) approved when one applies, and confirm CI for the resulting release source is green
- classify the diff from the previous stable to the release source as patch or minor, then show the
target version and rationale to the user
- draft the changelog, show it to the user, wait for review
@@ -17,9 +17,10 @@ A release has exactly two steps. The agent does the first, the user authorizes t
**Go-ahead** (user says "go ahead"):
- commit the approved release inputs locally
- run the release, which publishes npm and pushes the prepared branch and tag
- commit the approved release inputs locally on the release branch
- run the release, which publishes npm and pushes the release branch and tag
- create the release heartbeat immediately and babysit it to completion
- [reconcile `main`](#reconcile-main) with what shipped
Rules that apply to both steps:
@@ -27,56 +28,128 @@ Rules that apply to both steps:
- No code changes bundled into the changelog commit or the release commit. Code shims live in their own commit, reviewed on their own merits.
- A sanity-check finding is information, not a directive. The agent surfaces it; the user decides.
- Invoking a release skill is intent to start the flow, not blanket authorization to publish.
- An approval the user already gave covers what it names, such as an explicit PR selection or authorization to release autonomously. Do not ask for it again.
- If the user asks for a release preview, show the prospective changelog/release contents and answer questions, but do not commit, tag, publish, or run release commands until they explicitly authorize the release.
## Release source and CI
## Main stays open
The default release source is `origin/main`. Fetch `origin`, then record the
resolved commit. The default release checkout is a clean local `main` whose
`HEAD` equals `origin/main`.
Merge normal PRs into `main` at any time, including while a beta soaks or a release is being
finalized. Each release builds in a short-lived release workspace, and its published tag records
the shipped source.
An explicit user instruction can select another ref, such as a hotfix commit or
tag. Resolve that ref once and apply every source, diff, and CI check to that
commit instead of `origin/main`.
## Choosing a path
Before making release-preparation commits, confirm the existing CI run for the
resolved commit is green. Pending CI is watched to completion. Release
preparation then stays local through the changelog, any explicitly requested ACP
catalog update, lockfile preparation, and the version commit. After approval,
commit the prepared inputs locally and run the release command. Its branch and
tag push is the one remote release batch and starts CI for the complete release
commit.
The user decides when the changes on `main` warrant a release and how much risk it takes:
## Release branch discipline
1. **Direct stable release** for a low-risk batch of polish and bug fixes. Stable desktop updates
already ramp over 36 hours (see [Staged rollout](#staged-rollout-stable-channel)), and store
review often takes about a day, with no guaranteed timing.
2. **Beta flow**, the default for changes that need testing. Release candidates on the `beta`
channel. Each beta folds into the series' single changelog entry, publishes npm only on the
explicit `beta` dist-tag, and stays behind the Stable/Beta switch on `/download`.
While you finalize a release on `main`, use a temporary `next` branch for work intended for the following
release. This applies to both beta and stable releases.
A beta follows these defaults, which the user can explicitly override:
- Create each new `next` from freshly fetched `origin/main`. Reuse it while active.
- "This goes to next" means create the PR against `next` or retarget an existing
PR, and keep that destination through delivery.
- Keep `next` current by merging `origin/main` into it as release fixes land.
Avoid rebasing this shared branch because agents and open PRs depend on its history.
- After the release ships, bring `next` up to date and open a `next` → `main` PR.
Pass CI and merge without squashing away the individual PR commits needed for
the changelog. Retarget remaining PRs based on `next` to `main` and delete the integrated
`next`. Create it fresh when needed again.
- Soak a beta at least 24 hours before promotion. Soak longer for risky or breaking changes, or
while feedback is open. Changes added in a later beta get their own soak; earlier soak time does
not count for them.
- A follow-up beta carries regressions, fixes, and polish for what that beta delivers. Unrelated
work on `main` and pre-existing bugs wait for a later release unless the user explicitly selects
them.
- Promotion ships the tested beta's product content unchanged and does not pull from `main`.
Adding a product change needs the user's explicit selection and a new beta, which soaks again.
- During a soak, ship urgent stable regressions and compatibility or provider model updates as a
[stable hotfix](#release-base-and-source) from the latest stable tag. The beta keeps its own
pace.
**Setup still needed:** PR checks run for every base branch, but CI, Docker, and Nix
push runs target only `main`, and GitHub permits only squash merges. Enable
required-check protection for `next`, CI on its pushes, and merge commits for the
integration PR. Handle PR
base changes (`edited` events) so retargeting runs checks against the new base;
GitHub's default PR events do not cover this. Deployment triggers stay unchanged.
npm dist-tags are independent pointers:
### Hotfix from a release tag
- A beta release publishes with `--tag beta` and moves only `beta`; `latest` remains on the
newest stable.
- A stable release publishes to `latest`. Move `beta` to it as well when the stable version is
newer than the version `beta` points to, which covers promotions and direct stable releases, so
`@getpaseo/cli@beta` users never sit behind stable. A stable hotfix on an older line leaves
`beta` on the active newer beta.
If `main` contains changes you do not want to release, branch from the affected
release tag and cherry-pick only the required fixes. Run CI on that branch, then
use the normal release flow with it as the explicit source, choosing a new patch
or beta version. Ensure the fixes and changelog also reach `main` and any active
`next`, preserving newer development and version changes there. This is a
short-lived hotfix branch, not another maintained release track.
## Release base and source
The release base is the tag or commit a release workspace starts from. The release source is the
base plus any approved cherry-picks, and every source, diff, and CI check uses it.
| Release | Base | Changes on top |
| -------------------------------------------------------- | ------------------------------------------------------------ | ---------------------------- |
| Starting release: first beta of a line, or direct stable | `origin/main`, captured once | None |
| Follow-up beta | The selected published beta tag, normally the series' latest | Approved commits from `main` |
| Promotion | The beta tag being promoted | None by default |
| Stable hotfix | The latest appropriate stable tag | Approved commits from `main` |
An explicit user instruction can select another base.
1. Fetch `origin` with tags and resolve the base to one commit.
2. Create the release workspace through Paseo MCP on a new branch from the base, named
`release/vX.Y.Z` or `release/vX.Y.Z-beta.N` for the version being cut. Run the release from
this workspace: `release:push` pushes `HEAD` to the current branch.
3. Cherry-pick the approved commits in their `main` order with `git cherry-pick -x`, so each
commit records the `main` commit it came from. Include the prerequisites the inventory listed.
Resolve conflicts within the approved changes and report how. A resolution that needs new
behavior is a scope addition: surface it for selection.
4. Confirm CI for the release source. When the source is a commit whose CI already passed (the
captured `origin/main`, or the beta tag being promoted), use that run and watch pending CI to
completion. Otherwise push the release branch and run `gh workflow run ci.yml --ref <branch>`,
because CI push runs target only `main`.
5. Keep the changelog, any explicitly requested ACP catalog update, lockfile preparation, and the
version commit local until the go-ahead. The release command pushes the release branch and tag
as one batch, and the tag push starts the release workflows. Then run
`gh workflow run ci.yml --ref <branch>` so CI covers the complete release commit.
6. [Reconcile `main`](#reconcile-main).
A stable hotfix can take the version a patch beta line would promote to, such as `0.13.3`
shipping while `0.13.3-beta.2` soaks. Never republish or reuse a shipped version. Continue the line
as the next unused patch beta, `0.13.4-beta.1`, from the tested beta tag. No `version:all:*` mode
computes that from a prerelease, so follow the [manual steps](#manual-step-by-step) with
`npm version 0.13.4-beta.1 --include-workspace-root --message "chore(release): cut %s"` and
`npm run release:publish:beta`. Record the tested beta tag as its source so its soak still counts
for the unchanged content.
## Release inventory
When the user asks for a follow-up beta or a promotion, list what `main` has that the base does
not, then get the batch approved. There is no release label or queue.
1. Find the commits on `origin/main` whose patches are not in the base tag:
```bash
git log --cherry-pick --right-only --no-merges --format='%h %s' <base-tag>...origin/main
```
Patch equivalence recognizes commits already cherry-picked into the tag, so trust the tag
content over PR state or branch names.
2. Present a short include/defer list. Link each PR and give one plain reason. List separately the
prerequisites an included PR depends on and anything that adds scope beyond what the beta
delivers. For each fix that shipped in a stable hotfix after the beta's base, confirm the beta
preserves its behavior, directly or through a superseding change. List the missing ones for
explicit selection; adding one to a promotion needs a new beta.
3. Wait for the user to approve the batch, unless an explicit selection or autonomous
authorization they already gave covers it.
For a promotion, every item defaults to defer.
## Reconcile main
After the release push, open a PR into `main` that brings in what shipped and keeps newer
development on `main`:
- The shipped `CHANGELOG.md` and `CHANGELOG.zh-CN.md` entries, exactly as tagged. A beta or
promotion entry replaces the series entry on `main`. A stable hotfix on an older line goes
below any newer entries.
- The shipped version in every `package.json` and `package-lock.json`, unless `main` already
carries a newer version, plus the F-Droid changelog files a stable release generates.
- No claims about unshipped work: a shipped entry describes only the tagged content, and notes
`main` holds for changes that have not shipped stay out of it.
Delete the release branch once the PR merges. The tag is the record of shipped source; Paseo
maintains no release track besides `main`.
## ACP catalog updates
@@ -91,21 +164,6 @@ ACP catalog work enters a release through an explicit user request:
The release authorization covers the requested ACP commit. It ships in the same
release push as the changelog and version commit.
## Two paths
There are two supported release paths:
1. **Direct stable release**: you are ready to ship the resolved release source to everyone immediately (default `origin/main`).
2. **Beta flow**: release candidates on the `beta` channel. Each beta folds into the series' single changelog entry, publishes npm only on the explicit `beta` dist-tag, and stays behind the Stable/Beta switch on `/download`.
Paseo has one linear release track even though npm dist-tags are independent
pointers. The npm invariant is:
- A beta release moves only `beta`; `latest` remains on the newest stable.
- A stable release moves both `latest` and `beta` to that stable version. This
keeps users who install `@getpaseo/cli@beta` on the newest Paseo release after
a beta is promoted or superseded by a direct stable release.
## Release version decision
Every fresh release starts by classifying the full diff from the previous
@@ -133,7 +191,7 @@ as described in **Fixing a failed release build**.
Before running any stable release command:
- Make sure the resolved release source passed CI, the approved release inputs are committed locally on the intended branch, and the working tree is clean.
- Make sure the release source passed CI, the approved release inputs are committed locally on the release branch, and the working tree is clean.
- **Run `npm run format`, `npm run lint`, and `npm run typecheck` and commit any resulting changes BEFORE you start any `release:*` command.** `release:check` runs `npm install --workspaces --include-workspace-root` as part of `release:prepare`, which can mutate `package-lock.json` (e.g. churning `"dev": true` markers on optional deps). The next step, `version:all:*`, runs `npm version` which aborts when the working tree is dirty. If this happens mid-flight you have to commit the lockfile churn before retrying — and the pre-commit format hook will reject a lockfile-only commit because oxfmt internally skips `package-lock.json` while lefthook's glob still matches it. Avoid the whole mess by running format/lint/typecheck first, then `release:prepare` once on its own to absorb any lockfile churn into a normal commit, then start the release.
- Do not use a release command as a substitute for checking whether the current commit is actually ready.
@@ -145,9 +203,9 @@ npm run release:minor
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. The workflows create the GitHub Release as a draft while builds and release-note sync run. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no mobile-release workflow under `.github/workflows`.
After the stable release succeeds, move npm's `beta` pointer to the new stable
version for every published package. This changes dist-tags only; do not
republish the packages:
After the stable release succeeds, check `npm view @getpaseo/cli dist-tags`. When the new stable
is newer than `beta`, move `beta` to it for every published package. This changes dist-tags only;
do not republish the packages:
```bash
PASEO_VERSION=$(node -p "require('./package.json').version")
@@ -156,8 +214,8 @@ for package in highlight relay protocol client plugin server cli; do
done
```
Verify both npm tags now resolve to `PASEO_VERSION` before considering the
stable release complete.
Verify `latest` resolves to `PASEO_VERSION`, and `beta` resolves to `PASEO_VERSION` or the
active newer beta, before considering the stable release complete.
The Docker workflow builds images from the checked-out source tree on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`.
@@ -175,7 +233,7 @@ npm run version:all:patch
npm run version:all:minor
npm run release:publish # Publish to npm
npm run release:push # Push HEAD + tag (triggers CI workflows)
# Then move npm's beta dist-tag to this stable version using the command above.
# Then move npm's beta dist-tag to this stable version when it is newer than beta (see above).
```
## Beta flow
@@ -322,7 +380,8 @@ There is no mobile-release workflow under `.github/workflows`. The EAS GitHub ap
Each stable release publishes the screenshots committed under `assets/store-screenshots/output/` (see [its README](../assets/store-screenshots/README.md) for how they are made). To change the store screenshots, re-render and commit them before you tag.
- **App Store:** `submit_review` uploads `output/apple/` and `output/apple-ipad/` to the version it submits, replacing that version's `en-US` screenshots. Apple ties screenshots to an app version, so a version that is already live keeps the ones it shipped with. `Resubmit iOS for App Store review` uploads them again.
- **Google Play:** `upload_android_screenshots` runs `fastlane android upload_screenshots` after `submit_android`. It replaces only the phone and 10-inch tablet screenshots, never the listing text or changelogs, and skips images whose checksum already matches.
- When `submit_review` fails with `appStoreVersions … is not in valid state`, the reason is in the API error's `meta.associatedErrors`, which fastlane does not print. Read it with a `POST /v1/reviewSubmissionItems` against the version. A required age-rating question that Apple added, such as `socialMedia`, fails this way; answer it under App Information > Age Rating in App Store Connect, then resubmit.
- **Google Play:** `upload_android_screenshots` runs `fastlane android upload_screenshots` after `submit_android`. It replaces only the `en-US` phone and 10-inch tablet screenshots, never the listing text or changelogs, and skips images whose checksum already matches. The lane stages only `en-US`: supply uploads every locale folder in one edit, and Play rejects F-Droid locales such as `ja`, which fails the whole edit. `Upload Google Play screenshots` reruns the step for the current release without a rebuild.
- **F-Droid:** reads the same files as Google Play. `fastlane/metadata/android/en-US/images/phoneScreenshots/` and `tenInchScreenshots/` hold symlinks to the Google renders. `render.mjs` rewrites them on every render, so added or renamed panels reach Google Play and F-Droid as well.
Fastlane cannot use the Play key Expo stores for `eas submit`. The Play lane reads its own copy of the same service-account key from the `GOOGLE_PLAY_JSON_KEY` file secret in the EAS `production` environment, and that service account needs the Play Console permission **Manage store presence**.
@@ -488,7 +547,7 @@ git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
git tag -f v0.1.29-beta.2 HEAD && git push origin v0.1.29-beta.2 --force
```
This ensures the checkout ref matches the actual code on `main` with the fix included.
This ensures the checkout ref matches the code you want built, with the fix included.
- `vX.Y.Z` or `vX.Y.Z-beta.N` rebuilds the full tagged release
- `desktop-vX.Y.Z` rebuilds desktop for all desktop platforms only
@@ -631,7 +690,7 @@ Rules:
This returns every distinct GitHub login that authored or co-authored a commit in the PR. Use those logins for attribution. Fall back to `gh pr view N --json author` only if the commits command returns nothing (which should not happen for merged PRs).
When listing PR numbers, `git log --format='%H %s' v<previous>..<release-source-sha> | grep -E '\(#[0-9]+\)$'` pulls the PR number out of squash commit subjects.
When listing PR numbers, `git log --cherry-pick --right-only --no-merges --format='%H %s' v<previous>...<release-source-sha> | grep -E '\(#[0-9]+\)$'` pulls the PR number out of squash commit subjects and skips commits the previous stable already shipped as cherry-picks.
## Changelog ordering
@@ -663,11 +722,11 @@ Every entry covers `previous stable release → release source`: first beta, lat
### Beta release
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
- [ ] The release base and changes match the [release base table](#release-base-and-source) and any approved [inventory](#release-inventory), and CI for the release source is green
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
- [ ] Fold this beta into the series' single `CHANGELOG.md` entry (heading `## X.Y.Z-beta.N - YYYY-MM-DD`, previous stable → release source), review it against the changelog policy, get approval, and commit it with its [`CHANGELOG.zh-CN.md` translation](#changelog-translations) before cutting the release
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
- [ ] The changelog, ACP, lockfile, and version commits stayed local until the approved release command pushed the release branch and tag
- [ ] `npm run release:beta:patch`, `npm run release:beta:minor`, or `npm run release:beta:next` completes successfully
- [ ] Every GitHub Actions run for the complete release commit and tag is green
- [ ] npm shows the version under the `beta` dist-tag, not `latest`
@@ -678,22 +737,24 @@ Every entry covers `previous stable release → release source`: first beta, lat
- [ ] GitHub `Docker` workflow is green and the versioned beta image is published without moving `latest`
- [ ] GitHub `Release Notes Sync` mirrored the beta entry into the prerelease body
- [ ] EAS `Release iOS Beta` completed its build, TestFlight distribution, external beta group, and Beta App Review path
- [ ] The [reconcile PR](#reconcile-main) brought the shipped changelog entries and version into `main`
- [ ] The release heartbeat was created after the tag push and deleted only after every item above passed
### Stable release (or promotion)
- [ ] Run the pre-release sanity check (see above) and address any findings
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
- [ ] The release base and changes match the [release base table](#release-base-and-source) and any approved [inventory](#release-inventory), and CI for the release source is green
- [ ] A promotion ships the tested beta's product content after its soak, unless the user explicitly overrode either default
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
- [ ] Ensure the approved release inputs are committed locally and the git worktree is clean before running any release command
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any release command
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). Promotion renames the series' `## X.Y.Z-beta.N` entry to `## X.Y.Z - YYYY-MM-DD` and re-checks it covers the full release. Commit the matching [`CHANGELOG.zh-CN.md` entry](#changelog-translations) with it
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
- [ ] The changelog, ACP, lockfile, and version commits stayed local until the approved release command pushed the release branch and tag
- [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully
- [ ] Every GitHub Actions run for the complete release commit and tag is green
- [ ] Move npm's `beta` dist-tag to the new stable version for every published package and verify both `latest` and `beta` resolve to it
- [ ] `latest` resolves to the new stable, and `beta` resolves to it too unless an active newer beta holds `beta`
- [ ] The GitHub Release was published only after the three stable manifests were uploaded, and it has the changelog body and every expected macOS, Linux, Windows, and Android APK asset
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
- [ ] The GitHub Release contains `latest-mac.yml`, `latest-linux.yml`, and `latest.yml`
@@ -707,4 +768,5 @@ Every entry covers `previous stable release → release source`: first beta, lat
- [ ] 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
- [ ] The [reconcile PR](#reconcile-main) brought the shipped changelog entries and version into `main`
- [ ] The release heartbeat was created after the tag push and deleted only after every item above passed
+8
View File
@@ -358,6 +358,14 @@ export to `src`, but browser and Electron Metro resolve its `browser` export to
`lib/module`; CommonJS consumers use `lib/commonjs`. A source-only patch leaves
Electron running the old code.
The adaptive-theme patch keeps `colorScheme` reads off the media-query lists
owned by Unistyles' change listeners. Chromium defers their events while a window
is hidden; reading `matches` on those same lists can consume the pending change.
CSS variables then follow the OS theme while `withUnistyles` props retain the old
colors. Keep this patch until upstream reads the scheme through separate lists.
The desktop browser E2E checks both transition directions in an isolated Electron
process, because Playwright's focus emulation prevents real hidden-page behavior.
Register every dependency patch in `scripts/postinstall-patches.mjs`. A file in
`patches/` is inert unless that script knows which installed package activates
it.
@@ -0,0 +1,10 @@
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
Completed conversation turns can be collapsed into summaries and expanded to read the full exchange.
Voice settings now let you configure speech providers and download local models with one tap, including SenseVoice and Whisper for Chinese dictation. Voice mode is now opt-in.
Added
- Let plugins give tools to specific agents
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,10 @@
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
Completed conversation turns can be collapsed into summaries and expanded to read the full exchange.
Voice settings now let you configure speech providers and download local models with one tap, including SenseVoice and Whisper for Chinese dictation. Voice mode is now opt-in.
Added
- Let plugins give tools to specific agents
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,10 @@
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
Completed conversation turns can be collapsed into summaries and expanded to read the full exchange.
Voice settings now let you configure speech providers and download local models with one tap, including SenseVoice and Whisper for Chinese dictation. Voice mode is now opt-in.
Added
- Let plugins give tools to specific agents
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,10 @@
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
Completed conversation turns can be collapsed into summaries and expanded to read the full exchange.
Voice settings now let you configure speech providers and download local models with one tap, including SenseVoice and Whisper for Chinese dictation. Voice mode is now opt-in.
Added
- Let plugins give tools to specific agents
Full notes: https://paseo.sh/changelog
+1 -1
View File
@@ -1 +1 @@
sha256-AmMp/4ugSUKXTL6H0oJaGJMztVGiYCPXeWteQaaaguw=
sha256-6C4Rjh0mRVrC9akBVGK0NZaCaLJhwKObT7HrgPmcvyw=
+28 -28
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -37590,7 +37590,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -39793,12 +39793,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/server": "0.14.0-beta.2",
"@getpaseo/client": "0.14.0",
"@getpaseo/protocol": "0.14.0",
"@getpaseo/server": "0.14.0",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -40045,10 +40045,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"dependencies": {
"@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/relay": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0",
"@getpaseo/relay": "0.14.0",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
@@ -40060,7 +40060,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -40304,7 +40304,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -40350,7 +40350,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -40379,15 +40379,15 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"i18next": "^26.3.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/client": "0.14.0",
"@getpaseo/protocol": "0.14.0",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -40397,8 +40397,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/client": "0.14.0",
"@getpaseo/protocol": "0.14.0",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -40420,7 +40420,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -40447,7 +40447,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"dependencies": {
"@noble/ciphers": "^2.4.0",
"@noble/curves": "^2.4.0",
@@ -40668,16 +40668,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.14.0-beta.2",
"@getpaseo/highlight": "0.14.0-beta.2",
"@getpaseo/plugin": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/relay": "0.14.0-beta.2",
"@getpaseo/client": "0.14.0",
"@getpaseo/highlight": "0.14.0",
"@getpaseo/plugin": "0.14.0",
"@getpaseo/protocol": "0.14.0",
"@getpaseo/relay": "0.14.0",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -41598,7 +41598,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -43208,7 +43208,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -0,0 +1,27 @@
name: Upload Google Play screenshots
# Standalone re-trigger for the release workflow's Google Play screenshot step.
# Replaces the en-US phone and tablet screenshots with the committed renders,
# without rebuilding or resubmitting the app.
on:
workflow_dispatch:
inputs:
android_version_code:
type: string
required: false
description: "versionCode of the production release to attach the change to, e.g. 14000. Leave empty when the production track has a single release."
jobs:
upload_android_screenshots:
name: Upload Google Play screenshots
environment: production
runs_on: macos-medium
steps:
- uses: eas/checkout
- name: Install fastlane
run: bundle install
- name: Upload screenshots
run: |
export ANDROID_VERSION_CODE="${{ inputs.android_version_code }}"
bundle exec fastlane android upload_screenshots
+1 -1
View File
@@ -102,7 +102,7 @@ export default {
name: variant.name,
slug: "voice-mobile",
version: nativeReleaseVersion.appVersion,
orientation: "portrait",
orientation: "default",
icon: "./assets/images/icon.png",
scheme: "paseo",
userInterfaceStyle: "automatic",
+35
View File
@@ -59,3 +59,38 @@ describe("Android app config", () => {
30_000,
);
});
it("follows system rotation on Android", async () => {
const manifest = await resolveAndroidManifest();
const activity = manifest.application[0].activity.find(
(entry: ManifestEntry) => entry.$["android:name"] === ".MainActivity",
);
expect(activity.$["android:screenOrientation"]).toBe("unspecified");
}, 30_000);
it("allows all orientations on iPhone and iPad", async () => {
const { mkdtemp, rm } = await import("node:fs/promises");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const root = await mkdtemp(join(tmpdir(), "paseo-ios-config-"));
try {
const { exp } = await getPrebuildConfigAsync(__dirname, { platforms: ["ios"] });
await compileModsAsync(exp, {
projectRoot: root,
introspect: true,
platforms: ["ios"],
assertMissingModProviders: false,
});
const plist = exp._internal.modResults.ios.infoPlist;
const orientations = [
"UIInterfaceOrientationPortrait",
"UIInterfaceOrientationPortraitUpsideDown",
"UIInterfaceOrientationLandscapeLeft",
"UIInterfaceOrientationLandscapeRight",
];
expect(plist.UISupportedInterfaceOrientations).toEqual(orientations);
expect(plist["UISupportedInterfaceOrientations~ipad"]).toEqual(orientations);
} finally {
await rm(root, { recursive: true, force: true });
}
}, 30_000);
@@ -0,0 +1,134 @@
import { expect, type Locator, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { createCreationScenario } from "../support/helpers/creation";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
const DRAG_DISTANCE = -40;
const TOLERANCE_PX = 2;
function splitDividers(page: Page): Locator {
return page.getByTestId("workspace-split-resize-handle").filter({ visible: true });
}
async function dividerX(divider: Locator): Promise<number> {
const box = await divider.boundingBox();
if (!box) throw new Error("Divider has no bounding box");
return box.x;
}
async function dragDivider(divider: Locator, deltaX: number): Promise<void> {
const page = divider.page();
const box = await divider.boundingBox();
if (!box) throw new Error("Divider has no bounding box");
const x = box.x + box.width / 2;
const y = box.y + box.height / 2;
await page.mouse.move(x, y);
await page.mouse.down();
await page.mouse.move(x + deltaX, y, { steps: 8 });
await page.mouse.up();
}
async function dragDividerWithTouch(divider: Locator, deltaX: number): Promise<void> {
const page = divider.page();
const box = await divider.boundingBox();
if (!box) throw new Error("Divider has no bounding box");
const x = box.x + box.width / 2;
const y = box.y + box.height / 2;
const input = await page.context().newCDPSession(page);
try {
await input.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
for (let step = 1; step <= 8; step++) {
await input.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x: x + (deltaX * step) / 8, y }],
});
}
await input.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
} finally {
await input.detach();
}
}
async function splitIntoFourPanes(page: Page): Promise<void> {
for (const expectedDividers of [1, 2, 3]) {
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await expect(splitDividers(page)).toHaveCount(expectedDividers);
}
}
async function expectDividersTrackPointer(
page: Page,
distance = DRAG_DISTANCE,
drag = dragDivider,
): Promise<void> {
for (const index of [0, 1, 2]) {
const divider = splitDividers(page).nth(index);
const before = await dividerX(divider);
await drag(divider, distance);
await expect
.poll(async () => Math.abs((await dividerX(divider)) - before - distance), {
message: `divider ${index + 1} should stay under the pointer`,
})
.toBeLessThanOrEqual(TOLERANCE_PX);
}
}
for (const hasTouch of [false, true]) {
test.describe(`Split pane drag tracking (${hasTouch ? "coarse" : "fine"} pointer)`, () => {
test.use({ hasTouch });
test("a divider moves as far as the pointer in the main row", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 });
const workspace = await seedWorkspace({ repoPrefix: "split-drag-tracking-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await clickNewChat(page);
await splitIntoFourPanes(page);
await expectDividersTrackPointer(
page,
DRAG_DISTANCE,
hasTouch ? dragDividerWithTouch : dragDivider,
);
} finally {
await workspace.cleanup();
}
});
test("dividers track the pointer after app creation, Explorer changes and reload", async ({
page,
}) => {
await page.setViewportSize({ width: 1600, height: 900 });
const creation = await createCreationScenario(page);
try {
await creation.openWorkspaceForm("local");
await creation.submitPrompt("Reply briefly.", "Create");
await creation.expectAssistantReply();
await waitForWorkspaceTabsVisible(page);
await splitIntoFourPanes(page);
await expectDividersTrackPointer(page);
await page
.getByTestId("workspace-explorer-toggle")
.filter({ visible: true })
.first()
.click();
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
await expectDividersTrackPointer(page, -DRAG_DISTANCE);
await page
.getByTestId("workspace-explorer-toggle")
.filter({ visible: true })
.first()
.click();
await expect(page.getByTestId("workspace-explorer-sidebar")).not.toBeVisible();
await page.reload();
await expect(splitDividers(page)).toHaveCount(3);
await expectDividersTrackPointer(page);
} finally {
await creation.cleanup();
}
});
});
}
+17 -1
View File
@@ -26,6 +26,22 @@ def stage_app_store_screenshots
root
end
# Google Play only has en-US screenshots, and some F-Droid locales in
# ANDROID_METADATA are not Play languages (Play rejects "ja"). supply uploads
# every locale folder in one edit, so give it only the en-US images.
def stage_google_play_screenshots
root = Dir.mktmpdir("google-play-screenshots")
images = File.join(ANDROID_METADATA, "en-US/images")
%w[phoneScreenshots tenInchScreenshots].each do |kind|
target = File.join(root, "en-US/images", kind)
FileUtils.mkdir_p(target)
Dir.glob(File.join(images, kind, "*.png")).sort.each do |file|
FileUtils.cp(File.realpath(file), File.join(target, File.basename(file)))
end
end
root
end
platform :ios do
desc "Submit the latest TestFlight build to App Store Review"
lane :submit_review do
@@ -132,7 +148,7 @@ platform :android do
supply(
package_name: APP_IDENTIFIER,
json_key: ENV.fetch("GOOGLE_PLAY_JSON_KEY"),
metadata_path: ANDROID_METADATA,
metadata_path: stage_google_play_screenshots,
version_code: version_code.empty? ? nil : version_code.to_i,
skip_upload_apk: true,
skip_upload_aab: true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"private": true,
"main": "index.ts",
"scripts": {
@@ -1,8 +1,8 @@
import { Modal } from "@/components/ui/modal";
import equal from "fast-deep-equal";
import { recordRenderProfileReasons } from "@/utils/render-profiler";
import {
FlatList,
Modal,
Pressable,
Text,
View,
@@ -1,3 +1,4 @@
import { Modal } from "@/components/ui/modal";
import { usePanelStore } from "@/stores/panel-store";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { router } from "expo-router";
@@ -22,14 +23,7 @@ import {
type ComponentType,
type RefObject,
} from "react";
import {
Modal,
Pressable,
ScrollView,
Text,
View,
type PressableStateCallbackType,
} from "react-native";
import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
import {
EditingTextInput as TextInput,
type EditingTextInputHandle,
+1 -1
View File
@@ -1,8 +1,8 @@
import { Modal } from "@/components/ui/modal";
import {
View,
Text,
Pressable,
Modal,
RefreshControl,
FlatList,
type ListRenderItem,
@@ -1,5 +1,6 @@
import { Modal } from "@/components/ui/modal";
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { Modal, Pressable, Text, View } from "react-native";
import { Pressable, Text, View } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles";
import { SafeAreaProvider, useSafeAreaInsets } from "react-native-safe-area-context";
@@ -1,5 +1,6 @@
import { Modal } from "@/components/ui/modal";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { Modal, View } from "react-native";
import { View } from "react-native";
import { X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
@@ -1,6 +1,6 @@
import { Modal } from "@/components/ui/modal";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Modal,
Pressable,
StyleSheet as RNStyleSheet,
ScrollView,
+21 -14
View File
@@ -49,7 +49,7 @@ export function ResizeHandle({
const { theme } = useUnistyles();
const finePointer = useHasFinePointer();
const pointerStatesRef = useRef(new Map<number, PointerState>());
const touchDragRef = useRef<ResizeHandleDrag | null>(null);
const touchStateRef = useRef<PointerState | null>(null);
const cursorBeforeDragRef = useRef<string | null>(null);
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [active, setActive] = useState(false);
@@ -151,23 +151,30 @@ export function ResizeHandle({
const touchGesture = useMemo(() => {
const gesture = Gesture.Pan()
.runOnJS(true)
.onBegin(() => setDragging(true))
.onStart(() => {
touchDragRef.current = startResizeHandleDrag({
sizes,
index,
preview: (nextSizes) => onPreviewResizeSplit(groupId, nextSizes),
commit: (nextSizes) => onResizeSplit(groupId, nextSizes),
});
.onBegin((event) => {
setDragging(true);
touchStateRef.current = {
containerSize,
pointerStart: direction === "horizontal" ? event.absoluteX : event.absoluteY,
drag: startResizeHandleDrag({
sizes,
index,
preview: (nextSizes) => onPreviewResizeSplit(groupId, nextSizes),
commit: (nextSizes) => onResizeSplit(groupId, nextSizes),
}),
};
})
.onUpdate((event) => {
if (containerSize <= 0) return;
const translation = direction === "horizontal" ? event.translationX : event.translationY;
touchDragRef.current?.move(translation / containerSize);
const state = touchStateRef.current;
if (!state || state.containerSize <= 0) return;
// Gesture Handler resets web translation when the pan activates. Measure from
// the initial press so the activation distance is included on every platform.
const pointerCurrent = direction === "horizontal" ? event.absoluteX : event.absoluteY;
state.drag.move((pointerCurrent - state.pointerStart) / state.containerSize);
})
.onEnd(() => touchDragRef.current?.finish())
.onEnd(() => touchStateRef.current?.drag.finish())
.onFinalize(() => {
touchDragRef.current = null;
touchStateRef.current = null;
setDragging(false);
});
@@ -48,6 +48,7 @@ import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-
import {
collectAllPanes,
createDefaultLayout,
selectExplorerSidebarPaneId,
useWorkspaceLayoutStore,
type SplitNode,
type SplitPane,
@@ -183,8 +184,8 @@ export function SplitContainer({
} | null>(null);
const maximizedPaneId =
maximizedPane?.workspaceKey === workspaceKey ? maximizedPane.paneId : null;
const explorerSidebarPaneId = useWorkspaceLayoutStore(
(state) => state.explorerSidebarPaneIdByWorkspace[workspaceKey] ?? null,
const explorerSidebarPaneId = useWorkspaceLayoutStore((state) =>
selectExplorerSidebarPaneId(state, workspaceKey),
);
const mainRoot = useMemo(
@@ -64,7 +64,7 @@ function resolveShouldFill(
): boolean {
if (!fillAvailableHeight) return false;
const t = detail?.type;
return t === "shell" || t === "edit" || t === "write" || t === "read" || t === "sub_agent";
return t === "shell" || t === "edit" || t === "write" || t === "read";
}
function useDetailStyles(
@@ -186,184 +186,6 @@ function ShellDetailSection({ command, output, ds }: ShellDetailProps) {
);
}
function resolveSubAgentFallbackHeader(
subAgentType: string | null | undefined,
description: string | null | undefined,
fallbackText: string,
): string {
if (subAgentType && description) {
return `${subAgentType}: ${description}`;
}
return subAgentType ?? description ?? fallbackText;
}
interface SubAgentDetailProps {
log: string;
childSessionId: string | null | undefined;
subAgentType: string | null | undefined;
description: string | null | undefined;
ds: DetailStyles;
}
interface SubAgentActivityRow {
index: number;
toolName: string;
summary?: string;
}
interface ParsedSubAgentLog {
actions: SubAgentActivityRow[];
remainingLog: string;
}
function parseBracketedSubAgentLine(line: string, index: number): SubAgentActivityRow | null {
const match = line.match(/^\[([^\]]+)\](?:\s+(.*))?$/);
if (!match) {
return null;
}
const toolName = match[1]?.trim();
if (!toolName) {
return null;
}
const summary = match[2]?.trim();
return {
index,
toolName,
...(summary ? { summary } : {}),
};
}
function parseSubAgentLog(log: string): ParsedSubAgentLog {
const actions: SubAgentActivityRow[] = [];
const remainingLines: string[] = [];
for (const line of log.replace(/^\n+/, "").split("\n")) {
const normalizedLine = line.trim();
if (!normalizedLine) {
continue;
}
const parsedAction = parseBracketedSubAgentLine(normalizedLine, actions.length + 1);
if (parsedAction) {
actions.push(parsedAction);
} else {
remainingLines.push(line);
}
}
return {
actions,
remainingLog: remainingLines.join("\n").replace(/^\n+/, ""),
};
}
function SubAgentActionRow({ action }: { action: SubAgentActivityRow }) {
return (
<View style={styles.subAgentActionRow}>
<Text selectable style={styles.subAgentActionTool}>
{formatSubAgentToolName(action.toolName)}
</Text>
{action.summary ? (
<Text selectable style={styles.subAgentActionSummary}>
{action.summary}
</Text>
) : null}
</View>
);
}
function formatSubAgentToolName(toolName: string): string {
const trimmed = toolName.trim();
if (!trimmed) {
return toolName;
}
return trimmed
.replace(/[._-]+/g, " ")
.split(" ")
.filter((segment) => segment.length > 0)
.map((segment) => `${segment[0]?.toUpperCase() ?? ""}${segment.slice(1)}`)
.join(" ");
}
function SubAgentLogText({
activityLog,
fallbackHeader,
hasActions,
}: {
activityLog: string;
fallbackHeader: string;
hasActions: boolean;
}) {
if (activityLog.length > 0) {
return (
<Text selectable style={styles.scrollText}>
{activityLog}
</Text>
);
}
if (!hasActions) {
return (
<Text selectable style={styles.scrollText}>
{fallbackHeader}
</Text>
);
}
return null;
}
function SubAgentDetailSection({
log,
childSessionId,
subAgentType,
description,
ds,
}: SubAgentDetailProps) {
const { t } = useTranslation();
const { actions, remainingLog } = useMemo(() => parseSubAgentLog(log), [log]);
const fallbackHeader = resolveSubAgentFallbackHeader(
subAgentType,
description,
t("toolCallDetails.subAgentActivity"),
);
const hasActions = actions.length > 0;
return (
<View style={ds.sectionFillStyle}>
<View style={ds.codeBlockFillStyle}>
<ScrollView
style={ds.codeVerticalScrollStyle}
contentContainerStyle={styles.codeVerticalContent}
nestedScrollEnabled
showsVerticalScrollIndicator
>
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
contentContainerStyle={styles.codeHorizontalContent}
>
<View style={styles.codeLine} dataSet={CODE_SURFACE_DATASET}>
{childSessionId ? (
<Text selectable style={styles.subAgentSessionText}>
session {childSessionId}
</Text>
) : null}
{hasActions ? (
<View style={styles.subAgentActions}>
{actions.map((action) => (
<SubAgentActionRow key={action.index} action={action} />
))}
</View>
) : null}
<SubAgentLogText
activityLog={remainingLog}
fallbackHeader={fallbackHeader}
hasActions={hasActions}
/>
</View>
</ScrollView>
</ScrollView>
</View>
</View>
);
}
interface EditDetailProps {
diffLines: DiffLine[] | undefined;
ds: DetailStyles;
@@ -636,19 +458,7 @@ function buildDetailSections(
<ShellDetailSection key="shell" command={detail.command} output={detail.output} ds={ds} />,
];
}
if (detail.type === "worktree_setup") return [];
if (detail.type === "sub_agent") {
return [
<SubAgentDetailSection
key="sub-agent"
log={detail.log}
childSessionId={detail.childSessionId}
subAgentType={detail.subAgentType}
description={detail.description}
ds={ds}
/>,
];
}
if (detail.type === "worktree_setup" || detail.type === "sub_agent") return [];
if (detail.type === "edit") {
return [<EditDetailSection key="edit" diffLines={diffLines} ds={ds} />];
}
@@ -900,34 +710,6 @@ const styles = StyleSheet.create((theme) => {
shellPrompt: {
color: theme.colors.foregroundMuted,
},
subAgentSessionText: {
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
color: theme.colors.foregroundMuted,
lineHeight: 18,
marginBottom: theme.spacing[2],
},
subAgentActions: {
gap: theme.spacing[1],
marginBottom: theme.spacing[2],
},
subAgentActionRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
subAgentActionTool: {
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
color: theme.colors.foregroundMuted,
lineHeight: 18,
},
subAgentActionSummary: {
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
color: theme.colors.foreground,
lineHeight: 18,
},
jsonScroll: {
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
+1 -1
View File
@@ -1,3 +1,4 @@
import { Modal } from "@/components/ui/modal";
import {
useCallback,
useEffect,
@@ -12,7 +13,6 @@ import {
View,
Text,
Pressable,
Modal,
ScrollView,
Platform,
StatusBar,
@@ -0,0 +1,124 @@
import React, { act, useEffect, useState, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { Text, View } from "react-native";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { page } from "vitest/browser";
import { MenuRoot, MenuSurface, MenuTrigger, type Size } from "./index";
// App sources compile against the classic JSX runtime, which expects React on the global.
beforeEach(() => vi.stubGlobal("React", React));
/**
* A real browser, because the regression lives in Reanimated's web entering path: it snapshots
* the surface's rect when a custom keyframe ends and writes that rect back as inline
* top/left/width/height when it cleans the keyframe up, 750ms after the animation starts.
* Anything that moved or resized the surface in between is undone by that write.
*/
interface Mounted {
root: Root;
container: HTMLDivElement;
}
const mounted: Mounted[] = [];
function mount(node: ReactNode): void {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => root.render(node));
mounted.push({ root, container });
}
afterEach(() => {
for (const entry of mounted.splice(0)) {
act(() => entry.root.unmount());
entry.container.remove();
}
});
const OFFSET = 12;
const PILL_POSITION = { position: "absolute", top: 700, left: 40 } as const;
const SHORT: Size = { width: 200, height: 40 };
const TALL: Size = { width: 200, height: 240 };
const WIDE: Size = { width: 480, height: 40 };
/** Inside the window between the entering animation ending and Reanimated's cleanup. */
const GROWS_BEFORE_CLEANUP_MS = 400;
/** Past Reanimated's cleanup, where the surface used to keep a fixed height. */
const GROWS_AFTER_CLEANUP_MS = 1_000;
/** Long enough after the content grew for anything that would write to the surface to land. */
const SETTLE_MS = 1_200;
/** Plain timers, not `act`: an async `act` holds every update until it resolves. */
function wait(ms: number): Promise<void> {
return new Promise<void>((resolve) => setTimeout(resolve, ms));
}
function element(testID: string): HTMLElement {
const found = document.querySelector(`[data-testid="${testID}"]`);
if (!(found instanceof HTMLElement)) throw new Error(`${testID} did not render`);
return found;
}
/** Stands in for a popover whose data lands after it opened, like a plugin's first RPC. */
function GrowsAfterOpen({ afterMs, from, to }: { afterMs: number; from: Size; to: Size }) {
const [grown, setGrown] = useState(false);
useEffect(() => {
const timer = setTimeout(() => setGrown(true), afterMs);
return () => clearTimeout(timer);
}, [afterMs]);
return <View testID="body" style={grown ? to : from} />;
}
function mountMenuAbovePill(body: ReactNode): void {
mount(
<View style={PILL_POSITION}>
<MenuRoot>
<MenuTrigger testID="pill" accessibilityLabel="Open panel">
<Text>Open panel</Text>
</MenuTrigger>
<MenuSurface
side="top"
align="start"
offset={OFFSET}
minWidth={280}
maxWidth={620}
testID="panel"
>
{body}
</MenuSurface>
</MenuRoot>
</View>,
);
act(() => element("pill").click());
}
describe("anchored menu surface on web", () => {
beforeEach(async () => {
await page.viewport(1280, 900);
});
it.each([GROWS_BEFORE_CLEANUP_MS, GROWS_AFTER_CLEANUP_MS])(
"stays on its anchor when its content grows %ims after opening",
async (afterMs) => {
mountMenuAbovePill(<GrowsAfterOpen afterMs={afterMs} from={SHORT} to={TALL} />);
await wait(afterMs + SETTLE_MS);
const pill = element("pill").getBoundingClientRect();
const panel = element("panel").getBoundingClientRect();
expect(panel.height).toBeGreaterThan(240);
expect(panel.bottom).toBeLessThanOrEqual(pill.top);
},
);
it("widens when its content widens after the entering animation", async () => {
mountMenuAbovePill(<GrowsAfterOpen afterMs={GROWS_BEFORE_CLEANUP_MS} from={SHORT} to={WIDE} />);
await wait(GROWS_BEFORE_CLEANUP_MS + SETTLE_MS);
const body = element("body").getBoundingClientRect();
const panel = element("panel").getBoundingClientRect();
expect(panel.width).toBeGreaterThanOrEqual(body.width);
});
});
@@ -1,7 +1,9 @@
import { Modal } from "@/components/ui/modal";
import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useMemo,
useState,
type ReactElement,
@@ -11,7 +13,6 @@ import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import {
Dimensions,
Modal,
Platform,
Pressable,
View,
@@ -47,56 +48,41 @@ const contentEntering = new Keyframe({
100: { opacity: 1, transform: [{ scale: 1 }] },
}).duration(CONTENT_ENTERING_DURATION_MS);
/** `contentEntering` for the browser's own animation engine. Both default to linear easing. */
const WEB_CONTENT_ENTERING_KEYFRAMES: PropertyIndexedKeyframes = {
opacity: [0, 1],
transform: ["scale(0.97)", "scale(1)"],
};
/** Web plays the same keyframes itself; see `useWebContentEntering`. */
const nativeContentEntering = isWeb ? undefined : contentEntering;
const contentExiting = new Keyframe({
0: { opacity: 1, transform: [{ scale: 1 }] },
100: { opacity: 0, transform: [{ scale: 0.97 }] },
}).duration(100);
function releaseFixedMenuHeight(surfaceNativeID: string): void {
if (!isWeb) return;
document.getElementById(surfaceNativeID)?.style.removeProperty("height");
}
/**
* Reanimated's web entering animation leaves an inline height snapshot on the measured surface.
* Once the menu is open, height must return to content-sized so rows can grow in place — and so
* a submenu page taller than the page it replaced is not clipped to the old height.
*
* `revision` is what makes that second case work: bump it whenever the rendered content changes
* identity (a page push, for instance) and the snapshot is released again.
* On web the surface enters through the Web Animations API rather than Reanimated. Reanimated's
* web path snapshots an element's rect when a custom keyframe ends and, when it cleans the keyframe
* up 750ms after the start, writes that rect back as inline top/left/width/height. A surface that
* moved or resized in between — content that grew once its data arrived — snapped back to the old
* rect and stayed there, clipped or hanging past its anchor. A Web Animation leaves no inline style.
*/
function useReleaseFixedMenuHeight({
contentSize,
enabled,
function useWebContentEntering({
placed,
surfaceNativeID,
revision,
}: {
contentSize: Size | null;
enabled: boolean;
placed: boolean;
surfaceNativeID: string;
revision?: string | number;
}): void {
useEffect(() => {
if (!enabled) return undefined;
const release = () => {
releaseFixedMenuHeight(surfaceNativeID);
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => releaseFixedMenuHeight(surfaceNativeID));
}
};
const timers: ReturnType<typeof setTimeout>[] = [
setTimeout(release, CONTENT_ENTERING_DURATION_MS),
];
if (contentSize) {
timers.push(setTimeout(release, 0));
}
return () => {
for (const timer of timers) clearTimeout(timer);
};
}, [contentSize, enabled, surfaceNativeID, revision]);
useLayoutEffect(() => {
if (!isWeb || !placed || typeof document === "undefined") return undefined;
const animation = document
.getElementById(surfaceNativeID)
?.animate(WEB_CONTENT_ENTERING_KEYFRAMES, CONTENT_ENTERING_DURATION_MS);
return () => animation?.cancel();
}, [placed, surfaceNativeID]);
}
/**
@@ -224,8 +210,6 @@ export interface AnchoredSurfaceProps {
fullWidth?: boolean;
horizontalPadding?: number;
scrollable?: boolean;
/** Bump when the rendered content changes identity, so the height snapshot is released. */
revision?: string | number;
/** A submenu sits inside its parent's overlay and must not paint a second backdrop. */
backdrop?: boolean;
/**
@@ -259,7 +243,6 @@ export function AnchoredSurface({
fullWidth = false,
horizontalPadding = 16,
scrollable = false,
revision,
backdrop = true,
onPointerEnter,
onPointerLeave,
@@ -269,33 +252,25 @@ export function AnchoredSurface({
}: AnchoredSurfaceProps): ReactElement | null {
const { t } = useTranslation();
const surfaceNativeID = useId();
const { position, actualPlacement, contentSize, visibleContentSize, onContentLayout } =
useAnchoredPosition({
open,
anchorRect,
anchorRef,
side,
align,
offset,
scrollable,
maxHeight,
});
useReleaseFixedMenuHeight({
contentSize,
enabled: open,
surfaceNativeID,
revision,
const { position, actualPlacement, visibleContentSize, onContentLayout } = useAnchoredPosition({
open,
anchorRect,
anchorRef,
side,
align,
offset,
scrollable,
maxHeight,
});
// The surface is placed once both measurements land. Until then it sits off-screen so it can
// be measured, and it must not animate: Reanimated's web entering path snapshots the element's
// rect when the animation ends and writes that rect back as inline top/left when it cleans the
// keyframe up 750ms later. If the animation ends before placement, the snapshot is the
// off-screen rect and the menu jumps back off-screen. Remounting on placement starts the
// animation from the final position.
// be measured, and it must not animate: an entering animation that ran there would be over
// before anyone saw the menu. Remounting on placement starts the native animation from the
// final position; web starts its own animation on placement.
const placed = position !== null;
useWebContentEntering({ placed, surfaceNativeID });
useEffect(() => {
if (!isWeb || !open || !placed || typeof document === "undefined") return undefined;
const frame = requestAnimationFrame(() => {
@@ -383,7 +358,7 @@ export function AnchoredSurface({
dataSet={surfaceDataSet}
style={styles.content}
frameStyle={frameStyle}
entering={placed ? contentEntering : undefined}
entering={placed ? nativeContentEntering : undefined}
exiting={
!placed || isWeb || !onExited
? undefined
@@ -344,7 +344,6 @@ function MenuFlyout({
maxHeight={maxHeight}
scrollable={scrollable}
backdrop={false}
revision={page.id}
onPointerEnter={cancelHoverClose}
onPointerLeave={handleHoverOut}
testID={testID}
+13
View File
@@ -0,0 +1,13 @@
import { Modal as NativeModal, type ModalProps } from "react-native";
// React Native defaults iPhone modals to portrait even when the app supports rotation.
const orientations: NonNullable<ModalProps["supportedOrientations"]> = [
"portrait",
"portrait-upside-down",
"landscape-left",
"landscape-right",
];
export function Modal(props: ModalProps) {
return <NativeModal supportedOrientations={orientations} {...props} />;
}
+1 -1
View File
@@ -1,3 +1,4 @@
import { Modal } from "@/components/ui/modal";
import {
Children,
cloneElement,
@@ -16,7 +17,6 @@ import { createPortal } from "react-dom";
import {
Dimensions,
Platform,
Modal,
Pressable,
View,
type PressableProps,
+2 -8
View File
@@ -1,13 +1,7 @@
import { Modal } from "@/components/ui/modal";
import { usePanelStore } from "@/stores/panel-store";
import { createElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Modal,
Pressable,
ScrollView,
Text,
View,
type PressableStateCallbackType,
} from "react-native";
import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
import {
EditingTextInput as TextInput,
type EditingTextInputHandle,
-1
View File
@@ -832,7 +832,6 @@ describe("translation resources", () => {
expect(en.common.placeholders.search).toBe("Search...");
expect(en.common.empty.noResults).toBe("No results found");
expect(en.common.empty.noOptionsMatchSearch).toBe("No options match your search.");
expect(en.toolCallDetails.subAgentActivity).toBe("Sub-agent activity");
expect(en.panels.file.failedToLoadPreview).toBe("Failed to load file preview");
});
-1
View File
@@ -2013,7 +2013,6 @@ export const ar: TranslationResources = {
toolCallDetails: {
error: "خطأ",
empty: "لا توجد تفاصيل إضافية متاحة",
subAgentActivity: "نشاط الوكيل الفرعي",
input: "مدخل",
output: "الإخراج",
},
-1
View File
@@ -2038,7 +2038,6 @@ export const en = {
toolCallDetails: {
error: "Error",
empty: "No additional details available",
subAgentActivity: "Sub-agent activity",
input: "Input",
output: "Output",
},
-1
View File
@@ -2061,7 +2061,6 @@ export const es: TranslationResources = {
toolCallDetails: {
error: "Error",
empty: "No hay detalles adicionales disponibles",
subAgentActivity: "Actividad de subagente",
input: "Aporte",
output: "Producción",
},
-1
View File
@@ -2060,7 +2060,6 @@ export const fr: TranslationResources = {
toolCallDetails: {
error: "Erreur",
empty: "Aucun détail supplémentaire disponible",
subAgentActivity: "Activité du sous-agent",
input: "Entrée",
output: "Sortie",
},
-1
View File
@@ -2033,7 +2033,6 @@ export const ja: TranslationResources = {
toolCallDetails: {
error: "エラー",
empty: "追加の詳細はありません",
subAgentActivity: "サブエージェントアクティビティ",
input: "入力",
output: "出力",
},
-1
View File
@@ -2023,7 +2023,6 @@ export const ko: TranslationResources = {
toolCallDetails: {
error: "오류",
empty: "추가 세부 정보가 없습니다",
subAgentActivity: "서브에이전트 활동",
input: "입력",
output: "출력",
},
-1
View File
@@ -2046,7 +2046,6 @@ export const ptBR: TranslationResources = {
toolCallDetails: {
error: "Erro",
empty: "Nenhum detalhe adicional disponível",
subAgentActivity: "Atividade do subagente",
input: "Entrada",
output: "Saída",
},
-1
View File
@@ -2046,7 +2046,6 @@ export const ru: TranslationResources = {
toolCallDetails: {
error: "Ошибка",
empty: "Дополнительные сведения отсутствуют",
subAgentActivity: "Активность субагента",
input: "Входные данные",
output: "Выходные данные",
},
-1
View File
@@ -1991,7 +1991,6 @@ export const zhCN: TranslationResources = {
toolCallDetails: {
error: "错误",
empty: "没有可用的更多详情",
subAgentActivity: "Sub-agent 活动",
input: "输入",
output: "输出",
},
@@ -40,12 +40,6 @@ function hasMeaningfulFetchDetail(detail: Extract<ToolCallDetail, { type: "fetch
return Boolean(detail.url || detail.result || detail.codeText);
}
function hasMeaningfulSubAgentDetail(
detail: Extract<ToolCallDetail, { type: "sub_agent" }>,
): boolean {
return Boolean(detail.subAgentType || detail.description || detail.log);
}
function hasMeaningfulPlainTextDetail(
detail: Extract<ToolCallDetail, { type: "plain_text" }>,
): boolean {
@@ -75,9 +69,9 @@ export function hasMeaningfulToolCallDetail(detail: ToolCallDetail | undefined):
case "fetch":
return hasMeaningfulFetchDetail(detail);
case "worktree_setup":
return false;
// A sub-agent card is a launch marker; the child's activity lives in the sub-agent panel.
case "sub_agent":
return hasMeaningfulSubAgentDetail(detail);
return false;
case "plain_text":
return hasMeaningfulPlainTextDetail(detail);
case "plan":
@@ -92,7 +86,7 @@ export function isPendingToolCallDetail(params: {
status: "executing" | "running" | "completed" | "failed" | "canceled";
error: unknown;
}): boolean {
if (params.detail?.type === "worktree_setup") return false;
if (params.detail?.type === "worktree_setup" || params.detail?.type === "sub_agent") return false;
const isRunning = params.status === "running" || params.status === "executing";
return isRunning && params.error == null && !hasMeaningfulToolCallDetail(params.detail);
}
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"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.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/server": "0.14.0-beta.2",
"@getpaseo/client": "0.14.0",
"@getpaseo/protocol": "0.14.0",
"@getpaseo/server": "0.14.0",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -47,8 +47,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/relay": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0",
"@getpaseo/relay": "0.14.0",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
@@ -0,0 +1,158 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import electronPath from "electron";
import { build } from "esbuild";
export async function runAdaptiveThemeRegression(artifactDir) {
const { outputFiles } = await build({
stdin: {
resolveDir: fileURLToPath(new URL("../../app", import.meta.url)),
contents: `
import { createElement } from "react";
import { createRoot } from "react-dom/client";
import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unistyles";
StyleSheet.configure({
themes: {
dark: { foreground: "#fafafa", background: "#181b1a" },
light: { foreground: "#1a1a1e", background: "#ffffff" },
},
settings: { adaptiveThemes: true },
});
const Message = withUnistyles(({ color }) =>
createElement("p", { style: { color } }, "Theme-reactive message"));
createRoot(document.getElementById("root")).render(createElement(Message, {
style: { margin: 0 },
uniProps: theme => ({ color: theme.foreground }),
}));
window.readThemeForTest = () => UnistylesRuntime.themeName;
`,
},
bundle: true,
write: false,
format: "iife",
platform: "browser",
alias: { "react-native": "react-native-web" },
define: { __DEV__: "false" },
resolveExtensions: [".web.js", ".js", ".json"],
});
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-adaptive-theme-"));
try {
await fs.writeFile(path.join(directory, "renderer.js"), outputFiles[0].text);
await fs.writeFile(path.join(directory, "main.cjs"), `(${electronRegression.toString()})()`);
// A separate process avoids Playwright's focus emulation, which keeps hidden pages visible.
const env = { ...process.env };
delete env.ELECTRON_RUN_AS_NODE;
const args = [
...(process.platform === "linux" ? ["--no-sandbox"] : []),
path.join(directory, "main.cjs"),
artifactDir,
];
const command = process.platform === "linux" ? "xvfb-run" : electronPath;
const commandArgs =
process.platform === "linux"
? ["-a", "--server-args=-screen 0 1280x800x24", electronPath, ...args]
: args;
await promisify(execFile)(command, commandArgs, {
env,
timeout: 30_000,
});
return JSON.parse(await fs.readFile(path.join(artifactDir, "adaptive-theme.json"), "utf8"));
} finally {
await fs.rm(directory, { recursive: true, force: true });
}
}
async function electronRegression() {
const { app, BrowserWindow, nativeTheme } = require("electron");
const assert = require("node:assert/strict");
const files = require("node:fs");
const paths = require("node:path");
const { setTimeout: delay } = require("node:timers/promises");
app.setPath("userData", paths.join(__dirname, "profile"));
app.on("window-all-closed", () => {});
// Electron keeps Linux's --no-sandbox in argv; the artifact directory is always last.
const artifactDir = process.argv.at(-1);
let window;
const observations = [];
const colors = {
dark: { foreground: "rgb(250, 250, 250)", background: "rgb(24, 27, 26)" },
light: { foreground: "rgb(26, 26, 30)", background: "rgb(255, 255, 255)" },
};
async function read(expression) {
return window.webContents.executeJavaScript(expression);
}
async function expectValue(expression, expected) {
let actual;
for (let attempt = 0; attempt < 50; attempt++) {
actual = await read(expression);
if (actual === expected) return;
await delay(50);
}
assert.equal(actual, expected, expression);
}
try {
await app.whenReady();
if (process.platform === "darwin") {
app.setActivationPolicy("accessory");
app.dock.hide();
}
for (const [from, to] of [
["dark", "light"],
["light", "dark"],
]) {
nativeTheme.themeSource = from;
window = new BrowserWindow({ width: 600, height: 200, show: false, skipTaskbar: true });
await window.loadURL(
"data:text/html," +
encodeURIComponent(`<!doctype html>
<style>body { background: var(--background); } p { font: 20px system-ui; }</style>
<div id="root"></div>`),
);
await read(files.readFileSync(paths.join(__dirname, "renderer.js"), "utf8"));
window.showInactive();
const foreground =
"document.querySelector('p') && getComputedStyle(document.querySelector('p')).color";
const background = "getComputedStyle(document.body).backgroundColor";
await expectValue(foreground, colors[from].foreground);
await expectValue(background, colors[from].background);
window.hide();
await expectValue("document.visibilityState", "hidden");
nativeTheme.themeSource = to;
await expectValue(`matchMedia('(prefers-color-scheme: ${to})').matches`, true);
// Reading the public runtime while hidden must not consume the pending theme notification.
assert.equal(await read("window.readThemeForTest()"), to);
window.showInactive();
await expectValue("document.visibilityState", "visible");
await expectValue(background, colors[to].background);
try {
await expectValue(foreground, colors[to].foreground);
} finally {
files.writeFileSync(
paths.join(artifactDir, `theme-${from}-to-${to}.png`),
(await window.webContents.capturePage()).toPNG(),
);
}
observations.push({
from,
to,
foreground: await read(foreground),
background: await read(background),
});
window.destroy();
window = undefined;
}
files.writeFileSync(
paths.join(artifactDir, "adaptive-theme.json"),
JSON.stringify(observations, null, 2),
);
app.exit(0);
} catch (error) {
console.error(error);
window?.destroy();
app.exit(1);
}
}
+8 -1
View File
@@ -14,6 +14,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
import { chromium } from "playwright";
import { hideDeveloperToasts } from "../../app/e2e/support/developer-ui.mjs";
import { runAppearanceFontSizeRegression } from "./appearance-font-size.electron.mjs";
import { runAdaptiveThemeRegression } from "./appearance-theme.electron.mjs";
import { runSettingsScrollRegression } from "./settings-scroll.electron.mjs";
import { runSettingsMemoryRegression } from "./settings-memory.electron.mjs";
@@ -1241,6 +1242,7 @@ async function main() {
return;
}
const adaptiveTheme = await runAdaptiveThemeRegression(artifactDir);
await runAppearanceFontSizeRegression(page);
const callerAgentId = await createCallerAgent(daemonPort);
@@ -1265,7 +1267,12 @@ async function main() {
artifactDir,
});
const pluginLinks = await checkPluginLinks();
writeJson(path.join(artifactDir, "result.json"), { ...report, settingsMemory, pluginLinks });
writeJson(path.join(artifactDir, "result.json"), {
...report,
settingsMemory,
pluginLinks,
adaptiveTheme,
});
console.log(
`Browser desktop browser E2E passed: WebContents ${report.originalWebContentsId} remained ${report.finalWebContentsId}; viewport, inactive capture, focus continuity, list, snapshot, click, local-page selectors passed.`,
);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"files": [
"dist",
"!dist/**/*.map",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"description": "Paseo plugin SDK package",
"files": [
"dist",
@@ -77,8 +77,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/client": "0.14.0",
"@getpaseo/protocol": "0.14.0",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -88,8 +88,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/client": "0.14.0",
"@getpaseo/protocol": "0.14.0",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
+1 -1
View File
@@ -288,7 +288,7 @@ export type ToolCallDetail =
subAgentType?: string;
description?: string;
childSessionId?: string;
log: string;
log?: string;
actions?: Array<{
index: number;
toolName: string;
+3 -1
View File
@@ -638,7 +638,9 @@ const ToolCallDetailPayloadSchema: z.ZodType<ToolCallDetail, unknown> = z.discri
subAgentType: z.string().optional(),
description: z.string().optional(),
childSessionId: z.string().optional(),
log: z.string(),
// COMPAT(subagent-log): added in v0.14.1, remove after 2027-04-30. A sub-agent card is a
// launch marker; apps before v0.14.1 required `log`, so daemons still send it empty.
log: z.string().optional(),
// Compat cruft for clients <= 0.1.65-beta.3 that required this field. Producers still
// emit `[]`; nothing reads it. Drop the field (and the `[]` emissions) once those
// clients are no longer in the field.
@@ -200,8 +200,8 @@ describe("wire schema compatibility", () => {
});
});
test("sub_agent tool-call payload still parses against the v0.1.65-beta.3 schema", () => {
const parsed = LegacySubAgentToolCallSchema.parse({
test("a sub-agent launch marker still parses against the v0.1.65-beta.3 schema", () => {
const card = {
type: "tool_call",
callId: "call-sub-agent-1",
name: "Task",
@@ -212,12 +212,26 @@ describe("wire schema compatibility", () => {
subAgentType: "Explore",
description: "Inspect repository structure",
childSessionId: "child-session-1",
log: "[Read] README.md",
log: "",
actions: [],
},
});
} as const;
expect(parsed.detail.actions).toEqual([]);
const parsed = LegacySubAgentToolCallSchema.parse(JSON.parse(JSON.stringify(card)));
expect(parsed.detail).toMatchObject({ log: "", actions: [] });
const { log: _log, ...withoutLog } = card.detail;
expect(LegacySubAgentToolCallSchema.safeParse({ ...card, detail: withoutLog }).success).toBe(
false,
);
expect(AgentTimelineItemPayloadSchema.parse({ ...card, detail: withoutLog })).toMatchObject({
detail: { type: "sub_agent", description: "Inspect repository structure" },
});
expect(
AgentTimelineItemPayloadSchema.parse({
...card,
detail: { ...card.detail, log: "[Read] README.md" },
}),
).toMatchObject({ detail: { log: "[Read] README.md" } });
});
test("old clients parse agent snapshots with rewind capabilities", () => {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -89,7 +89,7 @@
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
"test": "npm run test:unit && npm run test:integration",
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
"test:integration:all": "npm run test:e2e",
"test:integration:real": "vitest run real.e2e.test.ts",
@@ -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.14.0-beta.2",
"@getpaseo/highlight": "0.14.0-beta.2",
"@getpaseo/plugin": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/relay": "0.14.0-beta.2",
"@getpaseo/client": "0.14.0",
"@getpaseo/highlight": "0.14.0",
"@getpaseo/plugin": "0.14.0",
"@getpaseo/protocol": "0.14.0",
"@getpaseo/relay": "0.14.0",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -0,0 +1,108 @@
// Opt-in diagnostic for #3905. Run from the repository root:
// node --import tsx packages/server/scripts/reproduce-codex-history-string-limit.mts 520 [legacy|paginated]
// Requires Codex 0.148+ on PATH and approximately 2 GiB of available memory.
// `paginated` migrates the synthetic rollout with `codex migrate-rollouts` first.
import { execFileSync, spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { mkdtemp, mkdir, open, rm } from "node:fs/promises";
import path from "node:path";
import pino from "pino";
import { CodexAppServerAgentSession } from "../src/server/agent/providers/codex-app-server-agent.js";
const megabytes = Number(process.argv[2] ?? "520");
if (!Number.isSafeInteger(megabytes) || megabytes < 1 || megabytes > 600) {
throw new Error("Expected an integer size between 1 and 600 MiB");
}
const historyMode = process.argv[3] ?? "legacy";
if (historyMode !== "legacy" && historyMode !== "paginated") {
throw new Error("Expected history mode legacy or paginated");
}
const root = await mkdtemp(path.join(process.cwd(), ".codex-history-repro-"));
const home = path.join(root, "codex-home");
const now = new Date();
const timestamp = now.toISOString();
const threadId = randomUUID();
const turnId = randomUUID();
const sessions = path.join(home, "sessions", ...timestamp.slice(0, 10).split("-"));
await mkdir(sessions, { recursive: true });
const rollout = await open(
path.join(sessions, `rollout-${timestamp.slice(0, 19).replaceAll(":", "-")}-${threadId}.jsonl`),
"w",
);
async function write(type: string, payload: unknown) {
await rollout.write(`${JSON.stringify({ timestamp, type, payload })}\n`);
}
await write("session_meta", {
id: threadId,
timestamp,
cwd: root,
originator: "codex_cli_rs",
cli_version: "0.159.0",
source: "cli",
model_provider: "openai",
history_mode: "legacy",
});
await write("event_msg", { type: "task_started", turn_id: turnId, model_context_window: 200000 });
await write("event_msg", {
type: "user_message",
message: "Synthetic oversized history",
images: [],
local_images: [],
text_elements: [],
});
for (let index = 0; index < megabytes; index++) {
await write("event_msg", {
type: "agent_message",
message: "x".repeat(1024 * 1024),
phase: "commentary",
});
}
await write("event_msg", { type: "task_complete", turn_id: turnId, last_agent_message: "done" });
await rollout.close();
if (historyMode === "paginated") {
execFileSync("codex", ["migrate-rollouts", "--apply", "--thread", threadId], {
env: { ...process.env, CODEX_HOME: home },
stdio: "ignore",
});
}
console.log(
`Opening synthetic ${megabytes} MiB ${historyMode} history through the real Codex app-server`,
);
const session = new CodexAppServerAgentSession(
{ provider: "codex", cwd: root },
{ sessionId: threadId },
pino({ level: "silent" }),
async () =>
spawn(
"codex",
["app-server", "--disable", "plugins", "--disable", "apps", "--disable", "remote_plugin"],
{
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env, CODEX_HOME: home },
},
),
{ codexHome: home },
false,
false,
false,
undefined,
"history",
);
try {
await session.connect();
let count = 0;
for await (const event of session.streamHistory()) {
if (event.type !== "timeline") continue;
count++;
if (event.item.type === "notification") console.log(event.item.message);
}
console.log(`History loaded: ${count} timeline rows`);
} catch (error) {
// A recoverable session error is acceptable for the crash-isolation assertion.
console.log(
`History rejected without crashing: ${error instanceof Error ? error.message : String(error)}`,
);
} finally {
await session.close();
await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
}
@@ -247,7 +247,7 @@ second line'`,
]);
});
it("keeps nested sub-agent logs beside the sub-agent that produced them", () => {
it("lists each sub-agent as its launch marker", () => {
const timeline: AgentTimelineItem[] = [
{ type: "assistant_message", text: "Before first child." },
toolCallItem({
@@ -257,7 +257,7 @@ second line'`,
type: "sub_agent",
subAgentType: "Child one",
description: "First investigation",
log: "[Assistant] First child result.",
log: "",
},
}),
{ type: "assistant_message", text: "Between children." },
@@ -268,7 +268,7 @@ second line'`,
type: "sub_agent",
subAgentType: "Child two",
description: "Second investigation",
log: "[Assistant] Second child result.",
log: "",
},
}),
{ type: "assistant_message", text: "After second child." },
@@ -279,10 +279,8 @@ second line'`,
expect(result.split("\n")).toEqual([
"[Assistant] Before first child.",
"[Child one] First investigation",
"[Assistant] First child result.",
"[Assistant] Between children.",
"[Child two] Second investigation",
"[Assistant] Second child result.",
"[Assistant] After second child.",
]);
});
@@ -164,9 +164,6 @@ function curateProjectedActivityEntries(
break;
}
entries.push(formatToolCallEntry(item, options));
if (item.detail.type === "sub_agent" && item.detail.log.trim()) {
entries.push(activityEntry(item.detail.log.trim()));
}
break;
}
case "todo":
@@ -8059,6 +8059,60 @@ test("global subscribers receive internal agent events only when they opt in", a
unsubscribeOptedIn();
});
test("agent removal notifications respect scoped subscriptions and internal visibility", () => {
const manager = new AgentManager({ clients: {}, logger });
const removedId = "00000000-0000-4000-8000-000000000901";
const internalId = "00000000-0000-4000-8000-000000000902";
const otherId = "00000000-0000-4000-8000-000000000903";
const publicEvents: AgentManagerEvent[] = [];
const allEvents: AgentManagerEvent[] = [];
const scopedEvents: AgentManagerEvent[] = [];
const internalEvents: AgentManagerEvent[] = [];
const unrelatedEvents: AgentManagerEvent[] = [];
const releases = [
manager.subscribe((event) => publicEvents.push(event), { replayState: false }),
manager.subscribe((event) => allEvents.push(event), {
replayState: false,
includeInternal: true,
}),
manager.subscribe((event) => scopedEvents.push(event), {
replayState: false,
agentId: removedId,
}),
manager.subscribe((event) => internalEvents.push(event), {
replayState: false,
agentId: internalId,
}),
manager.subscribe((event) => unrelatedEvents.push(event), {
replayState: false,
agentId: otherId,
}),
];
try {
manager.notifyAgentRemoved({ id: removedId, workspaceId: "workspace" });
manager.notifyAgentRemoved({ id: internalId, workspaceId: "workspace", internal: true });
const publicRemoval = {
type: "agent_removed",
agentId: removedId,
workspaceId: "workspace",
internal: false,
};
const internalRemoval = {
type: "agent_removed",
agentId: internalId,
workspaceId: "workspace",
internal: true,
};
expect(publicEvents).toEqual([publicRemoval]);
expect(allEvents).toEqual([publicRemoval, internalRemoval]);
expect(scopedEvents).toEqual([publicRemoval]);
expect(internalEvents).toEqual([internalRemoval]);
expect(unrelatedEvents).toEqual([]);
} finally {
for (const release of releases) release();
}
});
test("getAgent returns internal agents by ID", async () => {
const internalAgentId = "00000000-0000-4000-8000-000000000107";
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
@@ -236,6 +236,7 @@ export type {
export type AgentManagerEvent =
| { type: "agent_state"; agent: ManagedAgent }
| { type: "agent_removed"; agentId: string; workspaceId?: string; internal: boolean }
| { type: "provider_subagent"; event: ProviderSubagentStoreEvent }
| { type: "timeline_replacement"; agentId: string; epoch: string }
| {
@@ -3519,6 +3520,15 @@ export class AgentManager {
await this.deleteCommittedTimeline(agentId);
}
notifyAgentRemoved(agent: Pick<StoredAgentRecord, "id" | "workspaceId" | "internal">): void {
this.dispatch({
type: "agent_removed",
agentId: agent.id,
workspaceId: agent.workspaceId,
internal: agent.internal === true,
});
}
async deleteCommittedTimeline(agentId: string): Promise<void> {
await this.durableTimelineStore?.deleteAgent(agentId);
}
@@ -5376,7 +5386,7 @@ export class AgentManager {
for (const subscriber of this.subscribers) {
if (
subscriber.agentId &&
event.type === "agent_stream" &&
(event.type === "agent_stream" || event.type === "agent_removed") &&
subscriber.agentId !== event.agentId
) {
continue;
@@ -5412,6 +5422,7 @@ export class AgentManager {
private eventBelongsToInternalAgent(event: AgentManagerEvent): boolean {
if (event.type === "agent_state") return event.agent.internal === true;
if (event.type === "agent_removed") return event.internal;
if (event.type === "agent_stream") return this.agents.get(event.agentId)?.internal === true;
if (event.type !== "provider_subagent") return false;
const parentAgentId =
@@ -313,7 +313,10 @@ export type ToolCallDetail =
subAgentType?: string;
description?: string;
childSessionId?: string;
log: string;
// COMPAT(subagent-log): added in v0.14.1, remove after 2027-04-30. Apps before v0.14.1
// require `log`; every producer sends it empty and carries the same tag. Optional here because
// protocol timeline items, where `log` is optional, flow into this type.
log?: string;
actions?: Array<{
index: number;
toolName: string;
@@ -1,4 +1,6 @@
import { measureMainThreadWork } from "../../test-utils/main-thread-work.js";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { selectReferenceProjectedTimelinePage } from "./test-utils/timeline-page-reference.js";
import type { AgentTimelineItem } from "./agent-sdk-types.js";
@@ -251,23 +253,52 @@ it("includes transitive tool updates in a contiguous projected tail", () => {
expect(tail).toMatchObject({ startSeq: 1, endSeq: 6, hasOlder: false });
});
async function measureTimelineTail(wholeTurns: boolean) {
const fixture = fileURLToPath(
new URL("./test-utils/timeline-responsiveness.ts", import.meta.url),
);
const { stdout, stderr } = await promisify(execFile)(
process.execPath,
["--expose-gc", "--import", "tsx", fixture, String(wholeTurns)],
{ timeout: 15_000 },
);
process.stderr.write(stderr);
return JSON.parse(stdout) as {
result: ReturnType<InMemoryAgentTimelineStore["fetch"]>;
blockingMs: number;
};
}
it("excludes unrelated test-runner CPU from the timeline fetch budget", async () => {
let unrelatedWork: ReturnType<typeof setImmediate>;
const burnCpu = () => {
const threadCpuUsage: (previous?: NodeJS.CpuUsage) => NodeJS.CpuUsage = Reflect.get(
process,
"threadCpuUsage",
);
const start = threadCpuUsage();
let elapsed: NodeJS.CpuUsage;
do {
elapsed = threadCpuUsage(start);
} while (elapsed.user + elapsed.system < 80_000);
unrelatedWork = setImmediate(burnCpu);
};
unrelatedWork = setImmediate(burnCpu);
try {
const { result: tail, blockingMs } = await measureTimelineTail(false);
expect(tail.rows).toHaveLength(200);
expect(tail.startSeq).toBe(99_801);
expect(tail.endSeq).toBe(100_000);
expect(blockingMs).toBeLessThan(50);
} finally {
clearImmediate(unrelatedWork);
}
});
it.each([false, true])(
"serves tail 200 from 100,000 retained rows without stalling the loop (wholeTurns=%s)",
async (wholeTurns) => {
const store = new InMemoryAgentTimelineStore();
store.initialize("large");
for (let index = 0; index < 100_000; index++) {
store.append("large", { type: "user_message", text: String(index) });
}
const { result: tail, blockingMs } = await measureMainThreadWork(
`timeline tail main-thread work (wholeTurns=${wholeTurns})`,
() => {
let page = store.fetch("large", { limit: 200, wholeTurns });
for (let repeat = 1; repeat < 8; repeat++)
page = store.fetch("large", { limit: 200, wholeTurns });
return page;
},
);
const { result: tail, blockingMs } = await measureTimelineTail(wholeTurns);
expect(tail.rows).toHaveLength(200);
expect(tail.startSeq).toBe(99_801);
expect(tail.endSeq).toBe(100_000);
@@ -466,13 +466,13 @@ describe("background Claude subagents", () => {
type: "sub_agent",
subAgentType: "general-purpose",
description: "Count files here",
log: "[Bash] ls -1",
log: "",
},
});
});
test.each(["before", "after"])(
"keeps one logged Task card when child tools start %s the parent finishes",
"keeps one Task card when child tools start %s the parent finishes",
async (timing) => {
// 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.
@@ -605,7 +605,7 @@ describe("background Claude subagents", () => {
type: "sub_agent",
subAgentType: "general-purpose",
description: "Count files here",
log: "[Bash] ls -1",
log: "",
},
});
},
@@ -273,7 +273,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
queryFactory.mockReset();
});
test("accumulates lightweight sub_agent detail and preserves callId lifecycle collapse", async () => {
test("labels the Task card from its input and preserves callId lifecycle collapse", async () => {
const session = await new ClaudeAgentClient({
logger,
queryFactory,
@@ -307,9 +307,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
expect(latest.detail.subAgentType).toBe("Explore");
expect(latest.detail.description).toBe("Inspect repository structure");
expect(latest.detail.log).toContain("[Read] README.md");
expect(latest.detail.log).toContain("[Edit] src/index.ts");
expect(latest.detail.log).not.toContain("VERY_LARGE_OLD_STRING");
expect(latest.detail.log).toBe("");
const rows: AgentTimelineRow[] = timelineToolCalls.map((item, index) => ({
seq: index + 1,
@@ -396,10 +394,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
.map((event) => event.item)
.at(-1);
expect(latestSubAgentUpdate?.detail).toMatchObject({
type: "sub_agent",
log: expect.stringContaining("[Read] README.md"),
});
expect(latestSubAgentUpdate?.detail).toMatchObject({ type: "sub_agent", log: "" });
});
test("routes a resumed SendMessage sidechain to the original child without a second Task card", async () => {
@@ -553,7 +548,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
).toMatchObject({ type: "upsert", id: "task-tail-1", status: "failed" });
});
test("tails sub-agent actions instead of dropping latest entries at cap", async () => {
test("keeps the Task card the same size while the child runs", async () => {
queryFactory.mockImplementation(() => buildQueryMock(buildTailScenarioEvents(205)));
const session = await new ClaudeAgentClient({
@@ -568,24 +563,27 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
const events = await collectUntilTerminal(streamSession(session, "delegate work"));
await session.close();
const timelineToolCalls = events
.filter(
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
event.type === "timeline" && event.item.type === "tool_call",
const runningCards = events
.flatMap((event) =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.callId === "task-tail-1" &&
event.item.status === "running" &&
event.item.detail.type === "sub_agent"
? [event.item]
: [],
)
.map((event) => event.item)
.filter((item) => item.callId === "task-tail-1");
const subAgentUpdates = timelineToolCalls.filter((item) => item.detail.type === "sub_agent");
const latest = subAgentUpdates[subAgentUpdates.length - 1];
expect(latest).toBeDefined();
if (!latest || latest.detail.type !== "sub_agent") {
throw new Error("expected sub_agent detail");
}
.map((item) => JSON.stringify(item));
expect(runningCards).toHaveLength(1);
expect(latest.detail.log).not.toContain("[Read] file-1.md");
expect(latest.detail.log).not.toContain("[Read] file-5.md");
expect(latest.detail.log).toContain("[Read] file-6.md");
expect(latest.detail.log).toContain("[Read] file-205.md");
const childToolCalls = events.filter(
(event) =>
event.type === "provider_subagent" &&
event.event.type === "timeline" &&
event.event.id === "task-tail-1" &&
event.event.item.type === "tool_call",
);
expect(childToolCalls).toHaveLength(205);
});
test("keeps a long main-thread tool call out of the subagents track", async () => {
@@ -4667,7 +4667,7 @@ class ClaudeAgentSession implements AgentSession {
/**
* The Task card in the parent transcript, built from the declaration.
*
* The sidechain tracker also emits this card, enriched with the child's action log, and the two
* The sidechain tracker also emits this card when it first sees the child, and the two
* collapse on the shared tool-call id. Emitting it at declaration matters because a
* backgrounded subagent produces no sidechain frames at all — verified on the wire — so the
* tracker never runs for one and its card would otherwise stay an unlabeled "Task".
@@ -6276,6 +6276,7 @@ function buildClaudeSubagentCardDetail(
type: "sub_agent",
...(facts.title ? { subAgentType: facts.title } : {}),
...(facts.description ? { description: facts.description } : {}),
// COMPAT(subagent-log): remove after 2027-04-30.
log: "",
actions: [],
};
@@ -4,7 +4,7 @@ import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
describe("ClaudeSidechainTracker", () => {
it("retains a background child's actions across the parent finishing", () => {
it("keeps a background child's card across the parent finishing", () => {
const tracker = new ClaudeSidechainTracker({
getToolInput: () => ({ subagent_type: "general-purpose", description: "List files" }),
isDescriptorOwnedElsewhere: () => true,
@@ -15,27 +15,67 @@ describe("ClaudeSidechainTracker", () => {
parent_tool_use_id: "task-1",
message: { content: [{ type: "tool_use", id, name: "Bash", input: { command } }] },
}) as unknown as SDKMessage;
tracker.handleMessage(action("ls", "ls -1"), "task-1");
const parentCards = (events: ReturnType<typeof tracker.handleMessage>) =>
events.filter((event) => event.type === "timeline");
const childCallIds = (events: ReturnType<typeof tracker.handleMessage>) =>
events.flatMap((event) =>
event.type === "provider_subagent" &&
event.event.type === "timeline" &&
event.event.item.type === "tool_call"
? [event.event.item.callId]
: [],
);
expect(parentCards(tracker.handleMessage(action("ls", "ls -1"), "task-1"))).toHaveLength(1);
expect(tracker.finishAll("completed")).toEqual([]);
const events = tracker.handleMessage(action("pwd", "pwd"), "task-1");
expect(events).toContainEqual(
expect.objectContaining({
type: "timeline",
item: expect.objectContaining({
detail: expect.objectContaining({ log: "[Bash] ls -1\n[Bash] pwd" }),
}),
}),
);
const afterFinish = tracker.handleMessage(action("pwd", "pwd"), "task-1");
expect(parentCards(afterFinish)).toEqual([]);
expect(childCallIds(afterFinish)).toEqual(["pwd"]);
tracker.delete("task-1");
const next = tracker.handleMessage(action("next", "ls"), "task-1");
expect(next).toContainEqual(
expect.objectContaining({
type: "timeline",
item: expect.objectContaining({
detail: expect.objectContaining({ log: "[Bash] ls" }),
}),
}),
);
expect(parentCards(next)).toHaveLength(1);
expect(childCallIds(next)).toEqual(["next"]);
});
it("shows a streamed child tool call's input once the assistant message brings it", () => {
const tracker = new ClaudeSidechainTracker({ getToolInput: () => null });
const frames = [
{
type: "stream_event",
parent_tool_use_id: "task-1",
event: {
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "bash-1", name: "Bash", input: {} },
},
},
{
type: "assistant",
parent_tool_use_id: "task-1",
message: {
content: [
{ type: "tool_use", id: "bash-1", name: "Bash", input: { command: "echo child" } },
],
},
},
] as unknown as SDKMessage[];
const childCards = frames
.flatMap((frame) => tracker.handleMessage(frame, "task-1"))
.flatMap((event) =>
event.type === "provider_subagent" &&
event.event.type === "timeline" &&
event.event.item.type === "tool_call"
? [event.event.item]
: [],
);
expect(childCards.at(-1)).toMatchObject({
callId: "bash-1",
status: "running",
detail: { type: "shell", command: "echo child" },
});
});
it("uses Claude's native agent name for the provider subagent title", () => {
@@ -5,7 +5,6 @@ import {
mapClaudeFailedToolCall,
mapClaudeRunningToolCall,
} from "./tool-call-mapper.js";
import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display";
import {
isClaudeSubagentHandbackToolName,
readClaudeSubagentHandback,
@@ -20,21 +19,17 @@ interface ClaudeContentChunk {
type ClaudeToolCallItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
interface SubAgentActionEntry {
index: number;
interface SubAgentChildCall {
toolName: string;
input: unknown;
summary?: string;
}
interface SubAgentActivityState {
name?: string;
subAgentType?: string;
description?: string;
actions: SubAgentActionEntry[];
actionKeys: string[];
nextActionIndex: number;
actionIndexByKey: Map<string, number>;
/** The child's tool calls awaiting results, which carry no input of their own. */
runningCalls: Map<string, SubAgentChildCall>;
completedActionKeys: Set<string>;
}
@@ -44,7 +39,6 @@ interface SubAgentActionCandidate {
input: unknown;
}
const MAX_SUB_AGENT_LOG_ENTRIES = 200;
const MAX_SUB_AGENT_SUMMARY_CHARS = 160;
function readTrimmedString(value: unknown): string | undefined {
@@ -79,8 +73,7 @@ export class ClaudeSidechainTracker {
needsSyntheticParentToolCard?: (toolUseId: string) => boolean;
/**
* The parent's tool call once Claude has answered it. A background child streams its frames
* after that answer, so its card keeps the settled status and output and gains the child's
* action log.
* after that answer, so its card keeps the settled status and output.
*/
getSettledParentToolCall?: (toolUseId: string) => ClaudeToolCallItem | undefined;
}) {
@@ -91,28 +84,27 @@ export class ClaudeSidechainTracker {
}
handleMessage(message: SDKMessage, parentToolUseId: string): AgentStreamEvent[] {
const existingState = this.activeSidechains.get(parentToolUseId);
const state =
this.activeSidechains.get(parentToolUseId) ??
existingState ??
({
actions: [],
actionKeys: [],
nextActionIndex: 1,
actionIndexByKey: new Map<string, number>(),
runningCalls: new Map<string, SubAgentChildCall>(),
completedActionKeys: new Set<string>(),
} satisfies SubAgentActivityState);
this.activeSidechains.set(parentToolUseId, state);
const contextUpdated = this.updateSubAgentContextFromTaskInput(state, parentToolUseId);
// The parent card is a launch marker: it changes only when the child is first seen or the
// task input names it differently, never with the child's activity.
const cardUpdated =
this.updateSubAgentContextFromTaskInput(state, parentToolUseId) || !existingState;
const actionCandidates = this.extractSubAgentActionCandidates(message);
const childTimelineItems = [
...this.extractSubAgentTimelineItems(message, state),
...this.extractSubAgentToolResults(message, state),
];
let actionUpdated = false;
for (const action of actionCandidates) {
if (state.completedActionKeys.has(action.key)) continue;
if (this.appendSubAgentAction(state, action)) {
actionUpdated = true;
if (this.trackChildCall(state, action)) {
const toolCall = mapClaudeRunningToolCall({
name: action.toolName,
callId: action.key,
@@ -125,7 +117,7 @@ export class ClaudeSidechainTracker {
}
}
if (!contextUpdated && !actionUpdated && childTimelineItems.length === 0) {
if (!cardUpdated && childTimelineItems.length === 0) {
return [];
}
@@ -145,11 +137,8 @@ export class ClaudeSidechainTracker {
type: "sub_agent",
...(state.subAgentType ? { subAgentType: state.subAgentType } : {}),
...(state.description ? { description: state.description } : {}),
log: state.actions
.map((action) =>
action.summary ? `[${action.toolName}] ${action.summary}` : `[${action.toolName}]`,
)
.join("\n"),
// COMPAT(subagent-log): remove after 2027-04-30.
log: "",
actions: [],
};
@@ -170,18 +159,19 @@ export class ClaudeSidechainTracker {
},
];
const parentCard: AgentStreamEvent[] = this.needsSyntheticParentToolCard(parentToolUseId)
? [
{
type: "timeline",
item: {
...toolCall,
detail,
const parentCard: AgentStreamEvent[] =
cardUpdated && this.needsSyntheticParentToolCard(parentToolUseId)
? [
{
type: "timeline",
item: {
...toolCall,
detail,
},
provider: "claude",
},
provider: "claude",
},
]
: [];
]
: [];
return [
...descriptorEvents,
@@ -303,8 +293,7 @@ export class ClaudeSidechainTracker {
if (!isClaudeContentChunk(block) || !block.type.endsWith("tool_result")) continue;
const callId = readTrimmedString(block.tool_use_id);
if (!callId || state.completedActionKeys.has(callId)) continue;
const actionIndex = state.actionIndexByKey.get(callId);
const action = actionIndex === undefined ? undefined : state.actions[actionIndex];
const action = state.runningCalls.get(callId);
const toolName = action?.toolName ?? readTrimmedString(block.tool_name);
if (!toolName) continue;
const params = {
@@ -317,6 +306,7 @@ export class ClaudeSidechainTracker {
? mapClaudeFailedToolCall({ ...params, error: block })
: mapClaudeCompletedToolCall(params);
if (toolCall) {
state.runningCalls.delete(callId);
state.completedActionKeys.add(callId);
items.push(toolCall);
}
@@ -412,13 +402,12 @@ export class ClaudeSidechainTracker {
const key =
readTrimmedString(block.id) ??
`stream:${block.name}:${typeof event.index === "number" ? event.index : 0}`;
return [
{
key,
toolName: block.name,
input: block.input ?? null,
},
];
// A streamed call starts with `{}` until its input arrives, so an empty input is not final.
const input =
block.input && typeof block.input === "object" && Object.keys(block.input).length > 0
? block.input
: null;
return [{ key, toolName: block.name, input }];
}
private extractSubAgentActionCandidates(message: SDKMessage): SubAgentActionCandidate[] {
@@ -448,84 +437,20 @@ export class ClaudeSidechainTracker {
return [];
}
private appendSubAgentAction(
/** Returns whether the call is new or has gained its input, so its running card changes. */
private trackChildCall(
state: SubAgentActivityState,
candidate: SubAgentActionCandidate,
): boolean {
const normalizedToolName = readTrimmedString(candidate.toolName);
if (!normalizedToolName) {
const toolName = readTrimmedString(candidate.toolName);
if (!toolName) {
return false;
}
const summary = this.deriveSubAgentActionSummary(normalizedToolName, candidate.input);
const existingIndex = state.actionIndexByKey.get(candidate.key);
if (existingIndex !== undefined) {
const existing = state.actions[existingIndex];
if (!existing) {
return false;
}
const nextSummary = existing.summary ?? summary;
if (existing.toolName === normalizedToolName && existing.summary === nextSummary) {
return false;
}
state.actions[existingIndex] = {
...existing,
toolName: normalizedToolName,
input: existing.input ?? candidate.input,
...(nextSummary ? { summary: nextSummary } : {}),
};
return true;
const existing = state.runningCalls.get(candidate.key);
if (existing && (existing.input !== null || candidate.input === null)) {
return false;
}
state.actions.push({
index: state.nextActionIndex,
toolName: normalizedToolName,
input: candidate.input,
...(summary ? { summary } : {}),
});
state.nextActionIndex += 1;
state.actionKeys.push(candidate.key);
this.trimSubAgentTail(state);
this.rebuildSubAgentActionIndex(state);
state.runningCalls.set(candidate.key, { toolName, input: candidate.input });
return true;
}
private trimSubAgentTail(state: SubAgentActivityState): void {
while (state.actions.length > MAX_SUB_AGENT_LOG_ENTRIES) {
state.actions.shift();
const removedKey = state.actionKeys.shift();
if (removedKey) state.completedActionKeys.delete(removedKey);
}
}
private rebuildSubAgentActionIndex(state: SubAgentActivityState): void {
state.actionIndexByKey.clear();
for (let index = 0; index < state.actionKeys.length; index += 1) {
const key = state.actionKeys[index];
if (key) {
state.actionIndexByKey.set(key, index);
}
}
}
private deriveSubAgentActionSummary(toolName: string, input: unknown): string | undefined {
const runningToolCall = mapClaudeRunningToolCall({
name: toolName,
callId: `sub-agent-summary-${toolName}`,
input,
output: null,
});
if (!runningToolCall) {
return undefined;
}
const display = buildToolCallDisplayModel({
name: runningToolCall.name,
status: runningToolCall.status,
error: runningToolCall.error,
detail: runningToolCall.detail,
metadata: runningToolCall.metadata,
});
return this.normalizeSubAgentText(display.summary);
}
}
@@ -77,12 +77,19 @@ CHILD_SENTINEL in your own response.
const completedChildIndex = events.findIndex(
(event) =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.status === "completed" &&
event.item.detail.type === "sub_agent" &&
event.item.detail.log.includes("CHILD_SENTINEL"),
event.type === "provider_subagent" &&
event.event.type === "upsert" &&
event.event.status === "completed",
);
const childAssistantText = events
.flatMap((event) =>
event.type === "provider_subagent" &&
event.event.type === "timeline" &&
event.event.item.type === "assistant_message"
? [event.event.item.text]
: [],
)
.join("");
const rootMessageIndexes = events.flatMap((event, index) =>
event.type === "timeline" && event.item.type === "assistant_message" ? [index] : [],
);
@@ -105,6 +112,7 @@ CHILD_SENTINEL in your own response.
.map((item) => item.text)
.join("");
expect(topLevelAssistantText).not.toContain("CHILD_SENTINEL");
expect(childAssistantText).toContain("CHILD_SENTINEL");
expect(completedChildIndex).toBeGreaterThanOrEqual(0);
expect(rootMessageIndexes[0]).toBeGreaterThan(completedChildIndex);
expect(terminalIndexes).toHaveLength(1);
@@ -88,6 +88,7 @@ describe("Codex executable discovery", () => {
import { CodexAppServerClient } from "./codex/app-server-transport.js";
import {
createFakeCodexAppServer,
fakeCodexThreadHistory,
type FakeCodexAppServer,
waitForNextPermission,
waitForNextTimelineItem,
@@ -1991,7 +1992,7 @@ describe("Codex app-server provider", () => {
purpose: "history",
});
expect(threadRequests).toEqual(["thread/read"]);
expect(threadRequests).toEqual(["thread/read", "thread/read"]);
await session.close();
appServer.assertNoErrors();
});
@@ -2068,6 +2069,7 @@ describe("Codex app-server provider", () => {
"thread/loaded/list",
"thread/resume",
"thread/read",
"thread/read",
]);
expect(codex.threadMethods(1)).toEqual(["thread/loaded/list", "thread/resume"]);
expect(codex.manager.getTimeline(codex.agentId)).toEqual(projected);
@@ -2427,7 +2429,7 @@ describe("Codex app-server provider", () => {
cwd: "/workspace/project",
model: "gpt-5.4",
serviceTier: null,
excludeTurns: false,
excludeTurns: true,
persistExtendedHistory: true,
},
]);
@@ -2441,8 +2443,9 @@ describe("Codex app-server provider", () => {
test("rewinds a paginated conversation through the public session capability", async () => {
const appServer = createFakeCodexAppServer({
"thread/read": () => ({
thread: { id: "thread-1", historyMode: "paginated", turns: [] },
...fakeCodexThreadHistory({
"thread-1": { historyMode: "paginated", turns: [] },
"forked-thread": { historyMode: "paginated", turns: [] },
}),
"thread/rollback": () => {
throw new Error("paginated threads do not support thread/rollback");
@@ -2487,7 +2490,7 @@ describe("Codex app-server provider", () => {
cwd: "/workspace/project",
model: "gpt-5.4",
serviceTier: null,
excludeTurns: false,
excludeTurns: true,
persistExtendedHistory: true,
},
]);
@@ -2504,11 +2507,10 @@ describe("Codex app-server provider", () => {
async (historyMode) => {
const appServer = createFakeCodexAppServer(
historyMode === "paginated"
? {
"thread/read": () => ({
thread: { id: "thread-1", historyMode: "paginated", turns: [] },
}),
}
? fakeCodexThreadHistory({
"thread-1": { historyMode: "paginated", turns: [] },
"forked-thread": { historyMode: "paginated", turns: [] },
})
: undefined,
);
const customCodexConfig = {
@@ -3522,7 +3524,7 @@ describe("Codex app-server provider", () => {
]);
});
test("folds child-thread Codex activity into the parent sub-agent tool call", async () => {
test("keeps child-thread Codex activity out of the parent sub-agent card", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
@@ -3560,7 +3562,6 @@ describe("Codex app-server provider", () => {
});
const timelineEvents = events.filter((event) => event.type === "timeline");
expect(timelineEvents).toHaveLength(4);
expect(timelineEvents.every((event) => event.item.type === "tool_call")).toBe(true);
const finalItem = timelineEvents.at(-1)?.item;
expect(finalItem).toMatchObject({
@@ -3572,7 +3573,7 @@ describe("Codex app-server provider", () => {
type: "sub_agent",
subAgentType: "Sub-agent",
description: "Report findings.",
log: "[Assistant] Found the path.",
log: "",
actions: [],
},
});
@@ -3649,13 +3650,9 @@ describe("Codex app-server provider", () => {
id: "settled-child-thread",
status: "completed",
});
expect(events.at(-1)).toMatchObject({
expect(events.findLast((event) => event.type === "timeline")).toMatchObject({
type: "timeline",
item: {
callId: "call-settled-child",
status: "completed",
detail: { type: "sub_agent", log: "[Assistant] Late trailing output." },
},
item: { callId: "call-settled-child", status: "completed" },
});
appServer.completesSubAgentActivity({
@@ -3960,6 +3957,8 @@ describe("Codex app-server provider", () => {
createTestLogger(),
async () => appServer.child,
);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
try {
const resultPromise = session.run("Delegate the investigation, then report the result.");
@@ -4003,11 +4002,18 @@ describe("Codex app-server provider", () => {
type: "tool_call",
callId: "spawn-child-early",
status: "completed",
detail: {
type: "sub_agent",
log: "[Assistant] Early child findings.",
},
detail: { type: "sub_agent", log: "" },
});
expect(events).toContainEqual(
expect.objectContaining({
type: "provider_subagent",
event: expect.objectContaining({
type: "timeline",
id: "child-thread-early",
item: expect.objectContaining({ type: "assistant_message" }),
}),
}),
);
appServer.assertNoErrors();
} finally {
await session.close();
@@ -4078,10 +4084,7 @@ describe("Codex app-server provider", () => {
expect(toolCalls.at(-1)).toMatchObject({
callId: "spawn-child-stable",
status: "canceled",
detail: {
type: "sub_agent",
log: "[Assistant] Still on the same card.",
},
detail: { type: "sub_agent", log: "" },
});
appServer.assertNoErrors();
} finally {
@@ -4161,11 +4164,10 @@ describe("Codex app-server provider", () => {
const result = await resultPromise;
const toolCalls = result.timeline.filter((item) => item.type === "tool_call");
expect(toolCalls.map((item) => item.status)).toEqual(["running", "completed", "completed"]);
expect(toolCalls.map((item) => item.status)).toEqual(["running", "completed"]);
expect(toolCalls.at(-1)).toMatchObject({
callId: "spawn-child-late-compaction",
status: "completed",
detail: { type: "sub_agent", log: "[Compacted]" },
});
appServer.assertNoErrors();
} finally {
@@ -4173,7 +4175,7 @@ describe("Codex app-server provider", () => {
}
});
test("projects legacy child tools into one stable sub-agent log", async () => {
test("routes legacy child tools to the child timeline", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
@@ -4181,6 +4183,8 @@ describe("Codex app-server provider", () => {
createTestLogger(),
async () => appServer.child,
);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
try {
const resultPromise = session.run("Delegate the implementation.");
@@ -4223,19 +4227,26 @@ describe("Codex app-server provider", () => {
expect(finalToolCall).toMatchObject({
callId: "spawn-legacy-tool-child",
status: "completed",
detail: { type: "sub_agent" },
detail: { type: "sub_agent", log: "" },
});
if (finalToolCall?.detail.type === "sub_agent") {
expect(finalToolCall.detail.log.match(/\[Shell\]/g)).toHaveLength(1);
expect(finalToolCall.detail.log).toContain("[Edit]");
}
const childToolCalls = events.flatMap((event) =>
event.type === "provider_subagent" &&
event.event.type === "timeline" &&
event.event.id === "legacy-tool-child" &&
event.event.item.type === "tool_call"
? [event.event.item]
: [],
);
expect(new Set(childToolCalls.map((item) => item.detail.type))).toEqual(
new Set(["shell", "edit"]),
);
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("keeps nested MultiAgentV2 output inside the root sub-agent card", async () => {
test("keeps nested MultiAgentV2 output out of the root sub-agent card", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
@@ -4295,8 +4306,22 @@ describe("Codex app-server provider", () => {
expect(beforeParentCompletes.at(-1)).toMatchObject({
callId: "spawn-child-root",
status: "running",
detail: { type: "sub_agent", log: expect.stringContaining("Grandchild findings.") },
detail: { type: "sub_agent", log: "" },
});
expect(events).toContainEqual(
expect.objectContaining({
type: "provider_subagent",
event: {
type: "timeline",
id: "grandchild-thread",
item: {
type: "assistant_message",
messageId: "grandchild-message",
text: "Grandchild findings.",
},
},
}),
);
await asInternals(session).handleNotification("turn/completed", {
threadId: "child-thread-root",
@@ -4363,13 +4388,20 @@ describe("Codex app-server provider", () => {
"child-thread-root",
"child-thread-root",
]);
expect(events.at(-1)).toMatchObject({
type: "timeline",
item: {
callId: "spawn-child-root",
detail: { type: "sub_agent", log: expect.stringContaining("Still nested.") },
},
});
expect(events).toContainEqual(
expect.objectContaining({
type: "provider_subagent",
event: {
type: "timeline",
id: "grandchild-thread",
item: {
type: "assistant_message",
messageId: "grandchild-after-wait",
text: "Still nested.",
},
},
}),
);
});
test("never treats an unmapped foreign terminal as the root terminal", async () => {
@@ -4789,7 +4821,7 @@ describe("Codex app-server provider", () => {
expect(events).toHaveLength(eventCountAfterSpawn);
});
test("keeps child context compaction inside the child card", async () => {
test("keeps child context compaction on the child timeline", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
@@ -4822,10 +4854,70 @@ describe("Codex app-server provider", () => {
(item) => item.type === "tool_call" && item.callId === "spawn-child-compaction",
),
).toBe(true);
expect(timelineItems.at(-1)).toMatchObject({
type: "tool_call",
detail: { type: "sub_agent", log: "[Compacted]" },
expect(
events.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "timeline" ? [event.event] : [],
),
).toEqual([
{
type: "timeline",
id: "child-thread-compaction",
item: { type: "compaction", status: "loading" },
},
{
type: "timeline",
id: "child-thread-compaction",
item: { type: "compaction", status: "completed" },
},
]);
});
test("settles a child compaction once when the item completion is missing or duplicated", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
for (const child of ["compacted-child", "turn-ended-child"]) {
await asInternals(session).handleNotification("item/completed", {
threadId: "test-thread",
item: {
type: "subAgentActivity",
id: `spawn-${child}`,
kind: "started",
agentThreadId: child,
agentPath: `/root/${child}`,
},
});
await asInternals(session).handleNotification("item/started", {
threadId: child,
item: { type: "contextCompaction", id: `${child}-compaction` },
});
}
await asInternals(session).handleNotification("thread/compacted", {
threadId: "compacted-child",
});
await asInternals(session).handleNotification("item/completed", {
threadId: "compacted-child",
item: { type: "contextCompaction", id: "compacted-child-compaction" },
});
await asInternals(session).handleNotification("turn/completed", {
threadId: "turn-ended-child",
turn: { status: "completed" },
});
const compactions = events.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "timeline"
? [
`${event.event.id} ${event.event.item.type === "compaction" ? event.event.item.status : ""}`,
]
: [],
);
expect(compactions).toEqual([
"compacted-child loading",
"turn-ended-child loading",
"compacted-child completed",
"turn-ended-child completed",
]);
});
test("keeps the parent sub-agent running when a child command fails during the child turn", async () => {
@@ -4860,7 +4952,7 @@ describe("Codex app-server provider", () => {
},
});
expect(events.at(-1)?.item).toMatchObject({
expect(events.findLast((event) => event.type === "timeline")?.item).toMatchObject({
type: "tool_call",
callId: "call-sub-agent-child-command-failure",
name: "Sub-agent",
@@ -4947,6 +5039,7 @@ describe("Codex app-server provider", () => {
}
expect(requests.map((request) => [request.method, request.params])).toEqual([
["thread/read", { threadId: "test-thread", includeTurns: false }],
["thread/read", { threadId: "test-thread", includeTurns: true }],
]);
expect(history).toEqual([
@@ -5154,7 +5247,7 @@ describe("Codex app-server provider", () => {
);
expect(liveToolCalls.at(-1)).toMatchObject({
status: "running",
detail: { type: "sub_agent", log: "[Assistant] More findings after resume." },
detail: { type: "sub_agent", log: "" },
});
liveEvents.length = 0;
@@ -5167,12 +5260,12 @@ describe("Codex app-server provider", () => {
itemId: "legacy-child-message-after-resume",
delta: "Legacy findings after resume.",
});
expect(liveEvents.at(-1)).toMatchObject({
expect(liveEvents.findLast((event) => event.type === "timeline")).toMatchObject({
type: "timeline",
item: {
callId: "legacy-spawn-history",
status: "running",
detail: { type: "sub_agent", log: "[Assistant] Legacy findings after resume." },
detail: { type: "sub_agent", log: "" },
},
});
});
@@ -49,10 +49,10 @@ import { Dirent } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { isDeepStrictEqual } from "node:util";
import { z } from "zod";
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
import { composeSystemPromptParts } from "../system-prompt.js";
import { curateAgentActivity } from "../activity-curator.js";
import { CodexAsyncQuestions, codexAsyncQuestionToTimeline } from "./codex/async-questions.js";
import {
mapCodexToolCallEnvelope,
@@ -92,12 +92,18 @@ import {
type CodexAppServerTraceContext,
} from "./codex/app-server-transport.js";
import { type CodexUserMessageTurnIndex, revertCodexConversation } from "./codex/rewind.js";
import {
type CodexHistoryRow,
CodexThreadHistoryTooLargeError,
readCodexThreadHistory,
} from "./codex/thread-history.js";
import {
materializeProviderImage,
renderProviderImageOutputAsAssistantMarkdown,
type ProviderImageOutput,
} from "./provider-image-output.js";
import { normalizeProviderReplayTimestamp } from "../provider-history-timestamps.js";
import { limitAgentTimelineItemContent } from "../agent-timeline-content.js";
import {
formatProviderDiagnostic,
formatProviderDiagnosticError,
@@ -1804,22 +1810,13 @@ function readCodexHistoryTimestamp(item: unknown): string | null {
);
}
function readCodexTurnHistoryTimestamp(
turn: unknown,
/** Falls back to the row's start time for a user message and its end time otherwise. */
function readCodexHistoryRowTimestamp(
row: CodexHistoryRow,
timelineItem: AgentTimelineItem,
): string | null {
const record = toObjectRecord(turn);
if (!record) {
return null;
}
const startedAt =
normalizeProviderReplayTimestamp(record.startedAt) ??
normalizeProviderReplayTimestamp(record.started_at);
const completedAt =
normalizeProviderReplayTimestamp(record.completedAt) ??
normalizeProviderReplayTimestamp(record.completed_at);
const startedAt = normalizeProviderReplayTimestamp(row.startedAt);
const completedAt = normalizeProviderReplayTimestamp(row.completedAt);
if (timelineItem.type === "user_message") {
return startedAt ?? completedAt;
}
@@ -2063,84 +2060,19 @@ async function threadItemToTimelineEntries(
return [timelineItem, ...(await mcpToolResultImagesToTimeline(item))];
}
const CodexThreadReadResponseSchema = z
.object({
thread: z
.object({
turns: z
.array(
z
.object({
items: z.array(z.unknown()).default([]),
})
.passthrough(),
)
.default([]),
})
.passthrough()
.default({ turns: [] }),
})
.passthrough();
type CodexThreadReadResponse = z.infer<typeof CodexThreadReadResponseSchema>;
type CodexThreadReadRequest = (threadId: string) => Promise<unknown>;
async function requestCodexThreadHistory(
requestThread: CodexThreadReadRequest,
threadId: string,
): Promise<CodexThreadReadResponse> {
const response = await requestThread(threadId);
return CodexThreadReadResponseSchema.parse(response);
}
async function loadCodexThreadHistoryTimeline(params: {
client: CodexAppServerClientLike;
threadId: string;
cwd: string | null;
requestThread: CodexThreadReadRequest;
}): Promise<CodexThreadHistoryProjection> {
const response = await requestCodexThreadHistory(params.requestThread, params.threadId);
const timeline: PersistedTimelineEntry[] = [];
const subAgentTimelineIndexByThreadId = new Map<string, number>();
for (const turn of response.thread.turns) {
for (const item of turn.items) {
const historicalSubAgentActivity = readCodexSubAgentActivity(item);
if (historicalSubAgentActivity) {
const existingIndex = subAgentTimelineIndexByThreadId.get(
historicalSubAgentActivity.agentThreadId,
);
if (existingIndex !== undefined) {
const activityTimelineItem = await threadItemToTimeline(item, { cwd: params.cwd });
updateHistoricalSubAgentActivity(
timeline,
existingIndex,
historicalSubAgentActivity.kind,
activityTimelineItem?.type === "tool_call" &&
activityTimelineItem.detail.type === "sub_agent"
? activityTimelineItem.detail.subAgentType
: undefined,
);
continue;
}
}
for (const timelineItem of await threadItemToTimelineEntries(item, { cwd: params.cwd })) {
const timestamp =
readCodexHistoryTimestamp(item) ?? readCodexTurnHistoryTimestamp(turn, timelineItem);
const settledTimelineItem =
historicalSubAgentActivity && timelineItem.type === "tool_call"
? settleHistoricalSubAgentActivity(timelineItem, historicalSubAgentActivity.kind)
: timelineItem;
timeline.push({
item: settledTimelineItem,
timestamp: timestamp ?? undefined,
...(timelineItem.type === "user_message" && typeof turn.id === "string"
? { providerTurnId: turn.id }
: {}),
});
for (const childThreadId of readCodexHistoricalSubAgentThreadIds(item)) {
subAgentTimelineIndexByThreadId.set(childThreadId, timeline.length - 1);
}
}
}
try {
await projectCodexThreadHistory(params, { timeline, subAgentTimelineIndexByThreadId });
} catch (error) {
if (!(error instanceof CodexThreadHistoryTooLargeError)) throw error;
// The session stays usable; the row says why the history stops here.
timeline.push({ item: { type: "notification", level: "warning", message: error.message } });
}
const subAgentRoutes = Array.from(subAgentTimelineIndexByThreadId.entries()).flatMap(
([childThreadId, timelineIndex]): PersistedSubAgentRoute[] => {
@@ -2153,11 +2085,55 @@ async function loadCodexThreadHistoryTimeline(params: {
return { timeline, subAgentRoutes };
}
function readCodexThread(client: CodexAppServerClientLike, threadId: string): Promise<unknown> {
return client.request("thread/read", {
threadId,
includeTurns: true,
});
async function projectCodexThreadHistory(
params: { client: CodexAppServerClientLike; threadId: string; cwd: string | null },
projection: {
timeline: PersistedTimelineEntry[];
subAgentTimelineIndexByThreadId: Map<string, number>;
},
): Promise<void> {
const { timeline, subAgentTimelineIndexByThreadId } = projection;
for await (const row of readCodexThreadHistory(params.client, params.threadId)) {
const { item } = row;
const historicalSubAgentActivity = readCodexSubAgentActivity(item);
if (historicalSubAgentActivity) {
const existingIndex = subAgentTimelineIndexByThreadId.get(
historicalSubAgentActivity.agentThreadId,
);
if (existingIndex !== undefined) {
const activityTimelineItem = await threadItemToTimeline(item, { cwd: params.cwd });
updateHistoricalSubAgentActivity(
timeline,
existingIndex,
historicalSubAgentActivity.kind,
activityTimelineItem?.type === "tool_call" &&
activityTimelineItem.detail.type === "sub_agent"
? activityTimelineItem.detail.subAgentType
: undefined,
);
continue;
}
}
for (const timelineItem of await threadItemToTimelineEntries(item, { cwd: params.cwd })) {
const timestamp =
readCodexHistoryTimestamp(item) ?? readCodexHistoryRowTimestamp(row, timelineItem);
const settledTimelineItem =
historicalSubAgentActivity && timelineItem.type === "tool_call"
? settleHistoricalSubAgentActivity(timelineItem, historicalSubAgentActivity.kind)
: timelineItem;
timeline.push({
// Cap each item as it is projected so a large history is never held uncapped.
item: limitAgentTimelineItemContent(settledTimelineItem),
timestamp: timestamp ?? undefined,
...(timelineItem.type === "user_message" && row.turnId !== undefined
? { providerTurnId: row.turnId }
: {}),
});
for (const childThreadId of readCodexHistoricalSubAgentThreadIds(item)) {
subAgentTimelineIndexByThreadId.set(childThreadId, timeline.length - 1);
}
}
}
}
export async function forkCodexThread(
@@ -3408,8 +3384,6 @@ interface CodexSubAgentCallState {
activityItemIds: Set<string>;
pendingCommandOutputDeltas: Map<string, string[]>;
pendingFileChangeOutputDeltas: Map<string, string[]>;
childItemOrder: string[];
childItems: Map<string, AgentTimelineItem>;
childThreadIds: Set<string>;
}
@@ -3511,6 +3485,9 @@ export class CodexAppServerAgentSession implements AgentSession {
private compactionTriggerByItemId = new Map<string, "auto" | "manual">();
private pendingRootCompactionItemIds = new Set<string>();
private pendingAnonymousRootCompactions = 0;
// Child threads with a compaction shown as loading. Some Codex builds settle it only through
// `thread/compacted` or the end of the child's turn.
private pendingChildCompactionThreadIds = new Set<string>();
// Codex can report one completed compaction through both channels:
// `thread/compacted` and a completed `contextCompaction` item.
private unpairedCompactionNotificationCompletions = 0;
@@ -3997,9 +3974,9 @@ export class CodexAppServerAgentSession implements AgentSession {
return { timeline: [], providerSubagentEvents: [] };
}
const { timeline, subAgentRoutes } = await loadCodexThreadHistoryTimeline({
client,
threadId: this.currentThreadId,
cwd: this.config.cwd ?? null,
requestThread: (threadIdToRead) => readCodexThread(client, threadIdToRead),
});
this.subAgentCallsByCallId.clear();
this.subAgentCallIdByChildThreadId.clear();
@@ -4055,9 +4032,9 @@ export class CodexAppServerAgentSession implements AgentSession {
});
try {
const childHistory = await loadCodexThreadHistoryTimeline({
client,
threadId: next.route.childThreadId,
cwd: this.config.cwd ?? null,
requestThread: (childThreadId) => readCodexThread(client, childThreadId),
});
for (const entry of childHistory.timeline) {
this.emitProviderSubagentTimeline(next.route.childThreadId, entry.item, entry.timestamp);
@@ -5540,6 +5517,9 @@ export class CodexAppServerAgentSession implements AgentSession {
case "patch_apply_completed":
this.handlePatchApplyCompletedNotification(parsed, callId);
return;
case "context_compacted":
this.settleChildCompaction(parsed.threadId);
return;
default:
// Aggregate child telemetry is redundant and must not leak into the
// root timeline. Concrete legacy tools are projected above for Codex
@@ -5716,20 +5696,10 @@ export class CodexAppServerAgentSession implements AgentSession {
activityItemIds: new Set<string>(),
pendingCommandOutputDeltas: new Map<string, string[]>(),
pendingFileChangeOutputDeltas: new Map<string, string[]>(),
childItemOrder: [],
childItems: new Map<string, AgentTimelineItem>(),
childThreadIds: new Set<string>(),
} satisfies CodexSubAgentCallState);
state.toolCall = {
...timelineItem,
detail: {
...timelineItem.detail,
log:
timelineItem.detail.log ||
(state.toolCall.detail.type === "sub_agent" ? state.toolCall.detail.log : ""),
},
};
state.toolCall = timelineItem;
state.parentCallId ??= parentCallId;
state.parentSubagentId ??= parentSubagentId;
const activity = readCodexSubAgentActivity(rawItem);
@@ -5782,25 +5752,17 @@ export class CodexAppServerAgentSession implements AgentSession {
const activityToolCall = mapCodexToolCallFromThreadItem(rawItem, {
cwd: this.config.cwd ?? null,
});
if (
activityToolCall?.detail.type === "sub_agent" &&
state.toolCall.detail.type === "sub_agent"
) {
state.toolCall = {
...state.toolCall,
detail: {
...state.toolCall.detail,
subAgentType: activityToolCall.detail.subAgentType,
},
};
}
const subAgentType =
activityToolCall?.detail.type === "sub_agent"
? activityToolCall.detail.subAgentType
: undefined;
let nextStatus: ToolCallTimelineItem["status"] | undefined = "running";
if (activity.kind === "interrupted") {
nextStatus = "canceled";
} else if (isTerminalSubAgentStatus(state.toolCall.status)) {
nextStatus = undefined;
}
this.emitSubAgentActivityUpdate(callId, nextStatus);
this.emitSubAgentActivityUpdate(callId, nextStatus, { subAgentType });
return true;
}
@@ -5841,7 +5803,13 @@ export class CodexAppServerAgentSession implements AgentSession {
): boolean {
if (
childSubAgentCallId &&
this.handleSubAgentContextCompactionItem(childSubAgentCallId, parsed.item, "completed")
parsed.threadId &&
this.handleSubAgentContextCompactionItem(
childSubAgentCallId,
parsed.threadId,
parsed.item,
"completed",
)
) {
return true;
}
@@ -5851,17 +5819,6 @@ export class CodexAppServerAgentSession implements AgentSession {
);
}
private upsertSubAgentChildItem(callId: string, itemId: string, item: AgentTimelineItem): void {
const state = this.subAgentCallsByCallId.get(callId);
if (!state) {
return;
}
if (!state.childItems.has(itemId)) {
state.childItemOrder.push(itemId);
}
state.childItems.set(itemId, item);
}
private emitCodexToolTimelineItem(
timelineItem: ToolCallTimelineItem,
subAgentCallId: string | null,
@@ -5871,7 +5828,6 @@ export class CodexAppServerAgentSession implements AgentSession {
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
return;
}
this.upsertSubAgentChildItem(subAgentCallId, timelineItem.callId, timelineItem);
const state = this.subAgentCallsByCallId.get(subAgentCallId);
if (state) {
const targetThreadIds = childThreadId ? [childThreadId] : state.childThreadIds;
@@ -5885,26 +5841,15 @@ export class CodexAppServerAgentSession implements AgentSession {
);
}
private getSubAgentChildTimeline(state: CodexSubAgentCallState): AgentTimelineItem[] {
return state.childItemOrder
.map((itemId) => state.childItems.get(itemId))
.filter((item): item is AgentTimelineItem => Boolean(item));
}
private emitSubAgentActivityUpdate(
callId: string,
status?: ToolCallTimelineItem["status"],
options?: { reopen?: boolean },
options?: { reopen?: boolean; subAgentType?: string },
): void {
const state = this.subAgentCallsByCallId.get(callId);
if (!state || state.toolCall.detail.type !== "sub_agent") {
return;
}
const childTimeline = this.getSubAgentChildTimeline(state);
const log =
childTimeline.length > 0
? curateAgentActivity(childTimeline, { labelAssistantMessages: true })
: "";
let resolvedStatus = status ?? state.toolCall.status;
if (
status === "running" &&
@@ -5913,16 +5858,12 @@ export class CodexAppServerAgentSession implements AgentSession {
) {
resolvedStatus = state.toolCall.status;
}
for (const childThreadId of state.childThreadIds) {
this.emitProviderSubagentUpsert(childThreadId, state, resolvedStatus);
}
const baseToolCall = {
...state.toolCall,
detail: {
...state.toolCall.detail,
log,
},
};
const baseToolCall = options?.subAgentType
? {
...state.toolCall,
detail: { ...state.toolCall.detail, subAgentType: options.subAgentType },
}
: state.toolCall;
const nextToolCall: ToolCallTimelineItem =
resolvedStatus === "failed"
? {
@@ -5935,10 +5876,14 @@ export class CodexAppServerAgentSession implements AgentSession {
status: resolvedStatus,
error: null,
};
// The card is a launch marker: the child's activity streams to its own timeline, so the card
// is re-sent only when its status or label changes.
const cardChanged = !isDeepStrictEqual(nextToolCall, state.toolCall);
state.toolCall = nextToolCall;
if (state.parentCallId && state.parentCallId !== callId) {
this.upsertSubAgentChildItem(state.parentCallId, state.callId, nextToolCall);
this.emitSubAgentActivityUpdate(state.parentCallId);
for (const childThreadId of state.childThreadIds) {
this.emitProviderSubagentUpsert(childThreadId, state, resolvedStatus);
}
if (!cardChanged || (state.parentCallId && state.parentCallId !== callId)) {
return;
}
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: nextToolCall });
@@ -6046,7 +5991,6 @@ export class CodexAppServerAgentSession implements AgentSession {
): void {
this.applyBufferedDeltaTextToTimelineItem(timelineItem, itemId);
if (itemId) {
this.upsertSubAgentChildItem(callId, itemId, timelineItem);
this.pendingAgentMessages.delete(itemId);
this.pendingReasoning.delete(itemId);
this.pendingCommandOutputDeltas.delete(itemId);
@@ -6057,19 +6001,29 @@ export class CodexAppServerAgentSession implements AgentSession {
private handleSubAgentContextCompactionItem(
callId: string,
childThreadId: string,
item: { id?: string; type?: string; [key: string]: unknown },
status: "loading" | "completed",
): boolean {
if (!this.isContextCompactionItem(item)) {
return false;
}
if (item.id) {
this.upsertSubAgentChildItem(callId, item.id, { type: "compaction", status });
if (status === "loading") {
this.pendingChildCompactionThreadIds.add(childThreadId);
this.emitProviderSubagentTimeline(childThreadId, { type: "compaction", status });
} else {
this.settleChildCompaction(childThreadId);
}
this.emitSubAgentActivityUpdate(callId);
return true;
}
private settleChildCompaction(childThreadId: string): void {
if (this.pendingChildCompactionThreadIds.delete(childThreadId)) {
this.emitProviderSubagentTimeline(childThreadId, { type: "compaction", status: "completed" });
}
}
private shouldSkipCompletedThreadItem(
timelineItem: AgentTimelineItem,
normalizedItemType: string | undefined,
@@ -6100,11 +6054,6 @@ export class CodexAppServerAgentSession implements AgentSession {
text: parsed.delta,
});
}
this.upsertSubAgentChildItem(subAgentCallId, parsed.itemId, {
type: "assistant_message",
messageId: parsed.itemId,
text,
});
this.emitSubAgentActivityUpdate(subAgentCallId, "running");
return;
}
@@ -6138,10 +6087,6 @@ export class CodexAppServerAgentSession implements AgentSession {
text: parsed.delta,
});
}
this.upsertSubAgentChildItem(subAgentCallId, parsed.itemId, {
type: "reasoning",
text: prev.join(""),
});
this.emitSubAgentActivityUpdate(subAgentCallId, "running");
return;
}
@@ -6209,6 +6154,7 @@ export class CodexAppServerAgentSession implements AgentSession {
): void {
const subAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
if (subAgentCallId) {
if (parsed.threadId) this.settleChildCompaction(parsed.threadId);
let status: ToolCallTimelineItem["status"] = "completed";
if (parsed.status === "failed") {
status = "failed";
@@ -6795,7 +6741,13 @@ export class CodexAppServerAgentSession implements AgentSession {
const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
if (
childSubAgentCallId &&
this.handleSubAgentContextCompactionItem(childSubAgentCallId, parsed.item, "loading")
parsed.threadId &&
this.handleSubAgentContextCompactionItem(
childSubAgentCallId,
parsed.threadId,
parsed.item,
"loading",
)
) {
return;
}
@@ -6826,9 +6778,6 @@ export class CodexAppServerAgentSession implements AgentSession {
});
if (childSubAgentCallId) {
this.emitStartedProviderSubagentItem(parsed.threadId, timelineItem);
if (parsed.item.id) {
this.upsertSubAgentChildItem(childSubAgentCallId, parsed.item.id, timelineItem);
}
this.emitSubAgentActivityUpdate(childSubAgentCallId, "running");
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
return;
@@ -6882,9 +6831,6 @@ export class CodexAppServerAgentSession implements AgentSession {
if (parsed.threadId) {
this.emitProviderSubagentTimeline(parsed.threadId, timelineItem);
}
if (itemId) {
this.upsertSubAgentChildItem(childSubAgentCallId, itemId, timelineItem);
}
this.emitSubAgentActivityUpdate(childSubAgentCallId, "running");
return;
}
@@ -0,0 +1,53 @@
import { execFile } from "node:child_process";
import { mkdtemp, rm } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test } from "vitest";
// Regression for #3905. Real Codex, synthetic on-disk history, no model turns.
// Opt in because the oversized cases need approximately 2 GiB of memory and over
// 1 GiB of temporary storage.
const enabled = process.env.PASEO_CODEX_HISTORY_STRING_LIMIT_REPRO === "1";
const script = fileURLToPath(
new URL("../../../../scripts/reproduce-codex-history-string-limit.mts", import.meta.url),
);
test.runIf(enabled).each([
{ megabytes: 2, historyMode: "legacy", expected: "History loaded: 3 timeline rows" },
{ megabytes: 520, historyMode: "legacy", expected: "codex migrate-rollouts --apply --thread" },
{ megabytes: 520, historyMode: "paginated", expected: "History loaded: 521 timeline rows" },
])(
"opening $megabytes MiB of $historyMode Codex history does not crash its hosting process",
async ({ megabytes, historyMode, expected }) => {
const root = await mkdtemp(path.join(process.cwd(), ".codex-history-test-"));
try {
const result = await new Promise<{ code: number; stdout: string; stderr: string }>(
(resolve) => {
const child = execFile(
process.execPath,
["--import", "tsx", script, String(megabytes), historyMode],
{
cwd: root,
detached: true,
timeout: 120_000,
maxBuffer: 1024 * 1024,
},
(error, stdout, stderr) => {
try {
if (child.pid) process.kill(-child.pid, "SIGKILL");
} catch (killError) {
if ((killError as NodeJS.ErrnoException).code !== "ESRCH") throw killError;
}
resolve({ code: error ? Number(error.code ?? 1) : 0, stdout, stderr });
},
);
},
);
expect(result.code, result.stderr).toBe(0);
expect(result.stdout).toContain(expected);
} finally {
await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
}
},
180_000,
);
@@ -185,14 +185,41 @@ describe("Codex app-server transport", () => {
});
describe("Codex stdout byte admission", () => {
test("fails pending requests and the session before joining an oversized line", async () => {
test("an oversized response fails only its own request", async () => {
const child = createCodexAppServerChildProcess();
const client = new CodexAppServerClient(child, createTestLogger());
const terminations: Error[] = [];
client.setUnexpectedTerminationHandler((error) => terminations.push(error));
const oversized = client.request("thread/items/list", {}).catch((error: Error) => error);
const other = client.request("model/list", {});
const chunk = Buffer.alloc(64 * 1024, "x");
child.stdout.write('{"id":1,"result":"');
for (let index = 0; index < 2049; index++) child.stdout.write(chunk);
child.stdout.write('"}\n{"id":2,"result":{"data":[]}}\n');
expect(await oversized).toMatchObject({
name: "CodexAppServerResponseTooLargeError",
method: "thread/items/list",
limitBytes: 128 * 1024 * 1024,
});
await expect(other).resolves.toEqual({ data: [] });
const next = client.request("model/list", {});
child.stdout.write('{"id":3,"result":{"data":["next"]}}\n');
await expect(next).resolves.toEqual({ data: ["next"] });
expect(terminations).toEqual([]);
await client.dispose();
child.stdout.end();
child.stderr.end();
});
test("an oversized notification fails pending requests and the session", async () => {
const child = createCodexAppServerChildProcess();
const client = new CodexAppServerClient(child, createTestLogger());
const terminations: Error[] = [];
client.setUnexpectedTerminationHandler((error) => terminations.push(error));
const request = client.request("thread/resume", {}, 1000).catch((error: Error) => error);
const chunk = Buffer.alloc(64 * 1024, "x");
child.stdout.write('{"id":1,"result":"');
child.stdout.write('{"method":"item/completed","params":"');
for (let index = 0; index < 2048; index++) child.stdout.write(chunk);
const error = await request;
expect(error).toMatchObject({
@@ -33,11 +33,26 @@ interface JsonRpcNotification {
}
interface PendingRequest {
method: string;
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timer: NodeJS.Timeout;
}
/** A response past the provider line cap. Only its request fails; the session stays up. */
export class CodexAppServerResponseTooLargeError extends Error {
constructor(
readonly method: string,
readonly limitBytes: number,
) {
super(`Codex app-server response to ${method} exceeds ${limitBytes} bytes`);
this.name = "CodexAppServerResponseTooLargeError";
}
}
// Codex serializes a response with `id` first, so its head names the request.
const OVERSIZED_RESPONSE_HEAD = /^\{"id":(\d+),"result":/;
export class CodexAppServerRpcError extends Error {
constructor(
message: string,
@@ -258,7 +273,7 @@ export class CodexAppServerClient {
this.pending.delete(id);
reject(new Error(`Codex app-server request timed out for ${method}`));
}, timeoutMs);
this.pending.set(id, { resolve, reject, timer });
this.pending.set(id, { method, resolve, reject, timer });
});
}
@@ -304,8 +319,21 @@ export class CodexAppServerClient {
}
private handleStreamLimit(failure: ProviderStreamLimitError): void {
// A rejected response/notification cannot be replayed safely. Fail the session,
const responseId = OVERSIZED_RESPONSE_HEAD.exec(failure.lineHead)?.[1];
const pending = responseId === undefined ? undefined : this.pending.get(Number(responseId));
if (pending) {
clearTimeout(pending.timer);
this.pending.delete(Number(responseId));
this.logger.warn(
{ method: pending.method, limitBytes: failure.limitBytes },
"Codex app-server response exceeded the line cap",
);
pending.reject(new CodexAppServerResponseTooLargeError(pending.method, failure.limitBytes));
return;
}
// A lost notification or server request cannot be replayed. Fail the session,
// including live turns with no pending RPC, rather than dropping a stream unit.
this.stdoutLineReader.close();
this.handleUnexpectedTermination(failure);
void this.dispose().catch((err) => {
this.logger.warn({ err }, "Failed to terminate oversized Codex stdout producer");
@@ -108,7 +108,7 @@ describe("Codex Rewind", () => {
cwd: "/workspace/project",
model: "gpt-5.4-mini",
serviceTier: null,
excludeTurns: false,
excludeTurns: true,
persistExtendedHistory: true,
},
]);
@@ -190,7 +190,7 @@ describe("Codex Rewind", () => {
cwd: "/workspace/project",
model: "gpt-5.4-mini",
serviceTier: null,
excludeTurns: false,
excludeTurns: true,
persistExtendedHistory: true,
},
]);
@@ -8,6 +8,7 @@ import {
parseCodexThreadForkResponse,
parseCodexThreadRollbackResponse,
} from "./app-server-transport.js";
import { readCodexThreadHistoryMode } from "./thread-history.js";
export interface CodexRewindClient {
forkThread?(params: CodexThreadForkParams): Promise<CodexThreadForkResponse>;
@@ -20,26 +21,6 @@ export interface CodexUserMessageTurnIndex {
count(): number;
}
type CodexThreadHistoryMode = "legacy" | "paginated";
async function readCodexThreadHistoryMode(
client: CodexRewindClient,
threadId: string,
): Promise<CodexThreadHistoryMode> {
const response = await client.request("thread/read", { threadId, includeTurns: false });
if (typeof response !== "object" || response === null || !("thread" in response)) {
throw new Error("Codex thread/read did not return thread metadata");
}
const thread = response.thread;
if (typeof thread !== "object" || thread === null || !("historyMode" in thread)) {
return "legacy";
}
if (thread.historyMode === "legacy" || thread.historyMode === "paginated") {
return thread.historyMode;
}
throw new Error(`Codex thread/read returned unknown history mode ${String(thread.historyMode)}`);
}
async function forkCodexThread(
client: CodexRewindClient,
params: CodexThreadForkParams,
@@ -95,7 +76,8 @@ export async function revertCodexConversation(input: {
model: input.model ?? null,
serviceTier: input.serviceTier ?? null,
...(input.config ? { config: input.config } : {}),
excludeTurns: false,
// Paseo re-reads the rewound thread through paged history, not the fork reply.
excludeTurns: true,
persistExtendedHistory: true,
};
@@ -3,6 +3,10 @@ import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import type { AgentSession, AgentStreamEvent } from "../../../agent-sdk-types.js";
import { CODEX_PROVIDER_LINE_BYTES } from "../app-server-transport.js";
/** A handler result that the fake writes as a response line past the provider line cap. */
export const OVERSIZED_RESPONSE = Symbol("oversized Codex response");
type JsonObject = Record<string, unknown>;
type FakeCodexAppServerHandler = (params: unknown) => unknown;
@@ -229,8 +233,18 @@ export function createFakeCodexAppServer(
return;
}
Promise.resolve(handler(message.params))
let response: unknown;
try {
response = handler(message.params);
} catch (error) {
response = Promise.reject(error);
}
Promise.resolve(response)
.then((result) => {
if (result === OVERSIZED_RESPONSE) {
writeOversizedResponse(message.id as number);
return undefined;
}
const rpcError = toJsonObject(result).__jsonRpcError;
if (rpcError) {
child.stdout.write(`${JSON.stringify({ id: message.id, error: rpcError })}\n`);
@@ -313,6 +327,16 @@ export function createFakeCodexAppServer(
});
}
// Codex writes `id` first, then a result past the provider line cap.
function writeOversizedResponse(id: number): void {
child.stdout.write(`{"id":${id},"result":{"data":[{"item":{"text":"`);
const chunk = Buffer.alloc(1024 * 1024, "x");
for (let written = 0; written <= CODEX_PROVIDER_LINE_BYTES; written += chunk.byteLength) {
child.stdout.write(chunk);
}
child.stdout.write('"}}]}}\n');
}
function writeNotification(method: string, params: JsonObject): void {
child.stdout.write(`${JSON.stringify({ method, params })}\n`);
}
@@ -696,3 +720,81 @@ export function waitForProviderSubagent(
): Promise<ProviderSubagentEvent> {
return waitForNextEvent(session, "provider_subagent", (event) => event.event.id === id);
}
export interface FakeCodexHistoryTurn {
id: string;
startedAt?: number;
completedAt?: number;
items: JsonObject[];
}
export interface FakeCodexHistoryThread {
/** Omitted on Codex builds older than 0.143, which only have legacy history. */
historyMode?: "legacy" | "paginated";
turns: FakeCodexHistoryTurn[];
}
export interface FakeCodexHistoryOptions {
/** Any response carrying one of these items is written past the line cap. */
oversizedItemIds?: string[];
}
/**
* Codex's persisted thread history: `thread/read` for metadata and legacy full
* reads, `thread/items/list` for paginated threads. Paginated threads reject
* the full read as Codex 0.145 and 0.146 do.
*/
export function fakeCodexThreadHistory(
threads: Record<string, FakeCodexHistoryThread>,
options: FakeCodexHistoryOptions = {},
): Record<string, FakeCodexAppServerHandler> {
const oversizedItemIds = new Set(options.oversizedItemIds ?? []);
const isOversized = (items: JsonObject[]) =>
items.some((item) => oversizedItemIds.has(String(item.id)));
function readThread(params: unknown): { id: string; thread: FakeCodexHistoryThread } {
const id = String(toJsonObject(params).threadId);
const thread = threads[id];
if (!thread) throw new Error(`thread not found: ${id}`);
return { id, thread };
}
return {
"thread/read": (params) => {
const { id, thread } = readThread(params);
if (!toJsonObject(params).includeTurns) {
return {
thread: { id, ...(thread.historyMode ? { historyMode: thread.historyMode } : {}) },
};
}
if (thread.historyMode === "paginated") {
throw new Error("paginated threads do not support thread/read(includeTurns=true)");
}
if (isOversized(thread.turns.flatMap((turn) => turn.items))) return OVERSIZED_RESPONSE;
return { thread: { id, turns: thread.turns } };
},
"thread/items/list": (params) => {
const { thread } = readThread(params);
if (thread.historyMode !== "paginated") {
throw new Error("thread/items/list is not supported for legacy threads");
}
const request = toJsonObject(params);
// Codex records each item's own times; the fake gives every item its turn's.
const rows = thread.turns.flatMap((turn) =>
turn.items.map((item) => ({
turnId: turn.id,
item,
startedAtMs: turn.startedAt === undefined ? null : turn.startedAt * 1000,
completedAtMs: turn.completedAt === undefined ? null : turn.completedAt * 1000,
})),
);
const limit = Math.min(typeof request.limit === "number" ? request.limit : 25, 100);
const start =
typeof request.cursor === "string" ? Number(request.cursor.slice("cursor-".length)) : 0;
const end = Math.min(rows.length, start + limit);
const data = rows.slice(start, end);
if (isOversized(data.map((row) => row.item))) return OVERSIZED_RESPONSE;
return { data, nextCursor: end < rows.length ? `cursor-${end}` : null };
},
};
}
@@ -0,0 +1,220 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js";
import { CodexAppServerAgentClient } from "../codex-app-server-agent.js";
import {
createFakeCodexAppServer,
type FakeCodexAppServer,
type FakeCodexHistoryOptions,
type FakeCodexHistoryThread,
fakeCodexThreadHistory,
} from "./test-utils/fake-app-server.js";
function userItem(id: string, text: string) {
return { type: "userMessage", id, content: [{ type: "text", text }] };
}
function agentItem(id: string, text: string) {
return { type: "agentMessage", id, text };
}
async function openCodexThread(
thread: FakeCodexHistoryThread,
options?: FakeCodexHistoryOptions,
): Promise<{ appServer: FakeCodexAppServer; session: AgentSession }> {
const appServer = createFakeCodexAppServer(
fakeCodexThreadHistory({ "saved-thread": thread }, options),
);
const provider = new CodexAppServerAgentClient(createTestLogger());
const internals = provider as unknown as {
goalsEnabledPromise: Promise<boolean> | null;
autoReviewEnabledPromise: Promise<boolean> | null;
spawnAppServer: () => Promise<ChildProcessWithoutNullStreams>;
};
internals.goalsEnabledPromise = Promise.resolve(false);
internals.autoReviewEnabledPromise = Promise.resolve(false);
internals.spawnAppServer = async () => appServer.child;
const session = await provider.resumeSession({ sessionId: "saved-thread" });
return { appServer, session };
}
async function readHistory(session: AgentSession): Promise<AgentStreamEvent[]> {
const events: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) events.push(event);
return events;
}
function timelineItems(events: AgentStreamEvent[]) {
return events.flatMap((event) => (event.type === "timeline" ? [event.item] : []));
}
function historyRequests(appServer: FakeCodexAppServer) {
return appServer
.requests()
.filter(
(request) =>
request.method === "thread/read" ||
request.method === "thread/turns/list" ||
request.method === "thread/items/list",
)
.map((request) => ({ method: request.method, params: request.params }));
}
describe("Codex thread history", () => {
test("loads a paginated thread through pages", async () => {
const answers = Array.from({ length: 150 }, (_, index) =>
agentItem(`agent-${index}`, `Answer ${index}`),
);
const { appServer, session } = await openCodexThread({
historyMode: "paginated",
turns: [
{
id: "turn-1",
startedAt: 1_778_832_941,
completedAt: 1_778_833_000,
items: [userItem("user-1", "First"), ...answers.slice(0, 120)],
},
{
id: "turn-2",
startedAt: 1_778_833_094,
completedAt: 1_778_833_100,
items: [userItem("user-2", "Second"), ...answers.slice(120)],
},
],
});
try {
const events = await readHistory(session);
expect(timelineItems(events)).toEqual([
{ type: "user_message", text: "First", messageId: "user-1" },
...answers.slice(0, 120).map((answer) => ({
type: "assistant_message",
text: answer.text,
messageId: answer.id,
})),
{ type: "user_message", text: "Second", messageId: "user-2" },
...answers.slice(120).map((answer) => ({
type: "assistant_message",
text: answer.text,
messageId: answer.id,
})),
]);
const timestamps = events.flatMap((event) =>
event.type === "timeline" ? [event.timestamp] : [],
);
expect(timestamps[0]).toBe("2026-05-15T08:15:41.000Z");
expect(timestamps[1]).toBe("2026-05-15T08:16:40.000Z");
expect(timestamps[121]).toBe("2026-05-15T08:18:14.000Z");
const requests = historyRequests(appServer);
expect(requests).not.toContainEqual(
expect.objectContaining({ params: expect.objectContaining({ includeTurns: true }) }),
);
const itemPages = requests.filter((request) => request.method === "thread/items/list");
expect(itemPages.length).toBeGreaterThan(1);
for (const page of itemPages) {
expect(page.params).toMatchObject({ threadId: "saved-thread", sortDirection: "asc" });
expect((page.params as { limit: number }).limit).toBeLessThanOrEqual(100);
}
} finally {
await session.close();
}
appServer.assertNoErrors();
});
test("loads a paginated thread whose full read Codex 0.145 rejects", async () => {
const { appServer, session } = await openCodexThread({
historyMode: "paginated",
turns: [{ id: "turn-1", items: [userItem("user-1", "Hello"), agentItem("agent-1", "Hi")] }],
});
try {
expect(timelineItems(await readHistory(session))).toEqual([
{ type: "user_message", text: "Hello", messageId: "user-1" },
{ type: "assistant_message", text: "Hi", messageId: "agent-1" },
]);
} finally {
await session.close();
}
appServer.assertNoErrors();
});
test("a page too big to send shows a warning row and stops loading", async () => {
const answers = Array.from({ length: 120 }, (_, index) =>
agentItem(`agent-${index}`, `Answer ${index}`),
);
const { appServer, session } = await openCodexThread(
{
historyMode: "paginated",
turns: [
{ id: "turn-1", items: [userItem("user-1", "One"), ...answers] },
{ id: "turn-2", items: [userItem("user-2", "Two"), agentItem("huge", "never sent")] },
],
},
{ oversizedItemIds: ["huge"] },
);
const loaded = [
{ type: "user_message", text: "One", messageId: "user-1" },
...answers.slice(0, 99).map((answer) => ({
type: "assistant_message",
text: answer.text,
messageId: answer.id,
})),
{
type: "notification",
level: "warning",
message: "The rest of this Codex thread's history is too large to load.",
},
];
try {
expect(timelineItems(await readHistory(session))).toEqual(loaded);
// The oversized reply failed only its own request; the session still answers.
expect(timelineItems(await readHistory(session))).toEqual(loaded);
} finally {
await session.close();
}
appServer.assertNoErrors();
});
test("loads a legacy thread that fits in one read", async () => {
const { appServer, session } = await openCodexThread({
historyMode: "legacy",
turns: [{ id: "turn-1", items: [userItem("user-1", "Hello"), agentItem("agent-1", "Hi")] }],
});
try {
expect(timelineItems(await readHistory(session))).toEqual([
{ type: "user_message", text: "Hello", messageId: "user-1" },
{ type: "assistant_message", text: "Hi", messageId: "agent-1" },
]);
expect(historyRequests(appServer).map((request) => request.method)).not.toContain(
"thread/items/list",
);
} finally {
await session.close();
}
appServer.assertNoErrors();
});
test("a legacy thread too big for one read shows the migration command", async () => {
const { appServer, session } = await openCodexThread(
{
historyMode: "legacy",
turns: [{ id: "turn-1", items: [userItem("user-1", "Hello"), agentItem("huge", "x")] }],
},
{ oversizedItemIds: ["huge"] },
);
const migrationRow = {
type: "notification",
level: "warning",
message: expect.stringContaining("codex migrate-rollouts --apply --thread saved-thread"),
};
try {
expect(timelineItems(await readHistory(session))).toEqual([migrationRow]);
// The session stays up for the user to prompt until they archive it to migrate.
expect(timelineItems(await readHistory(session))).toEqual([migrationRow]);
} finally {
await session.close();
}
appServer.assertNoErrors();
});
});
@@ -0,0 +1,168 @@
import { z } from "zod";
import { CodexAppServerResponseTooLargeError } from "./app-server-transport.js";
// Codex clamps history pages to 100 rows.
const PAGE_SIZE = 100;
export interface CodexThreadHistoryClient {
request(method: string, params?: unknown): Promise<unknown>;
}
export type CodexThreadHistoryMode = "legacy" | "paginated";
export interface CodexHistoryRow {
item: unknown;
turnId: string | undefined;
/** The item's times on a paginated thread; its turn's on a legacy thread. */
startedAt: unknown;
completedAt: unknown;
}
/** A history reply over the line cap; the rows before it were already read. */
export class CodexThreadHistoryTooLargeError extends Error {
constructor(message: string) {
super(message);
this.name = "CodexThreadHistoryTooLargeError";
}
}
const CodexThreadMetadataSchema = z
.object({
thread: z.object({ historyMode: z.enum(["legacy", "paginated"]).optional() }).passthrough(),
})
.passthrough();
const CodexLegacyThreadReadSchema = z
.object({
thread: z
.object({
turns: z
.array(
z
.object({
id: z.string().optional(),
startedAt: z.unknown().optional(),
completedAt: z.unknown().optional(),
items: z.array(z.unknown()).default([]),
})
.passthrough(),
)
.default([]),
})
.passthrough()
.default({ turns: [] }),
})
.passthrough();
const CodexItemsPageSchema = z
.object({
data: z.array(
z
.object({
turnId: z.string(),
item: z.unknown(),
startedAtMs: z.number().nullable().optional(),
completedAtMs: z.number().nullable().optional(),
})
.passthrough(),
),
nextCursor: z.string().nullable().optional(),
})
.passthrough();
/** Codex builds before 0.143 report no history mode; their threads are legacy. */
export async function readCodexThreadHistoryMode(
client: CodexThreadHistoryClient,
threadId: string,
): Promise<CodexThreadHistoryMode> {
const response = CodexThreadMetadataSchema.parse(
await client.request("thread/read", { threadId, includeTurns: false }),
);
return response.thread.historyMode ?? "legacy";
}
/**
* Reads a thread's persisted items in order. Paginated threads are read in
* pages; a legacy thread arrives in one `thread/read` reply, as Codex offers
* no pages for it.
*/
export async function* readCodexThreadHistory(
client: CodexThreadHistoryClient,
threadId: string,
): AsyncGenerator<CodexHistoryRow> {
if ((await readCodexThreadHistoryMode(client, threadId)) === "paginated") {
yield* readPaginatedHistory(client, threadId);
} else {
yield* readLegacyHistory(client, threadId);
}
}
async function* readLegacyHistory(
client: CodexThreadHistoryClient,
threadId: string,
): AsyncGenerator<CodexHistoryRow> {
const response = await requestHistory(
client,
"thread/read",
{ threadId, includeTurns: true },
`This Codex thread's history is too large to load in one read. ` +
`To move it to paginated history, archive this agent, ` +
`run \`codex migrate-rollouts --apply --thread ${threadId}\` (Codex 0.148 or newer), ` +
`then unarchive the agent.`,
);
for (const turn of CodexLegacyThreadReadSchema.parse(response).thread.turns) {
for (const item of turn.items) {
yield { item, turnId: turn.id, startedAt: turn.startedAt, completedAt: turn.completedAt };
}
}
}
async function* readPaginatedHistory(
client: CodexThreadHistoryClient,
threadId: string,
): AsyncGenerator<CodexHistoryRow> {
const seenCursors = new Set<string>();
let cursor: string | undefined;
for (;;) {
const page = CodexItemsPageSchema.parse(
await requestHistory(
client,
"thread/items/list",
{ threadId, limit: PAGE_SIZE, sortDirection: "asc", cursor },
"The rest of this Codex thread's history is too large to load.",
),
);
for (const row of page.data) {
yield {
item: row.item,
turnId: row.turnId,
startedAt: row.startedAtMs,
completedAt: row.completedAtMs,
};
}
if (page.nextCursor == null) return;
// Codex's own client guards against a cursor that does not advance.
if (seenCursors.has(page.nextCursor)) {
throw new Error(`Codex repeated history cursor for thread ${threadId}`);
}
seenCursors.add(page.nextCursor);
cursor = page.nextCursor;
}
}
async function requestHistory(
client: CodexThreadHistoryClient,
method: string,
params: unknown,
tooLargeMessage: string,
): Promise<unknown> {
try {
return await client.request(method, params);
} catch (error) {
if (error instanceof CodexAppServerResponseTooLargeError) {
throw new CodexThreadHistoryTooLargeError(tooLargeMessage);
}
throw error;
}
}
@@ -950,6 +950,7 @@ function mapCollabAgentToolCallItem(
type: "sub_agent",
subAgentType: "Sub-agent",
...(item.prompt ? { description: item.prompt } : {}),
// COMPAT(subagent-log): remove after 2027-04-30.
log: "",
actions: [],
};
@@ -1006,6 +1007,7 @@ function mapSubAgentActivityItem(
type: "sub_agent",
subAgentType: nativeName,
description,
// COMPAT(subagent-log): remove after 2027-04-30.
log: "",
actions: [],
},
@@ -1,10 +1,17 @@
// Provider-process stdout only; these are not app/daemon wire limits.
export const JSONL_PROVIDER_LINE_BYTES = 64 * 1024 * 1024;
// Enough of an oversized line to read which response it was, e.g. `{"id":12,"result":`.
const OVERSIZED_LINE_HEAD_BYTES = 64;
export class ProviderStreamLimitError extends Error {
readonly code = "PROVIDER_STREAM_LINE_TOO_LARGE";
constructor(readonly limitBytes: number) {
constructor(
readonly limitBytes: number,
/** The first bytes of the oversized line; the rest is discarded unread. */
readonly lineHead = "",
) {
super(
`Provider stdout line exceeds ${limitBytes} bytes. Start a new thread or reduce the provider response size.`,
);
@@ -12,10 +19,26 @@ export class ProviderStreamLimitError extends Error {
}
}
/** Admits raw UTF-8 bytes before retaining, decoding or joining a complete line. */
function readLineHead(parts: Buffer[]): string {
const head: Buffer[] = [];
let bytes = 0;
for (const part of parts) {
if (bytes >= OVERSIZED_LINE_HEAD_BYTES) break;
head.push(part.subarray(0, OVERSIZED_LINE_HEAD_BYTES - bytes));
bytes += head[head.length - 1].byteLength;
}
return Buffer.concat(head).toString("utf8");
}
/**
* Admits raw UTF-8 bytes before retaining, decoding or joining a complete line.
* An oversized line is reported with its head and skipped up to its newline;
* the owner closes the reader if the stream cannot continue without it.
*/
export class ProviderLineReader {
private parts: Buffer[] = [];
private bytes = 0;
private skippingOversizedLine = false;
private stopped = false;
constructor(
@@ -31,11 +54,20 @@ export class ProviderLineReader {
while (start < buffer.byteLength && !this.stopped) {
const newline = buffer.indexOf(10, start);
const end = newline === -1 ? buffer.byteLength : newline;
if (this.skippingOversizedLine) {
if (newline === -1) return;
this.skippingOversizedLine = false;
start = newline + 1;
continue;
}
const length = end - start;
if (length > this.limitBytes - this.bytes) {
this.close();
this.onLimit(new ProviderStreamLimitError(this.limitBytes));
return;
const head = readLineHead([...this.parts, buffer.subarray(start, end)]);
this.parts = [];
this.bytes = 0;
this.skippingOversizedLine = true;
this.onLimit(new ProviderStreamLimitError(this.limitBytes, head));
continue;
}
if (length) this.parts.push(buffer.subarray(start, end));
this.bytes += length;
@@ -150,7 +182,10 @@ export class JsonlFrameDecoder {
(line) => {
if (line.trim()) this.consumeLine(line);
},
(error) => this.drop("line-too-large", error),
(error) => {
this.lines.close();
this.drop("line-too-large", error);
},
);
}
@@ -69,7 +69,7 @@ import {
type OmpRuntimeOptions,
} from "./provider-config.js";
export { formatOmpVersionSupport, resolveOmpDiagnosticPaths } from "./provider-config.js";
import { OmpSubagentCardTracker, type OmpSubagentCardScheduler } from "./subagent-card-tracker.js";
import { OmpSubagentCardTracker } from "./subagent-card-tracker.js";
import { ompSkillPromptUserText, shouldDisplayOmpCustomMessage } from "./custom-message.js";
import { getUserMessageText } from "./message-history.js";
import { mapOmpSystemNoticeToNotification } from "./system-notice.js";
@@ -130,7 +130,6 @@ export interface OmpAgentClientOptions {
logger: Logger;
runtimeSettings?: ProviderRuntimeSettings;
runtime?: OmpRuntime;
subagentCardScheduler?: OmpSubagentCardScheduler;
providerIdleScheduler?: OmpProviderIdleScheduler;
noTurnScheduler?: OmpNoTurnScheduler;
usagePollScheduler?: OmpUsagePollScheduler;
@@ -205,7 +204,6 @@ interface OmpAgentSessionOptions {
initialState: OmpSessionState;
currentModeId?: string | null;
logger: Logger;
subagentCardScheduler?: OmpSubagentCardScheduler;
providerIdleScheduler?: OmpProviderIdleScheduler;
noTurnScheduler?: OmpNoTurnScheduler;
usagePollScheduler?: OmpUsagePollScheduler;
@@ -773,9 +771,7 @@ export class OmpAgentSession implements AgentSession {
this.logger.debug({ err: error }, "OMP context usage poll failed");
},
});
this.subagentCardTracker = new OmpSubagentCardTracker({
scheduler: options.subagentCardScheduler,
});
this.subagentCardTracker = new OmpSubagentCardTracker();
this.lastKnownThinkingOptionId =
normalizeOmpThinkingOption(options.config.thinkingOptionId) ??
this.state.thinkingLevel ??
@@ -2155,8 +2151,7 @@ export class OmpAgentSession implements AgentSession {
return mapOmpToolDetail(toolCall, result, {
toolCallId,
bridgedTool: this.hostTools?.bridge?.tool(toolCall.toolName),
mapSubagentDetail: (detail) =>
this.subagentCardTracker.detailFor(toolCallId, detail) ?? detail,
mapSubagentDetail: (detail) => this.subagentCardTracker.detailFor(toolCallId, detail),
});
}
@@ -2246,7 +2241,6 @@ export class OmpAgentClient implements AgentClient {
private readonly logger: Logger;
private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly subagentCardScheduler?: OmpSubagentCardScheduler;
private readonly providerIdleScheduler?: OmpProviderIdleScheduler;
private readonly noTurnScheduler?: OmpNoTurnScheduler;
private readonly usagePollScheduler?: OmpUsagePollScheduler;
@@ -2265,7 +2259,6 @@ export class OmpAgentClient implements AgentClient {
);
this.logger = options.logger;
this.runtimeSettings = runtimeSettings;
this.subagentCardScheduler = options.subagentCardScheduler;
this.providerIdleScheduler = options.providerIdleScheduler;
this.noTurnScheduler = options.noTurnScheduler;
this.usagePollScheduler = options.usagePollScheduler;
@@ -2349,7 +2342,6 @@ export class OmpAgentClient implements AgentClient {
initialState,
currentModeId: launchMode.modeId,
logger: this.logger,
subagentCardScheduler: this.subagentCardScheduler,
providerIdleScheduler: this.providerIdleScheduler,
noTurnScheduler: this.noTurnScheduler,
usagePollScheduler: this.usagePollScheduler,
@@ -2421,7 +2413,6 @@ export class OmpAgentClient implements AgentClient {
initialState,
currentModeId: launchMode.modeId,
logger: this.logger,
subagentCardScheduler: this.subagentCardScheduler,
providerIdleScheduler: this.providerIdleScheduler,
noTurnScheduler: this.noTurnScheduler,
usagePollScheduler: this.usagePollScheduler,
@@ -637,7 +637,7 @@ describe("OMP history mapper", () => {
subAgentType: "explore",
description: "Inspect files",
childSessionId: "/tmp/omp-task/Explore.jsonl",
log: "done\ntranscript: /tmp/omp-task/Explore.jsonl",
log: "",
},
error: null,
},
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { OmpSubagentCardTracker, type OmpSubagentCardScheduler } from "./subagent-card-tracker.js";
import { OmpSubagentCardTracker } from "./subagent-card-tracker.js";
import type { OmpSubagentLifecyclePayload, OmpSubagentProgressPayload } from "./rpc-types.js";
const PARENT_TOOL_CALL_ID = "task-1";
@@ -59,184 +59,84 @@ const PROGRESS: OmpSubagentProgressPayload[] = [
},
];
class ManualScheduler implements OmpSubagentCardScheduler {
private currentMs = 0;
private nextId = 1;
private readonly timers = new Map<number, { dueMs: number; callback: () => void }>();
now(): number {
return this.currentMs;
}
setTimeout(callback: () => void, delayMs: number) {
const id = this.nextId;
this.nextId += 1;
this.timers.set(id, { dueMs: this.currentMs + delayMs, callback });
return { token: id };
}
clearTimeout(timer: { token: unknown }): void {
if (typeof timer.token === "number") {
this.timers.delete(timer.token);
}
}
advance(ms: number): void {
this.currentMs += ms;
const dueTimers = [...this.timers.entries()]
.filter(([, timer]) => timer.dueMs <= this.currentMs)
.sort((left, right) => left[1].dueMs - right[1].dueMs);
for (const [id, timer] of dueTimers) {
if (this.timers.delete(id)) {
timer.callback();
}
}
}
}
describe("OmpSubagentCardTracker", () => {
test("folds lifecycle and progress into one throttled sub-agent detail", () => {
const scheduler = new ManualScheduler();
test("keeps the card a launch marker while the child runs", () => {
const emitted: string[] = [];
const tracker = new OmpSubagentCardTracker({
scheduler,
emitToolCall: (toolCallId) => {
emitted.push(toolCallId);
return true;
},
});
tracker.handleLifecycle(LIFECYCLE);
const emit = (toolCallId: string) => {
emitted.push(toolCallId);
return true;
};
const tracker = new OmpSubagentCardTracker();
tracker.handleLifecycle(LIFECYCLE, emit);
for (const payload of PROGRESS) {
tracker.handleProgress(payload);
tracker.handleProgress(payload, emit);
}
expect(emitted).toEqual([PARENT_TOOL_CALL_ID]);
const detailBeforeTrailing = tracker.detailFor(PARENT_TOOL_CALL_ID, {
type: "sub_agent",
subAgentType: "task",
description: "Task arg description wins",
log: "",
});
expect(detailBeforeTrailing).toEqual({
type: "sub_agent",
subAgentType: "task",
description: "Task arg description wins",
childSessionId: SESSION_FILE,
log: [
"EchoSubagent started",
"[bash] echo subagent-hi",
"[yield]",
"EchoSubagent completed",
].join("\n"),
});
scheduler.advance(500);
expect(emitted).toEqual([PARENT_TOOL_CALL_ID, PARENT_TOOL_CALL_ID]);
});
test("aggregates batch progress streams into one index-prefixed log", () => {
const scheduler = new ManualScheduler();
const emitted: string[] = [];
const tracker = new OmpSubagentCardTracker({
scheduler,
emitToolCall: (toolCallId) => {
emitted.push(toolCallId);
return true;
},
});
tracker.handleLifecycle({
id: "Explore",
agent: "task",
description: "Inspect files",
status: "started",
sessionFile: "/tmp/one.jsonl",
parentToolCallId: "task.batch-1",
index: 0,
});
scheduler.advance(600);
tracker.handleProgress({
index: 5,
agent: "task",
task: "Run tests",
parentToolCallId: "task.batch-1",
progress: {
id: "Test",
status: "running",
description: "Run tests",
recentTools: [{ tool: "bash", args: "npm test", endMs: 10 }],
},
sessionFile: "/tmp/two.jsonl",
});
expect(emitted).toEqual(["task.batch-1", "task.batch-1"]);
expect(
tracker.detailFor("task.batch-1", {
tracker.detailFor(PARENT_TOOL_CALL_ID, {
type: "sub_agent",
subAgentType: "batch",
actions: [],
subAgentType: "task",
description: "Task arg description wins",
log: "",
}),
).toEqual({
type: "sub_agent",
subAgentType: "batch",
description: "Inspect files",
childSessionId: "/tmp/one.jsonl",
log: "[1/6] Explore started\n[6/6] [bash] npm test",
actions: [],
subAgentType: "task",
description: "Task arg description wins",
childSessionId: SESSION_FILE,
log: "",
});
});
test("keeps dirty trailing state available for final detail and cancels it on cleanup", () => {
const scheduler = new ManualScheduler();
test("re-emits the card when a later event names the child", () => {
const emitted: string[] = [];
const tracker = new OmpSubagentCardTracker({
scheduler,
emitToolCall: (toolCallId) => {
emitted.push(toolCallId);
return true;
const emit = (toolCallId: string) => {
emitted.push(toolCallId);
return true;
};
const tracker = new OmpSubagentCardTracker();
tracker.handleLifecycle({ ...LIFECYCLE, description: undefined, sessionFile: undefined }, emit);
tracker.handleProgress(PROGRESS[0], emit);
expect(emitted).toEqual([PARENT_TOOL_CALL_ID, PARENT_TOOL_CALL_ID]);
expect(tracker.detailFor(PARENT_TOOL_CALL_ID, { type: "sub_agent", log: "" })).toEqual({
type: "sub_agent",
childSessionId: SESSION_FILE,
log: "",
});
tracker.delete(PARENT_TOOL_CALL_ID);
expect(tracker.detailFor(PARENT_TOOL_CALL_ID, { type: "sub_agent", log: "" })).toEqual({
type: "sub_agent",
log: "",
});
});
test("leaves the card alone when a later child of the same task starts", () => {
const emitted: string[] = [];
const emit = (toolCallId: string) => {
emitted.push(toolCallId);
return true;
};
const tracker = new OmpSubagentCardTracker();
tracker.handleLifecycle(LIFECYCLE, emit);
tracker.handleLifecycle(
{
...LIFECYCLE,
id: "Second",
index: 1,
description: "Second child",
sessionFile: "/s.jsonl",
},
emit,
);
expect(emitted).toEqual([PARENT_TOOL_CALL_ID]);
expect(tracker.detailFor(PARENT_TOOL_CALL_ID, { type: "sub_agent", log: "" })).toEqual({
type: "sub_agent",
description: "Run echo in subagent",
childSessionId: SESSION_FILE,
log: "",
});
tracker.handleLifecycle({
id: "Explore",
agent: "task",
description: "Inspect files",
status: "started",
parentToolCallId: "task-1",
index: 0,
});
scheduler.advance(100);
tracker.handleProgress({
index: 0,
agent: "task",
task: "Inspect files",
parentToolCallId: "task-1",
progress: {
id: "Explore",
status: "running",
recentOutput: ["found target file"],
},
});
expect(emitted).toEqual(["task-1"]);
expect(
tracker.detailFor("task-1", {
type: "sub_agent",
log: "",
}).log,
).toBe("Explore started\nfound target file");
tracker.delete("task-1");
scheduler.advance(500);
expect(emitted).toEqual(["task-1"]);
expect(
tracker.detailFor("task-1", {
type: "sub_agent",
log: "static",
}).log,
).toBe("static");
});
});
@@ -1,361 +1,106 @@
import type { ToolCallDetail } from "../../agent-sdk-types.js";
import type { OmpSubagentLifecyclePayload, OmpSubagentProgressPayload } from "./rpc-types.js";
export interface OmpSubagentCardTimer {
readonly token: unknown;
}
export interface OmpSubagentCardScheduler {
now(): number;
setTimeout(callback: () => void, delayMs: number): OmpSubagentCardTimer;
clearTimeout(timer: OmpSubagentCardTimer): void;
}
export interface OmpSubagentCardTrackerOptions {
scheduler?: OmpSubagentCardScheduler;
emitToolCall?: (toolCallId: string) => boolean;
}
interface OmpSubagentLogLine {
key: string;
text: string;
}
interface OmpSubagentCardItem {
interface OmpSubagentCardChild {
index: number;
description?: string;
agent?: string;
status?: "pending" | "running" | "completed" | "failed" | "aborted";
childSessionId?: string;
lines: OmpSubagentLogLine[];
lineKeys: Set<string>;
}
interface OmpSubagentCardState {
items: Map<number, OmpSubagentCardItem>;
emitToolCall: (toolCallId: string) => boolean;
lastEmitMs: number | null;
trailingTimer: OmpSubagentCardTimer | null;
dirty: boolean;
}
const MAX_LOG_LINES_PER_ITEM = 200;
const MAX_LOG_LINE_CHARS = 240;
const THROTTLE_INTERVAL_MS = 500;
export function createOmpSubagentCardScheduler(): OmpSubagentCardScheduler {
return {
now: () => Date.now(),
setTimeout: (callback, delayMs) => ({ token: setTimeout(callback, delayMs) }),
clearTimeout: (timer) => {
clearTimeout(timer.token as ReturnType<typeof setTimeout>);
},
};
}
/**
* Fills a task card's description and child session from OMP's sub-agent events. The card is a
* launch marker that names the task's first child; the children's activity belongs to their own
* timelines.
*/
export class OmpSubagentCardTracker {
private readonly scheduler: OmpSubagentCardScheduler;
private readonly emitToolCall: (toolCallId: string) => boolean;
private readonly states = new Map<string, OmpSubagentCardState>();
constructor(options: OmpSubagentCardTrackerOptions) {
this.scheduler = options.scheduler ?? createOmpSubagentCardScheduler();
this.emitToolCall = options.emitToolCall ?? (() => false);
}
private readonly firstChildren = new Map<string, OmpSubagentCardChild>();
handleLifecycle(
payload: OmpSubagentLifecyclePayload,
emitToolCall: (toolCallId: string) => boolean = this.emitToolCall,
emitToolCall: (toolCallId: string) => boolean,
): void {
const parentToolCallId = readTrimmedString(payload.parentToolCallId);
if (!parentToolCallId) {
return;
}
this.stateFor(parentToolCallId).emitToolCall = emitToolCall;
const item = this.upsertItem(parentToolCallId, {
index: payload.index,
agent: payload.agent,
description: payload.description,
status: payload.status === "started" ? "running" : payload.status,
childSessionId: payload.sessionFile,
});
this.appendLine(
item,
`lifecycle:${payload.status}:${payload.id}`,
`${payload.id} ${payload.status}`,
this.update(
payload.parentToolCallId,
{
index: payload.index,
description: payload.description,
childSessionId: payload.sessionFile,
},
emitToolCall,
);
this.requestEmit(parentToolCallId);
}
handleProgress(
payload: OmpSubagentProgressPayload,
emitToolCall: (toolCallId: string) => boolean = this.emitToolCall,
emitToolCall: (toolCallId: string) => boolean,
): void {
const parentToolCallId = readTrimmedString(payload.parentToolCallId);
if (!parentToolCallId) {
return;
}
this.stateFor(parentToolCallId).emitToolCall = emitToolCall;
const item = this.upsertItem(parentToolCallId, {
index: payload.index,
agent: payload.agent,
description: payload.progress.description,
status: payload.progress.status,
childSessionId: payload.sessionFile,
});
const currentToolLine = summarizeTool(readRecord(payload.progress.currentTool));
if (currentToolLine) {
this.appendLine(item, `current:${currentToolLine.key}`, currentToolLine.text);
}
for (const tool of readRecordArray(payload.progress.recentTools)) {
const line = summarizeTool(tool);
if (line) {
this.appendLine(item, `tool:${line.key}`, line.text);
}
}
for (const output of readOutputLines(payload.progress.recentOutput)) {
this.appendLine(item, `output:${output}`, output);
}
if (payload.progress.status !== "running") {
this.appendLine(
item,
`status:${payload.progress.status}:${payload.progress.id}`,
`${payload.progress.id} ${payload.progress.status}`,
);
}
this.requestEmit(parentToolCallId);
this.update(
payload.parentToolCallId,
{
index: payload.index,
description: payload.progress.description,
childSessionId: payload.sessionFile,
},
emitToolCall,
);
}
detailFor(toolCallId: string, baseDetail: ToolCallDetail): ToolCallDetail {
if (baseDetail.type !== "sub_agent") {
return baseDetail;
}
const state = this.states.get(toolCallId);
if (!state) {
const first = this.firstChildren.get(toolCallId);
if (!first) {
return baseDetail;
}
const items = [...state.items.values()].sort((left, right) => left.index - right.index);
const firstItem = items[0];
if (!firstItem) {
return baseDetail;
}
const detail: ToolCallDetail = {
type: "sub_agent",
...(baseDetail.subAgentType ? { subAgentType: baseDetail.subAgentType } : {}),
...((baseDetail.description ?? firstItem.description)
? { description: baseDetail.description ?? firstItem.description }
: {}),
...((firstItem.childSessionId ?? baseDetail.childSessionId)
? { childSessionId: firstItem.childSessionId ?? baseDetail.childSessionId }
: {}),
log: buildLog(items, baseDetail.log),
const description = baseDetail.description ?? first.description;
const childSessionId = first.childSessionId ?? baseDetail.childSessionId;
return {
...baseDetail,
...(description ? { description } : {}),
...(childSessionId ? { childSessionId } : {}),
};
return baseDetail.actions ? { ...detail, actions: baseDetail.actions } : detail;
}
flush(toolCallId: string): void {
const state = this.states.get(toolCallId);
if (!state || !state.dirty) {
return;
}
this.emitNow(toolCallId, state);
}
delete(toolCallId: string): void {
const state = this.states.get(toolCallId);
if (state?.trailingTimer) {
this.scheduler.clearTimeout(state.trailingTimer);
}
this.states.delete(toolCallId);
this.firstChildren.delete(toolCallId);
}
clear(): void {
for (const toolCallId of this.states.keys()) {
this.delete(toolCallId);
}
this.firstChildren.clear();
}
private upsertItem(
parentToolCallId: string,
input: {
index: number;
agent?: string;
status?: "pending" | "running" | "completed" | "failed" | "aborted";
description?: string;
childSessionId?: string;
},
): OmpSubagentCardItem {
const state = this.stateFor(parentToolCallId);
const existing = state.items.get(input.index);
if (existing) {
existing.agent = readTrimmedString(input.agent) ?? existing.agent;
existing.status = input.status ?? existing.status;
existing.description = readTrimmedString(input.description) ?? existing.description;
existing.childSessionId = readTrimmedString(input.childSessionId) ?? existing.childSessionId;
return existing;
private update(
parentToolCallId: string | undefined,
input: { index: number; description?: string; childSessionId?: string },
emitToolCall: (toolCallId: string) => boolean,
): void {
const toolCallId = readTrimmedString(parentToolCallId);
if (!toolCallId) {
return;
}
const item: OmpSubagentCardItem = {
const first = this.firstChildren.get(toolCallId);
if (first && input.index > first.index) {
return;
}
const existing = first?.index === input.index ? first : undefined;
const next: OmpSubagentCardChild = {
index: input.index,
lines: [],
lineKeys: new Set<string>(),
...(readTrimmedString(input.agent) ? { agent: readTrimmedString(input.agent) } : {}),
...(input.status ? { status: input.status } : {}),
...(readTrimmedString(input.description)
? { description: readTrimmedString(input.description) }
: {}),
...(readTrimmedString(input.childSessionId)
? { childSessionId: readTrimmedString(input.childSessionId) }
: {}),
description: readTrimmedString(input.description) ?? existing?.description,
childSessionId: readTrimmedString(input.childSessionId) ?? existing?.childSessionId,
};
state.items.set(input.index, item);
return item;
}
private stateFor(parentToolCallId: string): OmpSubagentCardState {
const existing = this.states.get(parentToolCallId);
if (existing) {
return existing;
}
const state: OmpSubagentCardState = {
items: new Map(),
emitToolCall: this.emitToolCall,
lastEmitMs: null,
trailingTimer: null,
dirty: false,
};
this.states.set(parentToolCallId, state);
return state;
}
private appendLine(item: OmpSubagentCardItem, key: string, text: string): void {
const normalizedText = normalizeLine(text);
if (!normalizedText || item.lineKeys.has(key)) {
if (
existing &&
existing.description === next.description &&
existing.childSessionId === next.childSessionId
) {
return;
}
item.lines.push({ key, text: normalizedText });
item.lineKeys.add(key);
while (item.lines.length > MAX_LOG_LINES_PER_ITEM) {
const removed = item.lines.shift();
if (removed) {
item.lineKeys.delete(removed.key);
}
}
this.firstChildren.set(toolCallId, next);
emitToolCall(toolCallId);
}
private requestEmit(toolCallId: string): void {
const state = this.stateFor(toolCallId);
const now = this.scheduler.now();
const elapsedMs = state.lastEmitMs === null ? THROTTLE_INTERVAL_MS : now - state.lastEmitMs;
if (elapsedMs >= THROTTLE_INTERVAL_MS) {
this.emitNow(toolCallId, state);
return;
}
state.dirty = true;
if (state.trailingTimer) {
return;
}
state.trailingTimer = this.scheduler.setTimeout(() => {
state.trailingTimer = null;
if (state.dirty) {
this.emitNow(toolCallId, state);
}
}, THROTTLE_INTERVAL_MS - elapsedMs);
}
private emitNow(toolCallId: string, state: OmpSubagentCardState): void {
if (state.trailingTimer) {
this.scheduler.clearTimeout(state.trailingTimer);
state.trailingTimer = null;
}
state.dirty = false;
state.lastEmitMs = this.scheduler.now();
state.emitToolCall(toolCallId);
}
}
function buildLog(items: OmpSubagentCardItem[], fallback: string): string {
const itemCount = Math.max(...items.map((item) => item.index)) + 1;
const lines = items.flatMap((item) => {
const prefix = itemCount > 1 ? `[${item.index + 1}/${itemCount}] ` : "";
return item.lines.map((line) => `${prefix}${line.text}`);
});
return lines.length > 0 ? lines.join("\n") : fallback;
}
function summarizeTool(tool: Record<string, unknown> | null): OmpSubagentLogLine | null {
if (!tool) {
return null;
}
const toolName = readTrimmedString(tool.tool) ?? readTrimmedString(tool.name);
if (!toolName) {
return null;
}
const args = readTrimmedString(tool.args) ?? summarizeUnknown(tool.input);
const endMs = typeof tool.endMs === "number" ? `:${tool.endMs}` : "";
return {
key: `${toolName}:${args ?? ""}${endMs}`,
text: args ? `[${toolName}] ${args}` : `[${toolName}]`,
};
}
function readOutputLines(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value.flatMap((item) => {
const text =
readTrimmedString(item) ??
(isRecord(item)
? (readTrimmedString(item.text) ??
readTrimmedString(item.output) ??
readTrimmedString(item.content))
: undefined);
return text ? [text] : [];
});
}
function readRecord(value: unknown): Record<string, unknown> | null {
return isRecord(value) ? value : null;
}
function readRecordArray(value: unknown): Record<string, unknown>[] {
return Array.isArray(value) ? value.filter(isRecord) : [];
}
function summarizeUnknown(value: unknown): string | undefined {
if (value === undefined || value === null) {
return undefined;
}
if (typeof value === "string") {
return readTrimmedString(value);
}
const json = JSON.stringify(value);
return typeof json === "string" ? normalizeLine(json) : undefined;
}
function normalizeLine(value: string): string | undefined {
const normalized = value.trim().replace(/\s+/g, " ");
if (!normalized) {
return undefined;
}
if (normalized.length <= MAX_LOG_LINE_CHARS) {
return normalized;
}
return `${normalized.slice(0, MAX_LOG_LINE_CHARS)}...`;
}
function readTrimmedString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -85,7 +85,7 @@ describe("OMP tool call mapper", () => {
expect(mapOmpToolDetail(parseToolArgs("todo", { op: "view" }), null)).toBeNull();
});
test("uses task result text and transcript path as the best static replay detail", () => {
test("takes the child session from the task transcript path", () => {
expect(
mapOmpToolDetail(
parseToolArgs("task", {
@@ -106,7 +106,7 @@ describe("OMP tool call mapper", () => {
subAgentType: "explore",
description: "Inspect the target files",
childSessionId: "/tmp/omp-task-static/Explore.jsonl",
log: "done\ntranscript: /tmp/omp-task-static/Explore.jsonl",
log: "",
});
});
@@ -179,7 +179,6 @@ const OPAQUE_TEXT_TOOLS = new Set([
function mapOmpTaskDetail(args: unknown, result: OmpToolResult): ToolCallDetail {
const argRecord = isRecord(args) ? args : {};
const firstTask = Array.isArray(argRecord.tasks) ? argRecord.tasks.find(isRecord) : undefined;
const resultText = extractTextFromToolResult(result);
const childSessionId = readChildSessionId(result);
return {
type: "sub_agent",
@@ -200,7 +199,8 @@ function mapOmpTaskDetail(args: unknown, result: OmpToolResult): ToolCallDetail
argRecord.assignment,
),
...(childSessionId ? { childSessionId } : {}),
log: resultText?.trim() ?? "",
// COMPAT(subagent-log): remove after 2027-04-30.
log: "",
};
}
@@ -4846,15 +4846,6 @@ class OpenCodeAgentSession implements AgentSession {
...item,
status: "failed",
error,
detail:
item.detail.type === "sub_agent"
? {
...item.detail,
log: [item.detail.log, error.message]
.filter((entry) => entry.trim().length > 0)
.join("\n"),
}
: item.detail,
},
},
turnId,
@@ -153,21 +153,6 @@ const OpencodeGrepOutputSchema = z
])
.nullable();
function formatLogEntry(value: unknown): string | undefined {
const outputText = readOutputText(value);
if (outputText) {
return outputText;
}
if (value === null || value === undefined) {
return undefined;
}
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
function extractOpenCodeTaskSessionId(value: unknown): string | undefined {
const text = readOutputText(value);
if (text) {
@@ -195,7 +180,6 @@ function extractOpenCodeTaskSessionId(value: unknown): string | undefined {
function deriveOpencodeTaskDetail(
input: unknown,
output: unknown,
error: unknown,
metadata: Record<string, unknown> | undefined,
): ToolCallDetail | null {
if (!isRecord(input)) {
@@ -208,7 +192,6 @@ function deriveOpencodeTaskDetail(
return null;
}
const log = [formatLogEntry(output), formatLogEntry(error)].filter((entry) => entry).join("\n");
const childSessionId =
normalizeSubAgentText(metadata?.sessionId) ?? extractOpenCodeTaskSessionId(output);
return {
@@ -216,7 +199,8 @@ function deriveOpencodeTaskDetail(
...(subAgentType ? { subAgentType } : {}),
...(description ? { description } : {}),
...(childSessionId ? { childSessionId } : {}),
log,
// COMPAT(subagent-log): remove after 2027-04-30.
log: "",
actions: [],
};
}
@@ -373,11 +357,10 @@ export function deriveOpencodeToolDetail(
toolName: string,
input: unknown,
output: unknown,
error: unknown = null,
metadata?: Record<string, unknown>,
): ToolCallDetail {
if (toolName.trim().toLowerCase() === "task") {
const taskDetail = deriveOpencodeTaskDetail(input, output, error, metadata);
const taskDetail = deriveOpencodeTaskDetail(input, output, metadata);
if (taskDetail) {
return taskDetail;
}
@@ -460,7 +460,7 @@ describe("opencode tool-call mapper", () => {
});
});
it("maps completed task calls with final output log to sub_agent detail", () => {
it("maps completed task calls to sub_agent detail", () => {
const item = expectMapped(
mapOpencodeToolCall({
toolName: "task",
@@ -480,7 +480,7 @@ describe("opencode tool-call mapper", () => {
type: "sub_agent",
subAgentType: "explore",
description: "Explore agent-tools codebase",
log: "Found the CLI entrypoint and provider registry.",
log: "",
actions: [],
});
});
@@ -505,12 +505,12 @@ describe("opencode tool-call mapper", () => {
subAgentType: "explore",
description: "Explore current directory",
childSessionId: "ses_2268db431ffe299vL1bbot8R7Z",
log: '<task id="ses_2268db431ffe299vL1bbot8R7Z">done</task>',
log: "",
actions: [],
});
});
it("maps aborted task calls with preserved error log to sub_agent detail", () => {
it("maps aborted task calls to sub_agent detail", () => {
const item = expectMapped(
mapOpencodeToolCall({
toolName: "task",
@@ -531,7 +531,7 @@ describe("opencode tool-call mapper", () => {
type: "sub_agent",
subAgentType: "explore",
description: "Explore agent-tools codebase",
log: "Tool execution aborted",
log: "",
actions: [],
});
});
@@ -43,7 +43,7 @@ export function mapOpencodeToolCall(params: OpencodeToolCallParams): ToolCallTim
const error = raw.error ?? null;
const rawStatus = typeof raw.status === "string" ? raw.status : undefined;
const status = normalizeToolCallStatus(rawStatus, error, output);
const detail = deriveOpencodeToolDetail(name, input, output, error, raw.metadata);
const detail = deriveOpencodeToolDetail(name, input, output, raw.metadata);
if (status === "failed") {
return {
@@ -1,5 +1,4 @@
import { z } from "zod";
import { extractTextFromToolResult } from "../../tool-call-mapper.js";
import type { PiChildSessionFile, PiExtension } from "../contract.js";
import type { ProviderSubagentInputEvent } from "../../../../provider-subagents/store.js";
@@ -99,7 +98,8 @@ export const edxethPiSubagents: PiExtension = {
detail: {
type: "sub_agent",
description,
log: extractTextFromToolResult(call.result)?.trim() ?? "",
// COMPAT(subagent-log): remove after 2027-04-30.
log: "",
},
subagents,
childSessions,
@@ -130,6 +130,18 @@ describe("@gotgenes/pi-subagents adapter", () => {
.filter((event) => event.event.type === "upsert")
.map((event) => (event.event.type === "upsert" ? event.event.status : null)),
).toEqual(["running", "completed"]);
expect(
events.flatMap((event) => (event.event.type === "timeline" ? [event.event] : [])),
).toEqual([
{
type: "timeline",
id: "call_502323",
item: {
type: "assistant_message",
text: "Agent completed in 4.7s (1 tool uses, 9.0k token).\nAgent ID: 848583f5-6749-4a1\n\nThe workspace is empty.",
},
},
]);
});
test("correlates a background notification and result collection", async () => {
const source = readSubagentFixture(new URL("./fixtures/background.json", import.meta.url));
@@ -64,7 +64,8 @@ export const gotgenesPiSubagents: PiExtension = {
type: "sub_agent" as const,
subAgentType: title,
description,
log: extractTextFromToolResult(call.result)?.trim() ?? "",
// COMPAT(subagent-log): remove after 2027-04-30.
log: "",
};
if (call.status === "running")
return {
@@ -92,6 +93,10 @@ export const gotgenesPiSubagents: PiExtension = {
outputFileFromToolResult(call.result) ?? pendingFilesByAgent.get(details.data.agentId);
pendingFilesByAgent.delete(details.data.agentId);
const childSessions = childSession(call.callId, file);
const childStatus = status(details.data.status);
// Without a session file, as on replay, the child's answer is the tool result.
const answer =
!file && childStatus !== "running" ? extractTextFromToolResult(call.result)?.trim() : "";
return {
detail,
subagents: [
@@ -101,8 +106,17 @@ export const gotgenesPiSubagents: PiExtension = {
title,
description,
toolCallId: call.callId,
status: status(details.data.status),
status: childStatus,
},
...(answer
? [
{
type: "timeline" as const,
id: call.callId,
item: { type: "assistant_message" as const, text: answer },
},
]
: []),
],
childSessions,
};
@@ -118,7 +132,8 @@ export const gotgenesPiSubagents: PiExtension = {
const detail = {
type: "sub_agent" as const,
description: details.success ? details.data.description : undefined,
log: extractTextFromToolResult(call.result)?.trim() ?? "",
// COMPAT(subagent-log): remove after 2027-04-30.
log: "",
};
if (call.status === "running" || !details.success) return { detail };
const file = details.data.transcriptPath;
@@ -210,7 +210,7 @@ describe("Pi extension host", () => {
type: "sub_agent",
subAgentType: "scout",
description: "Inspect files",
log: "Found two files",
log: "",
},
}),
);

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