mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-29 12:42:57 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c4bb45761 | ||
|
|
ac6e4e9f96 | ||
|
|
c4b4516890 | ||
|
|
75f58c4fe7 | ||
|
|
fb69bf3314 | ||
|
|
f7eef9dd0c | ||
|
|
7d74ae5d6c | ||
|
|
61841d1ed5 | ||
|
|
7c88a48be4 | ||
|
|
3196260bd4 |
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -95,4 +95,8 @@ Callout crops are in native capture pixels. To find them, overlay a 100 px grid
|
||||
|
||||
6. Copy the captures into `raw/iphone/` or `raw/ipad/` under the names `render.mjs` uses, render, and check the preview.
|
||||
|
||||
## Publish
|
||||
|
||||
The stable release uploads these exports to the App Store and Google Play, and F-Droid reads the Google ones from the repo. See [Store screenshots](../../docs/release.md#store-screenshots). The Android metadata holds links to the Google exports, and every render rewrites them, so commit the links with the exports.
|
||||
|
||||
Dimensions are checked against [Apple's screenshot specifications](https://developer.apple.com/help/app-store-connect/reference/app-information/screenshot-specifications/) and [Google Play's preview asset requirements](https://support.google.com/googleplay/android-developer/answer/9866151?hl=en).
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
// elsewhere point SF_FONT_DIR at a folder holding SFNS.ttf copied from a Mac.
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
@@ -23,6 +23,7 @@ const root = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repo = path.resolve(root, "../..");
|
||||
const raw = (device, name) => path.join(root, "raw", device, `${name}.png`);
|
||||
const output = path.join(root, "output");
|
||||
const androidImages = path.join(repo, "fastlane/metadata/android/en-US/images");
|
||||
|
||||
// Native captures per device: iPhone 16 Pro Max at 3x and iPad Pro 13-inch at
|
||||
// 2x. The frame's screen radius and Dynamic Island are in capture pixels.
|
||||
@@ -116,6 +117,7 @@ const STORES = {
|
||||
},
|
||||
google: {
|
||||
device: "iphone",
|
||||
androidScreenshots: "phoneScreenshots",
|
||||
width: 1440,
|
||||
height: 2560,
|
||||
textTop: 150,
|
||||
@@ -134,6 +136,7 @@ const STORES = {
|
||||
},
|
||||
"google-tablet": {
|
||||
device: "ipad",
|
||||
androidScreenshots: "tenInchScreenshots",
|
||||
width: 1800,
|
||||
height: 3200,
|
||||
textTop: 200,
|
||||
@@ -352,6 +355,18 @@ async function expectLines(view, selector, expected, label) {
|
||||
if (lines !== expected) throw new Error(`${label} wraps to ${lines} lines, expected ${expected}`);
|
||||
}
|
||||
|
||||
// Google Play and F-Droid read their screenshots from the Android metadata,
|
||||
// which holds links to these renders. Rewriting the links on every render keeps
|
||||
// added, removed and renamed panels in step with the App Store.
|
||||
async function linkAndroidScreenshots(folder, files) {
|
||||
const dir = path.join(androidImages, folder);
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
await mkdir(dir, { recursive: true });
|
||||
for (const [index, file] of files.entries()) {
|
||||
await symlink(path.relative(dir, file), path.join(dir, `${index + 1}.png`));
|
||||
}
|
||||
}
|
||||
|
||||
async function renderPreview(rows) {
|
||||
const tile = 440;
|
||||
const gap = 40;
|
||||
@@ -387,7 +402,9 @@ try {
|
||||
const marks = await loadAgentMarks(work);
|
||||
const rows = [];
|
||||
for (const [name, store] of Object.entries(STORES)) {
|
||||
rows.push(await renderStore(browser, name, store, font, marks, work));
|
||||
const files = await renderStore(browser, name, store, font, marks, work);
|
||||
if (store.androidScreenshots) await linkAndroidScreenshots(store.androidScreenshots, files);
|
||||
rows.push(files);
|
||||
}
|
||||
await renderPreview(rows);
|
||||
} finally {
|
||||
|
||||
+4
-1
@@ -215,7 +215,8 @@ fastlane/metadata/android/
|
||||
│ ├── images/
|
||||
│ │ ├── icon.png (512x512)
|
||||
│ │ ├── featureGraphic.png (1024x500)
|
||||
│ │ └── phoneScreenshots/ (1.png, 2.png, ...)
|
||||
│ │ ├── phoneScreenshots/ (1.png, 2.png, ... linked to the store renders)
|
||||
│ │ └── tenInchScreenshots/ (1.png, 2.png, ... linked to the store renders)
|
||||
│ └── changelogs/ (generated — see below)
|
||||
├── ja/
|
||||
└── zh-CN/
|
||||
@@ -223,6 +224,8 @@ fastlane/metadata/android/
|
||||
|
||||
Locale directories generally match `packages/app/src/i18n/locales.ts`, but note that `en` becomes `en-US`.
|
||||
|
||||
Google Play reads its screenshots from the same folders; see [Store screenshots](release.md#store-screenshots).
|
||||
|
||||
F-Droid changelogs are generated from `CHANGELOG.md`. Run `npm run fdroid:changelogs`; `npm run fdroid:changelogs:check` verifies without writing. It is wired into the npm `version` lifecycle, so a release picks it up automatically and `git add -A` stages the result.
|
||||
|
||||
One changelog must be generated per-ABI-split, so each version will create **four** identical version-coded entries. F-Droid caps changelogs at 500 characters, so the generator strips some content and adds a link to the full notes.
|
||||
|
||||
+9
-7
@@ -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
|
||||
|
||||
+147
-74
@@ -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
|
||||
@@ -305,8 +363,8 @@ macOS 13 maps to Darwin 22. The two values use different version domains; do not
|
||||
|
||||
iOS and Android store builds are not in `.github/workflows`. They are triggered by the EAS GitHub app the moment the `v*` tag is pushed:
|
||||
|
||||
- **Android (Play Store)** — EAS builds with profile `production` and auto-submits to the Play Store via `eas submit` (EAS-managed credentials, no Fastlane).
|
||||
- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads once through a `testflight` job, distributes the build to the `Paseo Beta` external group with Beta App Review submission, and a Fastlane lane submits that same build for App Store review.
|
||||
- **Android (Play Store)** — EAS builds with profile `production` and auto-submits to the Play Store via `eas submit` (EAS-managed credentials). A Fastlane lane then replaces the store screenshots.
|
||||
- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads once through a `testflight` job, distributes the build to the `Paseo Beta` external group with Beta App Review submission, and a Fastlane lane uploads the store screenshots and submits that same build for App Store review.
|
||||
- **Android APK (GitHub Release asset)** — separate, via `.github/workflows/android-apk-release.yml`. This is the only Android-related workflow that lives in this repo.
|
||||
|
||||
EAS uses the local app version source. `packages/app/app.config.js` derives the native version from the package version. Android `versionCode` is `major * 1_000_000 + minor * 1_000 + patch`. iOS reserves 1,000 build slots per app version: beta `N` uses slot `N`, and stable uses slot `999`. For example, `0.2.6-beta.2` appears in App Store Connect as version `0.2.6` build `2006002`; stable uses build `2006999`. Rebuilding the same tag produces the same native build number; if a store has already accepted a binary and you need a different binary, cut the next beta or patch instead of relying on EAS remote auto-increment.
|
||||
@@ -317,6 +375,17 @@ Stable iOS releases use one binary for two Apple review paths: Beta App Review f
|
||||
|
||||
There is no mobile-release workflow under `.github/workflows`. The EAS GitHub app reads the workflows under `packages/app/.eas/workflows` and handles tag triggering directly.
|
||||
|
||||
### Store screenshots
|
||||
|
||||
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.
|
||||
- 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**.
|
||||
|
||||
### Watching mobile builds from the terminal
|
||||
|
||||
Use the EAS CLI from `packages/app/`:
|
||||
@@ -359,6 +428,7 @@ For the `Release Mobile` EAS workflow, these jobs must pass:
|
||||
- `submit_ios_for_review` — iOS build submitted for App Store review via Fastlane
|
||||
- `build_android` — Android store binary built
|
||||
- `submit_android` — Android binary submitted to the Play Store
|
||||
- `upload_android_screenshots` — Google Play screenshots replaced from the repo
|
||||
|
||||
Do not treat `build_ios: SUCCESS` or `distribute_ios: SUCCESS` as a completed iOS release. `submit_ios_for_review: FAILURE` means the iOS release is blocked even if the build is visible in TestFlight.
|
||||
|
||||
@@ -477,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
|
||||
@@ -620,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
|
||||
|
||||
@@ -652,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`
|
||||
@@ -667,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`
|
||||
@@ -696,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
|
||||
|
||||
@@ -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 @@
|
||||
../../../../../../packages/website/public/phone-1.png
|
||||
../../../../../../assets/store-screenshots/output/google/01-sidebar.png
|
||||
@@ -1 +1 @@
|
||||
../../../../../../packages/website/public/phone-2.png
|
||||
../../../../../../assets/store-screenshots/output/google/02-chat.png
|
||||
@@ -1 +1 @@
|
||||
../../../../../../packages/website/public/phone-3.png
|
||||
../../../../../../assets/store-screenshots/output/google/03-plugins.png
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../assets/store-screenshots/output/google/04-review.png
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../assets/store-screenshots/output/google/05-terminal.png
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../assets/store-screenshots/output/google-tablet/01-sidebar.png
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../assets/store-screenshots/output/google-tablet/02-chat.png
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../assets/store-screenshots/output/google-tablet/03-plugins.png
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../assets/store-screenshots/output/google-tablet/04-review.png
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../assets/store-screenshots/output/google-tablet/05-terminal.png
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-AmMp/4ugSUKXTL6H0oJaGJMztVGiYCPXeWteQaaaguw=
|
||||
sha256-6C4Rjh0mRVrC9akBVGK0NZaCaLJhwKObT7HrgPmcvyw=
|
||||
|
||||
Generated
+28
-28
@@ -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
@@ -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": [
|
||||
|
||||
@@ -42,6 +42,20 @@ jobs:
|
||||
build_id: ${{ needs.build_android.outputs.build_id }}
|
||||
profile: production
|
||||
|
||||
upload_android_screenshots:
|
||||
name: Upload Google Play screenshots
|
||||
needs: [build_android, submit_android]
|
||||
environment: production
|
||||
runs_on: macos-medium
|
||||
steps:
|
||||
- uses: eas/checkout
|
||||
- name: Install fastlane
|
||||
run: bundle install
|
||||
- name: Upload screenshots
|
||||
run: |
|
||||
export ANDROID_VERSION_CODE="${{ needs.build_android.outputs.app_build_version }}"
|
||||
bundle exec fastlane android upload_screenshots
|
||||
|
||||
submit_ios_for_review:
|
||||
name: Submit iOS for App Store review
|
||||
needs: [build_ios, distribute_ios]
|
||||
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,47 @@
|
||||
require "fileutils"
|
||||
require "tmpdir"
|
||||
|
||||
default_platform(:ios)
|
||||
|
||||
APP_IDENTIFIER = "sh.paseo"
|
||||
|
||||
# Store screenshots are rendered into assets/store-screenshots/output; see the
|
||||
# README there. Google Play and F-Droid both read fastlane/metadata/android at
|
||||
# the repo root, whose screenshot files link to those renders.
|
||||
REPO_ROOT = File.expand_path("../../..", __dir__)
|
||||
STORE_SCREENSHOTS = File.join(REPO_ROOT, "assets/store-screenshots/output")
|
||||
ANDROID_METADATA = File.join(REPO_ROOT, "fastlane/metadata/android")
|
||||
|
||||
# deliver reads screenshots from one folder per language and picks the device
|
||||
# slot from each image's size, so both Apple sets go into en-US together.
|
||||
def stage_app_store_screenshots
|
||||
root = Dir.mktmpdir("app-store-screenshots")
|
||||
locale = File.join(root, "en-US")
|
||||
FileUtils.mkdir_p(locale)
|
||||
{ "apple" => "iphone", "apple-ipad" => "ipad" }.each do |set, device|
|
||||
Dir.glob(File.join(STORE_SCREENSHOTS, set, "*.png")).sort.each do |file|
|
||||
FileUtils.cp(file, File.join(locale, "#{device}-#{File.basename(file)}"))
|
||||
end
|
||||
end
|
||||
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
|
||||
@@ -87,7 +127,8 @@ platform :ios do
|
||||
},
|
||||
force: true,
|
||||
skip_binary_upload: true,
|
||||
skip_screenshots: true,
|
||||
screenshots_path: stage_app_store_screenshots,
|
||||
overwrite_screenshots: true,
|
||||
precheck_include_in_app_purchases: false,
|
||||
submission_information: {
|
||||
add_id_info_uses_idfa: false,
|
||||
@@ -96,3 +137,25 @@ platform :ios do
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
platform :android do
|
||||
desc "Replace the Google Play store screenshots with the ones in the repo"
|
||||
lane :upload_screenshots do
|
||||
# supply attaches listing changes to a release on the production track.
|
||||
# The release workflow passes the version code it just submitted; without
|
||||
# it, supply uses the track's only release.
|
||||
version_code = ENV["ANDROID_VERSION_CODE"].to_s.strip
|
||||
supply(
|
||||
package_name: APP_IDENTIFIER,
|
||||
json_key: ENV.fetch("GOOGLE_PLAY_JSON_KEY"),
|
||||
metadata_path: stage_google_play_screenshots,
|
||||
version_code: version_code.empty? ? nil : version_code.to_i,
|
||||
skip_upload_apk: true,
|
||||
skip_upload_aab: true,
|
||||
skip_upload_metadata: true,
|
||||
skip_upload_changelogs: true,
|
||||
skip_upload_images: true,
|
||||
sync_image_upload: true,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.14.0-beta.2",
|
||||
"version": "0.14.0",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactElement,
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.14.0-beta.2",
|
||||
"version": "0.14.0",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map",
|
||||
|
||||
@@ -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,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,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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.14.0-beta.2",
|
||||
"version": "0.14.0",
|
||||
"description": "Paseo backend server",
|
||||
"files": [
|
||||
"dist/server",
|
||||
@@ -107,11 +107,11 @@
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.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 });
|
||||
}
|
||||
@@ -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 = {
|
||||
@@ -4947,6 +4949,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([
|
||||
|
||||
@@ -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(
|
||||
@@ -3997,9 +3973,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 +4031,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);
|
||||
|
||||
+53
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
{"type": "session", "version": 3, "id": "e56a0006-f421-45be-9aef-e4019db1cf24", "timestamp": "2026-10-21T13:05:57.384Z", "cwd": "/tmp/paseo-edxeth-fixture/lean/workspace", "name": "[marker] Fixture marker verification", "parentSession": "/tmp/paseo-edxeth-fixture/lean/sessions/2026-10-21T13-05-53-085Z_01a15e91-73bd-7282-82c6-d3974711a941.jsonl"}
|
||||
{"type": "custom", "customType": "pi-subagents_launch_metadata", "data": {"version": 1, "timestamp": "2026-10-21T13:05:57.385Z", "name": "fixture-marker", "title": "Fixture marker verification", "sessionTitle": "[marker] Fixture marker verification", "agent": "marker", "mode": "background", "sessionMode": "lineage-only", "autoExit": true, "parentClosePolicy": "terminate", "reportContextUsage": true, "async": true, "model": "openai-codex/gpt-5.6-sol", "thinking": "high", "modelRef": "openai-codex/gpt-5.6-sol:high", "definitionModel": "openai-codex/gpt-5.6-sol", "definitionThinking": "high", "allowModelOverride": true, "modelSource": "agent", "tools": "read", "denyTools": ["subagent", "subagent_resume", "subagent_kill"], "spawnableAgents": [], "spawnBudget": null, "extensions": [], "noContextFiles": false, "inheritAppendSystem": false, "noSession": false, "trustProject": false, "agentConfigDir": "/tmp/paseo-edxeth-fixture/lean/agent", "cwd": "/tmp/paseo-edxeth-fixture/lean/workspace", "boundarySystemPrompt": false}, "id": "57355eeb", "parentId": null, "timestamp": "2026-10-21T13:05:57.386Z"}
|
||||
{"type": "session_info", "id": "847415e4", "parentId": "57355eeb", "timestamp": "2026-10-21T13:05:57.566Z", "name": "[marker] Fixture marker verification"}
|
||||
{"type": "model_change", "id": "d4ad59be", "parentId": "847415e4", "timestamp": "2026-10-21T13:05:57.672Z", "provider": "openai-codex", "modelId": "gpt-5.6-sol"}
|
||||
{"type": "thinking_level_change", "id": "7f6bf9d2", "parentId": "d4ad59be", "timestamp": "2026-10-21T13:05:57.672Z", "thinkingLevel": "high"}
|
||||
{"type": "message", "id": "737b762e", "parentId": "6b436446", "timestamp": "2026-10-21T13:05:57.679Z", "message": {"role": "user", "content": [{"type": "text", "text": "<file name=\"/home/pi-user/.pi/history/workspace/artifacts/01a15e91-73bd-7282-82c6-d3974711a941/context/fixture-marker-2026-10-21T13-05-57.md\">\n\n\nReturn only the exact marker supplied by the user. Do not use tools.\n\nComplete your task autonomously.\n\nReturn only PASEO_EDXETH_LEAN_OK\n\nYour FINAL assistant message should summarize what you accomplished.\n</file>\n"}], "timestamp": 1792587957678}}
|
||||
{"type": "message", "id": "100b110d", "parentId": "737b762e", "timestamp": "2026-10-21T13:06:02.050Z", "message": {"role": "assistant", "content": [{"type": "text", "text": "PASEO_EDXETH_LEAN_OK"}], "api": "openai-codex-responses", "provider": "openai-codex", "model": "gpt-5.6-sol", "usage": {"input": 1713, "output": 13, "cacheRead": 0, "cacheWrite": 0, "reasoning": 0, "totalTokens": 1726, "cost": {"input": 0.006852, "output": 0.00026000000000000003, "cacheRead": 0, "cacheWrite": 0, "total": 0.007111999999999999}}, "stopReason": "stop", "timestamp": 1792587957684, "responseId": "resp_0bc8bfaee079148a016ad8b8b7edb08191924676ed8dcfd447", "rawStopReason": "completed"}}
|
||||
{"type": "custom", "customType": "pi-subagent-completion", "data": {"reason": "normal"}, "id": "80bbd3bb", "parentId": "100b110d", "timestamp": "2026-10-21T13:06:02.054Z"}
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
{
|
||||
"provenance": {
|
||||
"package": "Zeno2019/pi-subagents-lean",
|
||||
"version": "2.9.2-lean.1",
|
||||
"sourceCommit": "e10fe855808abcae193a205ea98f8ccd70d25620",
|
||||
"piVersion": "0.87.1",
|
||||
"captureDate": "2026-10-21"
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"type": "tool_execution_start",
|
||||
"toolCallId": "call_f28b261de3c6440b953f0f56c3fec7d0|fc_0d35cf51b3ed48b9016ad8b8b4a63c81918a55903d0a4f12cb",
|
||||
"toolName": "subagent",
|
||||
"args": {
|
||||
"agent": "marker",
|
||||
"name": "fixture-marker",
|
||||
"title": "Fixture marker verification",
|
||||
"task": "Return only PASEO_EDXETH_LEAN_OK"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tool_execution_end",
|
||||
"toolCallId": "call_f28b261de3c6440b953f0f56c3fec7d0|fc_0d35cf51b3ed48b9016ad8b8b4a63c81918a55903d0a4f12cb",
|
||||
"toolName": "subagent",
|
||||
"result": {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Sub-agent \"fixture-marker\" launched async with id 1bfdf3b3. Results will be delivered automatically as a steer message when it finishes. Use this exact id if you need to resume or stop this child.\n\nActivity: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl\nRun evidence: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json\nRead these on demand for progress or failure diagnosis; activity is evidence, not a completion verdict."
|
||||
}
|
||||
],
|
||||
"details": {
|
||||
"evidence": {
|
||||
"runId": "1bfdf3b3",
|
||||
"cwd": "/tmp/paseo-edxeth-fixture/lean/workspace",
|
||||
"activityFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl",
|
||||
"resultFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json"
|
||||
},
|
||||
"id": "1bfdf3b3",
|
||||
"name": "fixture-marker",
|
||||
"title": "Fixture marker verification",
|
||||
"task": "Return only PASEO_EDXETH_LEAN_OK",
|
||||
"agent": "marker",
|
||||
"sessionFile": "/tmp/paseo-edxeth-fixture/background/child-session.jsonl",
|
||||
"noSession": false,
|
||||
"status": "started",
|
||||
"mode": "background",
|
||||
"deliveryState": "detached",
|
||||
"parentClosePolicy": "terminate",
|
||||
"async": true,
|
||||
"autoExit": true
|
||||
}
|
||||
},
|
||||
"isError": false
|
||||
},
|
||||
{
|
||||
"type": "message_end",
|
||||
"message": {
|
||||
"role": "custom",
|
||||
"customType": "subagent_result",
|
||||
"content": "Sub-agent \"fixture-marker\" completed (7s).\n\nPASEO_EDXETH_LEAN_OK\n\nSession: /tmp/paseo-edxeth-fixture/background/child-session.jsonl\nResume: pi --session /tmp/paseo-edxeth-fixture/background/child-session.jsonl\n\nSub-agent context: 2K/372K tokens (0%) used at finish.\n\nActivity: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl\nRun evidence: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json\nRead these on demand for progress or failure diagnosis; activity is evidence, not a completion verdict.",
|
||||
"display": true,
|
||||
"details": {
|
||||
"evidence": {
|
||||
"runId": "1bfdf3b3",
|
||||
"cwd": "/tmp/paseo-edxeth-fixture/lean/workspace",
|
||||
"activityFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl",
|
||||
"resultFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json",
|
||||
"finalized": true
|
||||
},
|
||||
"id": "1bfdf3b3",
|
||||
"name": "fixture-marker",
|
||||
"task": "Return only PASEO_EDXETH_LEAN_OK",
|
||||
"agent": "marker",
|
||||
"mode": "background",
|
||||
"status": "completed",
|
||||
"deliveryState": "detached",
|
||||
"parentClosePolicy": "terminate",
|
||||
"async": true,
|
||||
"exitCode": 0,
|
||||
"elapsed": 7,
|
||||
"outputTokens": 13,
|
||||
"contextTokens": 1775,
|
||||
"contextWindow": 372000,
|
||||
"sessionFile": "/tmp/paseo-edxeth-fixture/background/child-session.jsonl"
|
||||
},
|
||||
"timestamp": 1792587964414
|
||||
}
|
||||
}
|
||||
],
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "toolCall",
|
||||
"id": "call_f28b261de3c6440b953f0f56c3fec7d0|fc_0d35cf51b3ed48b9016ad8b8b4a63c81918a55903d0a4f12cb",
|
||||
"name": "subagent",
|
||||
"arguments": {
|
||||
"agent": "marker",
|
||||
"name": "fixture-marker",
|
||||
"title": "Fixture marker verification",
|
||||
"task": "Return only PASEO_EDXETH_LEAN_OK"
|
||||
}
|
||||
}
|
||||
],
|
||||
"api": "openai-codex-responses",
|
||||
"provider": "openai-codex",
|
||||
"model": "gpt-5.6-sol",
|
||||
"usage": {
|
||||
"input": 2020,
|
||||
"output": 43,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0,
|
||||
"reasoning": 0,
|
||||
"totalTokens": 2063,
|
||||
"cost": {
|
||||
"input": 0.00808,
|
||||
"output": 0.0008600000000000001,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0,
|
||||
"total": 0.00894
|
||||
}
|
||||
},
|
||||
"stopReason": "toolUse",
|
||||
"timestamp": 1792587953256,
|
||||
"responseId": "resp_0d35cf51b3ed48b9016ad8b8b3698481918f82962b6eaf2788",
|
||||
"rawStopReason": "completed"
|
||||
},
|
||||
{
|
||||
"role": "toolResult",
|
||||
"toolCallId": "call_f28b261de3c6440b953f0f56c3fec7d0|fc_0d35cf51b3ed48b9016ad8b8b4a63c81918a55903d0a4f12cb",
|
||||
"toolName": "subagent",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Sub-agent \"fixture-marker\" launched async with id 1bfdf3b3. Results will be delivered automatically as a steer message when it finishes. Use this exact id if you need to resume or stop this child.\n\nActivity: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl\nRun evidence: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json\nRead these on demand for progress or failure diagnosis; activity is evidence, not a completion verdict."
|
||||
}
|
||||
],
|
||||
"details": {
|
||||
"evidence": {
|
||||
"runId": "1bfdf3b3",
|
||||
"cwd": "/tmp/paseo-edxeth-fixture/lean/workspace",
|
||||
"activityFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl",
|
||||
"resultFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json",
|
||||
"finalized": true
|
||||
},
|
||||
"id": "1bfdf3b3",
|
||||
"name": "fixture-marker",
|
||||
"title": "Fixture marker verification",
|
||||
"task": "Return only PASEO_EDXETH_LEAN_OK",
|
||||
"agent": "marker",
|
||||
"sessionFile": "/tmp/paseo-edxeth-fixture/background/child-session.jsonl",
|
||||
"noSession": false,
|
||||
"status": "started",
|
||||
"mode": "background",
|
||||
"deliveryState": "detached",
|
||||
"parentClosePolicy": "terminate",
|
||||
"async": true,
|
||||
"autoExit": true
|
||||
},
|
||||
"isError": false,
|
||||
"timestamp": 1792587957399
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "thinking",
|
||||
"thinking": "**Identifying need for await keyword**"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "DONE"
|
||||
}
|
||||
],
|
||||
"api": "openai-codex-responses",
|
||||
"provider": "openai-codex",
|
||||
"model": "gpt-5.6-sol",
|
||||
"usage": {
|
||||
"input": 2258,
|
||||
"output": 18,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0,
|
||||
"reasoning": 11,
|
||||
"totalTokens": 2276,
|
||||
"cost": {
|
||||
"input": 0.009032,
|
||||
"output": 0.00036,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0,
|
||||
"total": 0.009392
|
||||
}
|
||||
},
|
||||
"stopReason": "stop",
|
||||
"timestamp": 1792587957402,
|
||||
"responseId": "resp_0d35cf51b3ed48b9016ad8b8b602108191a8e8f7fd77fbe5b5",
|
||||
"rawStopReason": "completed"
|
||||
},
|
||||
{
|
||||
"role": "custom",
|
||||
"customType": "subagent_result",
|
||||
"content": "Sub-agent \"fixture-marker\" completed (7s).\n\nPASEO_EDXETH_LEAN_OK\n\nSession: /tmp/paseo-edxeth-fixture/background/child-session.jsonl\nResume: pi --session /tmp/paseo-edxeth-fixture/background/child-session.jsonl\n\nSub-agent context: 2K/372K tokens (0%) used at finish.\n\nActivity: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl\nRun evidence: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json\nRead these on demand for progress or failure diagnosis; activity is evidence, not a completion verdict.",
|
||||
"display": true,
|
||||
"details": {
|
||||
"evidence": {
|
||||
"runId": "1bfdf3b3",
|
||||
"cwd": "/tmp/paseo-edxeth-fixture/lean/workspace",
|
||||
"activityFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl",
|
||||
"resultFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json",
|
||||
"finalized": true
|
||||
},
|
||||
"id": "1bfdf3b3",
|
||||
"name": "fixture-marker",
|
||||
"task": "Return only PASEO_EDXETH_LEAN_OK",
|
||||
"agent": "marker",
|
||||
"mode": "background",
|
||||
"status": "completed",
|
||||
"deliveryState": "detached",
|
||||
"parentClosePolicy": "terminate",
|
||||
"async": true,
|
||||
"exitCode": 0,
|
||||
"elapsed": 7,
|
||||
"outputTokens": 13,
|
||||
"contextTokens": 1775,
|
||||
"contextWindow": 372000,
|
||||
"sessionFile": "/tmp/paseo-edxeth-fixture/background/child-session.jsonl"
|
||||
},
|
||||
"timestamp": 1792587964414
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "DONE"
|
||||
}
|
||||
],
|
||||
"api": "openai-codex-responses",
|
||||
"provider": "openai-codex",
|
||||
"model": "gpt-5.6-sol",
|
||||
"usage": {
|
||||
"input": 389,
|
||||
"output": 5,
|
||||
"cacheRead": 2176,
|
||||
"cacheWrite": 0,
|
||||
"reasoning": 0,
|
||||
"totalTokens": 2570,
|
||||
"cost": {
|
||||
"input": 0.0015559999999999999,
|
||||
"output": 0.0001,
|
||||
"cacheRead": 0.0008704000000000001,
|
||||
"cacheWrite": 0,
|
||||
"total": 0.0025264
|
||||
}
|
||||
},
|
||||
"stopReason": "stop",
|
||||
"timestamp": 1792587964417,
|
||||
"responseId": "resp_0d35cf51b3ed48b9016ad8b8bd0c548191bddd1fe3410a1c82",
|
||||
"rawStopReason": "completed"
|
||||
}
|
||||
]
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
{"type": "session", "version": 3, "id": "afbea6e0-7858-4ecf-8ef1-e26af9b7d6e0", "timestamp": "2026-10-21T13:05:17.628Z", "cwd": "/tmp/paseo-edxeth-fixture/upstream/workspace", "name": "[marker] Fixture marker verification", "parentSession": "/tmp/paseo-edxeth-fixture/upstream/sessions/2026-10-21T13-05-12-212Z_01a15e90-d414-7243-a701-4eb4b0bec880.jsonl"}
|
||||
{"type": "custom", "customType": "pi-subagents_launch_metadata", "data": {"version": 1, "timestamp": "2026-10-21T13:05:17.628Z", "name": "fixture-marker", "title": "Fixture marker verification", "sessionTitle": "[marker] Fixture marker verification", "agent": "marker", "mode": "background", "sessionMode": "lineage-only", "autoExit": true, "parentClosePolicy": "terminate", "reportContextUsage": true, "async": false, "model": "openai-codex/gpt-5.6-sol", "thinking": "high", "modelRef": "openai-codex/gpt-5.6-sol:high", "definitionModel": "openai-codex/gpt-5.6-sol", "definitionThinking": "high", "allowModelOverride": true, "modelSource": "agent", "tools": "read", "denyTools": ["subagent", "subagent_resume", "subagent_kill"], "spawnableAgents": [], "spawnBudget": null, "extensions": [], "noContextFiles": false, "inheritAppendSystem": false, "noSession": false, "trustProject": false, "agentConfigDir": "/tmp/paseo-edxeth-fixture/upstream/agent", "cwd": "/tmp/paseo-edxeth-fixture/upstream/workspace", "boundarySystemPrompt": false}, "id": "3f7d3c3f", "parentId": null, "timestamp": "2026-10-21T13:05:17.629Z"}
|
||||
{"type": "session_info", "id": "6e198450", "parentId": "3f7d3c3f", "timestamp": "2026-10-21T13:05:17.825Z", "name": "[marker] Fixture marker verification"}
|
||||
{"type": "model_change", "id": "94bf4c0b", "parentId": "6e198450", "timestamp": "2026-10-21T13:05:17.962Z", "provider": "openai-codex", "modelId": "gpt-5.6-sol"}
|
||||
{"type": "thinking_level_change", "id": "57cc6428", "parentId": "94bf4c0b", "timestamp": "2026-10-21T13:05:17.962Z", "thinkingLevel": "high"}
|
||||
{"type": "message", "id": "a9e47f08", "parentId": "8c186624", "timestamp": "2026-10-21T13:05:17.970Z", "message": {"role": "user", "content": [{"type": "text", "text": "<file name=\"/home/pi-user/.pi/history/workspace/artifacts/01a15e90-d414-7243-a701-4eb4b0bec880/context/fixture-marker-2026-10-21T13-05-17.md\">\n\n\nReturn only the exact marker supplied by the user. Do not use tools.\n\nComplete your task autonomously.\n\nReturn only PASEO_EDXETH_UPSTREAM_OK\n\nYour FINAL assistant message should summarize what you accomplished.\n</file>\n"}], "timestamp": 1792587917968}}
|
||||
{"type": "message", "id": "bc221a13", "parentId": "a9e47f08", "timestamp": "2026-10-21T13:05:23.476Z", "message": {"role": "assistant", "content": [{"type": "thinking", "thinking": "**Following user file marker exactly**"}, {"type": "text", "text": "PASEO_EDXETH_UPSTREAM_OK"}], "api": "openai-codex-responses", "provider": "openai-codex", "model": "gpt-5.6-sol", "usage": {"input": 1673, "output": 49, "cacheRead": 0, "cacheWrite": 0, "reasoning": 34, "totalTokens": 1722, "cost": {"input": 0.006692, "output": 0.0009800000000000002, "cacheRead": 0, "cacheWrite": 0, "total": 0.007672}}, "stopReason": "stop", "timestamp": 1792587917974, "responseId": "resp_09445c1733d532e7016ad8b8909c908191ba803c059497945e", "rawStopReason": "completed"}}
|
||||
{"type": "custom", "customType": "pi-subagent-completion", "data": {"reason": "normal"}, "id": "832fc8c9", "parentId": "bc221a13", "timestamp": "2026-10-21T13:05:23.477Z"}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
{
|
||||
"provenance": {
|
||||
"package": "edxeth/pi-subagents",
|
||||
"version": "2.9.2",
|
||||
"sourceCommit": "cf6dbf41c17986f3882e6804a68e8fb8282d25e6",
|
||||
"piVersion": "0.87.1",
|
||||
"captureDate": "2026-10-21"
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"type": "tool_execution_start",
|
||||
"toolCallId": "call_0bc6fc2213cd49aa92950947bde5dd8a|fc_0a0ca4aa78cd296a016ad8b88d018481919eadbf09eb191a1a",
|
||||
"toolName": "subagent",
|
||||
"args": {
|
||||
"agent": "marker",
|
||||
"name": "fixture-marker",
|
||||
"title": "Fixture marker verification",
|
||||
"task": "Return only PASEO_EDXETH_UPSTREAM_OK"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tool_execution_end",
|
||||
"toolCallId": "call_0bc6fc2213cd49aa92950947bde5dd8a|fc_0a0ca4aa78cd296a016ad8b88d018481919eadbf09eb191a1a",
|
||||
"toolName": "subagent",
|
||||
"result": {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Sub-agent \"fixture-marker\" completed (7s).\n\nPASEO_EDXETH_UPSTREAM_OK\n\nSession: /tmp/paseo-edxeth-fixture/foreground/child-session.jsonl\nResume: pi --session /tmp/paseo-edxeth-fixture/foreground/child-session.jsonl\n\nSub-agent context: 2K/372K tokens (0%) used at finish."
|
||||
}
|
||||
],
|
||||
"details": {
|
||||
"id": "dd7f94e8",
|
||||
"name": "fixture-marker",
|
||||
"agent": "marker",
|
||||
"status": "completed",
|
||||
"mode": "background",
|
||||
"parentClosePolicy": "terminate",
|
||||
"deliveryState": "awaited",
|
||||
"async": false,
|
||||
"autoExit": true,
|
||||
"exitCode": 0,
|
||||
"elapsed": 7,
|
||||
"outputTokens": 49,
|
||||
"contextTokens": 1777,
|
||||
"contextWindow": 372000,
|
||||
"summary": "PASEO_EDXETH_UPSTREAM_OK",
|
||||
"sessionFile": "/tmp/paseo-edxeth-fixture/foreground/child-session.jsonl"
|
||||
}
|
||||
},
|
||||
"isError": false
|
||||
}
|
||||
],
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "toolCall",
|
||||
"id": "call_0bc6fc2213cd49aa92950947bde5dd8a|fc_0a0ca4aa78cd296a016ad8b88d018481919eadbf09eb191a1a",
|
||||
"name": "subagent",
|
||||
"arguments": {
|
||||
"agent": "marker",
|
||||
"name": "fixture-marker",
|
||||
"title": "Fixture marker verification",
|
||||
"task": "Return only PASEO_EDXETH_UPSTREAM_OK"
|
||||
}
|
||||
}
|
||||
],
|
||||
"api": "openai-codex-responses",
|
||||
"provider": "openai-codex",
|
||||
"model": "gpt-5.6-sol",
|
||||
"usage": {
|
||||
"input": 3256,
|
||||
"output": 43,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0,
|
||||
"reasoning": 0,
|
||||
"totalTokens": 3299,
|
||||
"cost": {
|
||||
"input": 0.013023999999999999,
|
||||
"output": 0.0008600000000000001,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0,
|
||||
"total": 0.013883999999999999
|
||||
}
|
||||
},
|
||||
"stopReason": "toolUse",
|
||||
"timestamp": 1792587912428,
|
||||
"responseId": "resp_0a0ca4aa78cd296a016ad8b88bd52881918cbb9b8326b3060a",
|
||||
"rawStopReason": "completed"
|
||||
},
|
||||
{
|
||||
"role": "toolResult",
|
||||
"toolCallId": "call_0bc6fc2213cd49aa92950947bde5dd8a|fc_0a0ca4aa78cd296a016ad8b88d018481919eadbf09eb191a1a",
|
||||
"toolName": "subagent",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Sub-agent \"fixture-marker\" completed (7s).\n\nPASEO_EDXETH_UPSTREAM_OK\n\nSession: /tmp/paseo-edxeth-fixture/foreground/child-session.jsonl\nResume: pi --session /tmp/paseo-edxeth-fixture/foreground/child-session.jsonl\n\nSub-agent context: 2K/372K tokens (0%) used at finish."
|
||||
}
|
||||
],
|
||||
"details": {
|
||||
"id": "dd7f94e8",
|
||||
"name": "fixture-marker",
|
||||
"agent": "marker",
|
||||
"status": "completed",
|
||||
"mode": "background",
|
||||
"parentClosePolicy": "terminate",
|
||||
"deliveryState": "awaited",
|
||||
"async": false,
|
||||
"autoExit": true,
|
||||
"exitCode": 0,
|
||||
"elapsed": 7,
|
||||
"outputTokens": 49,
|
||||
"contextTokens": 1777,
|
||||
"contextWindow": 372000,
|
||||
"summary": "PASEO_EDXETH_UPSTREAM_OK",
|
||||
"sessionFile": "/tmp/paseo-edxeth-fixture/foreground/child-session.jsonl"
|
||||
},
|
||||
"isError": false,
|
||||
"timestamp": 1792587924671
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "DONE"
|
||||
}
|
||||
],
|
||||
"api": "openai-codex-responses",
|
||||
"provider": "openai-codex",
|
||||
"model": "gpt-5.6-sol",
|
||||
"usage": {
|
||||
"input": 388,
|
||||
"output": 5,
|
||||
"cacheRead": 3072,
|
||||
"cacheWrite": 0,
|
||||
"reasoning": 0,
|
||||
"totalTokens": 3465,
|
||||
"cost": {
|
||||
"input": 0.001552,
|
||||
"output": 0.0001,
|
||||
"cacheRead": 0.0012288000000000002,
|
||||
"cacheWrite": 0,
|
||||
"total": 0.0028808
|
||||
}
|
||||
},
|
||||
"stopReason": "stop",
|
||||
"timestamp": 1792587924675,
|
||||
"responseId": "resp_0a0ca4aa78cd296a016ad8b896af2c81919c0ef586dd50f2f0",
|
||||
"rawStopReason": "completed"
|
||||
}
|
||||
]
|
||||
}
|
||||
+336
@@ -0,0 +1,336 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createPiExtensionHost } from "../index.js";
|
||||
import { readSubagentFixture, verifySubagentFixture } from "../subagent-fixture-test.js";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { edxethPiSubagents } from "./index.js";
|
||||
import { streamPiHistory } from "../../history-mapper.js";
|
||||
import { ProviderSubagentStore } from "../../../../provider-subagents/store.js";
|
||||
|
||||
const args = {
|
||||
agent: "scout",
|
||||
name: "status-scout",
|
||||
title: "Check child status",
|
||||
task: "Return the completion marker",
|
||||
};
|
||||
|
||||
describe("edxeth/pi-subagents adapter", () => {
|
||||
test.each(["tool", "notification"])("ends a child that requested help via %s", (delivery) => {
|
||||
const host = createPiExtensionHost();
|
||||
const call = { callId: "spawn-1", toolName: "subagent", args, status: "completed" as const };
|
||||
host.mapToolCall({
|
||||
...call,
|
||||
result: { details: { id: "run-1", name: args.name, status: "started" } },
|
||||
});
|
||||
const details = { id: "run-1", name: args.name, message: "Please clarify the task" };
|
||||
const result =
|
||||
delivery === "tool"
|
||||
? host.mapToolCall({ ...call, result: { details: { ...details, status: "pinged" } } })
|
||||
: host.mapCustomMessage({
|
||||
role: "custom",
|
||||
customType: "subagent_ping",
|
||||
content: "Child requested help and exited",
|
||||
details,
|
||||
});
|
||||
expect(result?.subagents).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "upsert",
|
||||
id: "spawn-1",
|
||||
status: "canceled",
|
||||
subtitle: "Needs help",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("leaves unrelated tools and other subagent protocols to their adapters", () => {
|
||||
const host = createPiExtensionHost(undefined, [edxethPiSubagents]);
|
||||
const call = { callId: "other", toolName: "subagent", args, status: "completed" as const };
|
||||
for (const details of [null, {}, { mode: "single", results: [] }, { id: "run-1" }]) {
|
||||
expect(host.mapToolCall({ ...call, result: { details } })).toBeUndefined();
|
||||
}
|
||||
expect(
|
||||
host.mapToolCall({
|
||||
...call,
|
||||
toolName: "unrelated",
|
||||
result: { details: { id: "run-1", name: args.name, status: "completed" } },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
for (const details of [{ id: "run-1" }, { id: "run-1", status: "unknown" }]) {
|
||||
expect(
|
||||
host.mapCustomMessage({
|
||||
role: "custom",
|
||||
customType: "subagent_result",
|
||||
content: "Unrecognized result",
|
||||
details,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("waits for cancellation after a kill acknowledgement reports stopping", () => {
|
||||
const host = createPiExtensionHost();
|
||||
host.mapToolCall({
|
||||
callId: "spawn-1",
|
||||
toolName: "subagent",
|
||||
args,
|
||||
status: "completed",
|
||||
result: { details: { id: "run-1", name: args.name, status: "started" } },
|
||||
});
|
||||
const stopping = host.mapToolCall({
|
||||
callId: "kill-1",
|
||||
toolName: "subagent_kill",
|
||||
args: { id: "run-1" },
|
||||
status: "completed",
|
||||
result: { details: { id: "run-1", name: args.name, status: "stopping" } },
|
||||
});
|
||||
expect(stopping?.subagents ?? []).toEqual([]);
|
||||
expect(
|
||||
host.mapCustomMessage({
|
||||
role: "custom",
|
||||
customType: "subagent_result",
|
||||
content: "Cancelled",
|
||||
details: { id: "run-1", status: "cancelled" },
|
||||
})?.subagents,
|
||||
).toEqual([{ type: "upsert", id: "spawn-1", status: "canceled" }]);
|
||||
});
|
||||
|
||||
test("preserves a completion that arrives before its launch acknowledgement", () => {
|
||||
const host = createPiExtensionHost();
|
||||
const early = host.mapCustomMessage({
|
||||
role: "custom",
|
||||
customType: "subagent_result",
|
||||
content: "Already done",
|
||||
details: { id: "fast-run", status: "completed" },
|
||||
});
|
||||
expect(early?.subagents ?? []).toEqual([]);
|
||||
const started = host.mapToolCall({
|
||||
callId: "fast-call",
|
||||
toolName: "subagent",
|
||||
args,
|
||||
status: "completed",
|
||||
result: {
|
||||
details: { id: "fast-run", name: args.name, agent: args.agent, status: "started" },
|
||||
},
|
||||
});
|
||||
expect(started?.subagents).toEqual([
|
||||
{
|
||||
type: "upsert",
|
||||
id: "fast-call",
|
||||
title: "scout",
|
||||
description: args.task,
|
||||
toolCallId: "fast-call",
|
||||
status: "completed",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ details: { status: "completed" }, expected: "completed" },
|
||||
{ details: { status: "failed" }, expected: "failed" },
|
||||
{ details: { status: "cancelled" }, expected: "canceled" },
|
||||
{ details: { error: "Child watcher exited unexpectedly" }, expected: "failed" },
|
||||
])("maps completion $details to $expected", ({ details, expected }) => {
|
||||
const host = createPiExtensionHost();
|
||||
host.mapToolCall({
|
||||
callId: "spawn-1",
|
||||
toolName: "subagent",
|
||||
args,
|
||||
status: "completed",
|
||||
result: { details: { id: "run-1", name: args.name, status: "started" } },
|
||||
});
|
||||
expect(
|
||||
host.mapCustomMessage({
|
||||
role: "custom",
|
||||
customType: "subagent_result",
|
||||
content: "Child result",
|
||||
details: { id: "run-1", ...details },
|
||||
})?.subagents,
|
||||
).toEqual([{ type: "upsert", id: "spawn-1", status: expected }]);
|
||||
});
|
||||
|
||||
test("a resume gets its own run and reads the retained child session", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "paseo-edxeth-resume-"));
|
||||
const file = join(dir, "child.jsonl");
|
||||
const host = createPiExtensionHost();
|
||||
try {
|
||||
await writeFile(
|
||||
file,
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
message: { role: "assistant", content: [{ type: "text", text: "RESUMED" }] },
|
||||
}) + "\n",
|
||||
);
|
||||
const original = host.mapToolCall({
|
||||
callId: "spawn-1",
|
||||
toolName: "subagent",
|
||||
args,
|
||||
status: "completed",
|
||||
result: {
|
||||
details: { id: "run-1", name: args.name, status: "completed", sessionFile: file },
|
||||
},
|
||||
});
|
||||
await original?.hydration;
|
||||
const resumed = host.mapToolCall({
|
||||
callId: "resume-1",
|
||||
toolName: "subagent_resume",
|
||||
args: { sessionFile: file, task: "Continue" },
|
||||
status: "completed",
|
||||
result: {
|
||||
details: { id: "run-2", name: args.name, status: "started", sessionFile: file },
|
||||
},
|
||||
});
|
||||
expect(resumed?.childSessions).toEqual([{ id: "resume-1", file }]);
|
||||
expect(await resumed?.hydration).toEqual([
|
||||
{
|
||||
type: "provider_subagent",
|
||||
provider: "pi",
|
||||
event: {
|
||||
type: "timeline",
|
||||
id: "resume-1",
|
||||
item: expect.objectContaining({ type: "assistant_message", text: "RESUMED" }),
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
host.mapCustomMessage({
|
||||
role: "custom",
|
||||
customType: "subagent_result",
|
||||
content: "Resumed run complete",
|
||||
details: { id: "run-2", status: "completed", sessionFile: file },
|
||||
})?.subagents,
|
||||
).toEqual([{ type: "upsert", id: "resume-1", status: "completed" }]);
|
||||
} finally {
|
||||
host.close();
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("settles each batched child by run id without creating a parent-call card", () => {
|
||||
const host = createPiExtensionHost();
|
||||
const result = host.mapToolCall({
|
||||
callId: "batch-1",
|
||||
toolName: "subagent",
|
||||
args: { children: [args, { ...args, agent: "reviewer", name: "status-reviewer" }] },
|
||||
status: "completed",
|
||||
result: {
|
||||
details: {
|
||||
status: "batch",
|
||||
children: [
|
||||
{ id: "run-1", ...args, mode: "background", status: "completed" },
|
||||
{ id: "run-2", name: "status-reviewer", agent: "reviewer", status: "started" },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result?.subagents).toEqual([
|
||||
{
|
||||
type: "upsert",
|
||||
id: "batch-1:0",
|
||||
title: "scout",
|
||||
description: args.task,
|
||||
toolCallId: "batch-1",
|
||||
status: "completed",
|
||||
},
|
||||
{
|
||||
type: "upsert",
|
||||
id: "batch-1:1",
|
||||
title: "reviewer",
|
||||
description: undefined,
|
||||
toolCallId: "batch-1",
|
||||
status: "running",
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
host.mapCustomMessage({
|
||||
role: "custom",
|
||||
customType: "subagent_result",
|
||||
content: "Done",
|
||||
details: { id: "run-2", status: "completed" },
|
||||
})?.subagents,
|
||||
).toEqual([{ type: "upsert", id: "batch-1:1", status: "completed" }]);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ mode: "foreground", statuses: ["running", "completed"] },
|
||||
{ mode: "background", statuses: ["running", "running", "completed"] },
|
||||
])("settles the captured $mode run live and on replay", async ({ mode, statuses }) => {
|
||||
const fixture = readSubagentFixture(new URL(`./fixtures/${mode}.json`, import.meta.url), {
|
||||
from: `/tmp/paseo-edxeth-fixture/${mode}/child-session.jsonl`,
|
||||
to: fileURLToPath(new URL(`./fixtures/${mode}-child-session.jsonl`, import.meta.url)),
|
||||
});
|
||||
const events = await verifySubagentFixture(fixture);
|
||||
const updates = events.flatMap(({ event }) => (event.type === "upsert" ? [event] : []));
|
||||
expect(updates.map((event) => event.status)).toEqual(statuses);
|
||||
expect(new Set(updates.map((event) => event.id)).size).toBe(1);
|
||||
expect(events.some(({ event }) => event.type === "timeline")).toBe(true);
|
||||
});
|
||||
|
||||
test.each(["foreground", "background"])(
|
||||
"restores a completed %s child and its transcript to the consumer store",
|
||||
async (mode) => {
|
||||
const fixture = readSubagentFixture(new URL(`./fixtures/${mode}.json`, import.meta.url), {
|
||||
from: `/tmp/paseo-edxeth-fixture/${mode}/child-session.jsonl`,
|
||||
to: fileURLToPath(new URL(`./fixtures/${mode}-child-session.jsonl`, import.meta.url)),
|
||||
});
|
||||
const store = new ProviderSubagentStore();
|
||||
for await (const event of streamPiHistory("pi", fixture.messages)) {
|
||||
if (event.type === "provider_subagent") store.apply("parent", "pi", event.event);
|
||||
}
|
||||
const children = store.list("parent");
|
||||
expect(children).toEqual([expect.objectContaining({ status: "completed", title: "marker" })]);
|
||||
const transcript = store.fetchTimeline("parent", children[0]!.id);
|
||||
expect(transcript.rows).toContainEqual(
|
||||
expect.objectContaining({
|
||||
item: expect.objectContaining({
|
||||
type: "assistant_message",
|
||||
text: mode === "foreground" ? "PASEO_EDXETH_UPSTREAM_OK" : "PASEO_EDXETH_LEAN_OK",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test("settles a synchronous result on the card created when the tool started", () => {
|
||||
const host = createPiExtensionHost();
|
||||
const call = { callId: "spawn-1", toolName: "subagent", args };
|
||||
const start = host.mapToolCall({ ...call, status: "running", result: null });
|
||||
expect(start?.subagents).toEqual([
|
||||
{
|
||||
type: "upsert",
|
||||
id: "spawn-1",
|
||||
title: "scout",
|
||||
description: args.task,
|
||||
toolCallId: "spawn-1",
|
||||
status: "running",
|
||||
},
|
||||
]);
|
||||
const end = host.mapToolCall({
|
||||
...call,
|
||||
status: "completed",
|
||||
result: {
|
||||
content: [{ type: "text", text: "DONE" }],
|
||||
details: {
|
||||
id: "run-1",
|
||||
name: args.name,
|
||||
agent: args.agent,
|
||||
mode: "background",
|
||||
status: "completed",
|
||||
deliveryState: "awaited",
|
||||
exitCode: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(end?.subagents).toEqual([
|
||||
{
|
||||
type: "upsert",
|
||||
id: "spawn-1",
|
||||
title: "scout",
|
||||
description: args.task,
|
||||
toolCallId: "spawn-1",
|
||||
status: "completed",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
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";
|
||||
|
||||
const Run = z
|
||||
.object({
|
||||
id: z.string().trim().min(1),
|
||||
name: z.string().trim().min(1),
|
||||
agent: z.string().trim().min(1).optional(),
|
||||
task: z.string().optional(),
|
||||
status: z.enum(["started", "completed", "failed", "cancelled", "pinged"]),
|
||||
sessionFile: z.string().trim().min(1).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
const Args = z.object({ task: z.string().optional() }).passthrough();
|
||||
const Batch = z.object({ status: z.enum(["started", "batch"]), children: z.array(Run).min(1) });
|
||||
const Completion = z
|
||||
.object({
|
||||
id: z.string().trim().min(1),
|
||||
status: z.enum(["completed", "failed", "cancelled", "pinged"]).optional(),
|
||||
error: z.string().optional(),
|
||||
sessionFile: z.string().trim().min(1).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
const Ping = Completion.extend({ message: z.string() });
|
||||
|
||||
function childStatus(status: z.infer<typeof Run>["status"]) {
|
||||
if (status === "started") return "running";
|
||||
// A ping ends the child process and needs a new resume run. It is not success.
|
||||
if (status === "cancelled" || status === "pinged") return "canceled";
|
||||
return status;
|
||||
}
|
||||
|
||||
export const edxethPiSubagents: PiExtension = {
|
||||
id: "edxeth/pi-subagents",
|
||||
createSession: () => {
|
||||
const callsByRun = new Map<string, string>();
|
||||
const completions = new Map<string, z.infer<typeof Completion>>();
|
||||
const readSessions = new Map<string, string>();
|
||||
const childSession = (id: string, file: string | undefined): PiChildSessionFile[] => {
|
||||
if (!file || readSessions.get(id) === file) return [];
|
||||
readSessions.set(id, file);
|
||||
return [{ id, file }];
|
||||
};
|
||||
const completeRun = (result: z.infer<typeof Completion>) => {
|
||||
const status = result.status ?? (result.error === undefined ? undefined : "failed");
|
||||
if (!status) return undefined;
|
||||
// A fast child can finish before the tool returns its run id. Keep the
|
||||
// terminal outcome so a later "started" acknowledgement cannot regress it.
|
||||
completions.set(result.id, { ...result, status });
|
||||
const id = callsByRun.get(result.id);
|
||||
if (!id) return undefined;
|
||||
const subagents: ProviderSubagentInputEvent[] = [
|
||||
{
|
||||
type: "upsert",
|
||||
id,
|
||||
status: childStatus(status),
|
||||
...(status === "pinged" ? { subtitle: "Needs help" } : {}),
|
||||
},
|
||||
];
|
||||
return { subagents, childSessions: childSession(id, result.sessionFile) };
|
||||
};
|
||||
return {
|
||||
mapToolCall(call) {
|
||||
const isLaunch = call.toolName === "subagent" || call.toolName === "subagent_resume";
|
||||
if (!isLaunch || call.status === "running") return undefined;
|
||||
const details = typeof call.result === "object" ? call.result?.details : null;
|
||||
const single = Run.safeParse(details);
|
||||
let runs: z.infer<typeof Run>[];
|
||||
if (single.success) {
|
||||
runs = [single.data];
|
||||
} else {
|
||||
const batch = Batch.safeParse(details);
|
||||
if (!batch.success) return undefined;
|
||||
runs = batch.data.children;
|
||||
}
|
||||
const args = Args.safeParse(call.args);
|
||||
const description = args.success ? args.data.task : undefined;
|
||||
const subagents: ProviderSubagentInputEvent[] = [];
|
||||
const childSessions: PiChildSessionFile[] = [];
|
||||
for (const [index, run] of runs.entries()) {
|
||||
const id = runs.length === 1 ? call.callId : `${call.callId}:${index}`;
|
||||
const completion = completions.get(run.id);
|
||||
const status = completion?.status ?? run.status;
|
||||
callsByRun.set(run.id, id);
|
||||
subagents.push({
|
||||
type: "upsert",
|
||||
id,
|
||||
title: run.agent ?? run.name,
|
||||
description: run.task ?? description,
|
||||
toolCallId: call.callId,
|
||||
status: childStatus(status),
|
||||
...(status === "pinged" ? { subtitle: "Needs help" } : {}),
|
||||
});
|
||||
childSessions.push(...childSession(id, completion?.sessionFile ?? run.sessionFile));
|
||||
}
|
||||
return {
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
description,
|
||||
log: extractTextFromToolResult(call.result)?.trim() ?? "",
|
||||
},
|
||||
subagents,
|
||||
childSessions,
|
||||
};
|
||||
},
|
||||
mapCustomMessage(message) {
|
||||
if (message.customType === "subagent_ping") {
|
||||
const ping = Ping.safeParse(message.details);
|
||||
return ping.success ? completeRun({ ...ping.data, status: "pinged" }) : undefined;
|
||||
}
|
||||
if (message.customType !== "subagent_result") return undefined;
|
||||
const parsed = Completion.safeParse(message.details);
|
||||
return parsed.success ? completeRun(parsed.data) : undefined;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { piAskUser } from "./pi-ask-user/index.js";
|
||||
import { piSubagents } from "./pi-subagents/index.js";
|
||||
import { edxethPiSubagents } from "./edxeth-pi-subagents/index.js";
|
||||
import { piMcpAdapter } from "./pi-mcp-adapter/index.js";
|
||||
import { rpivTodo } from "./rpiv-todo/index.js";
|
||||
import { piExampleTodo } from "./pi-example-todo/index.js";
|
||||
@@ -10,6 +11,7 @@ import { rpivAskUserQuestion } from "./rpiv-ask-user-question/index.js";
|
||||
|
||||
export const piExtensions = [
|
||||
piAskUser, // Dialog correlation
|
||||
edxethPiSubagents, // Structured results before the generic subagent tool matcher
|
||||
piSubagents, // Delegation calls
|
||||
tintinwebPiSubagents,
|
||||
gotgenesPiSubagents,
|
||||
|
||||
@@ -184,17 +184,24 @@ test("repeated content is reused without rewriting the attachment", async () =>
|
||||
test("64 MiB materialization leaves the event loop responsive and preserves bytes", async () => {
|
||||
const bytes = Buffer.alloc(64 * 1024 * 1024, 37);
|
||||
const data = bytes.toString("base64");
|
||||
let previous = performance.now();
|
||||
// Main-thread CPU time between ticks bounds the decode and hash work done on the loop;
|
||||
// wall-clock gaps on shared CI runners also count preemption and timer granularity.
|
||||
// File writes stay on fs/promises, so they never block the loop.
|
||||
const threadCpuMs = () => {
|
||||
const { user, system } = process.threadCpuUsage();
|
||||
return (user + system) / 1000;
|
||||
};
|
||||
let previous = threadCpuMs();
|
||||
let longest = 0;
|
||||
const probe = setInterval(() => {
|
||||
const now = performance.now();
|
||||
const now = threadCpuMs();
|
||||
longest = Math.max(longest, now - previous);
|
||||
previous = now;
|
||||
}, 1);
|
||||
try {
|
||||
const result = await materializeProviderImage({ data, mimeType: "image/png" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
console.log(`64 MiB attachment longest loop gap: ${longest.toFixed(2)} ms`);
|
||||
console.log(`64 MiB attachment longest blocking stretch: ${longest.toFixed(2)} ms CPU`);
|
||||
expect(longest).toBeLessThan(50);
|
||||
expect(readFileSync(result.path).equals(bytes)).toBe(true);
|
||||
rmSync(result.path, { force: true });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.14.0-beta.2",
|
||||
"version": "0.14.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.14.0-beta.2",
|
||||
"version": "0.14.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user