mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-11 19:05:16 +00:00
fix(pi): track resumed pi-subagents runs and transcripts (#9261)
* 修复 Pi 恢复子任务的状态和对话跟踪
关联 #9200。
改动:
- 仅在 action:resume 确认成功启动新的 single async run 后登记独立子任务条目,按新 run ID 和 asyncDir 跟踪状态,拒绝失败及管理调用产生的假条目。
- 将对话去重从文件级调整为子任务条目与文件组合,支持恢复运行复用 session JSONL,避免旧通知结束新运行。
- 扩展原适配器回归测试,并附脱敏的命令输出、真实工具结果和 macOS Electron 截图,未改 UI、协议或共享生命周期。
实际验证:
- 原适配器下恢复条目回归为 RED;恢复旧文件级去重后共享对话回归为 RED。
- 基线 d46ee6558c 上 npm run build:server、npm run format、npm run typecheck、npm run lint、git diff --check 均成功。
- 在 packages/server 执行 npx vitest run src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts --bail=1,21/21 通过。
- 已在此前 0.15.3 开发版中,通过真实 macOS Electron、Pi 1.5.0、pi-subagents 0.86.0 验证 A→B→C 恢复、运行条目和对话、完成状态、归档保留运行任务、失败恢复不产生假条目;普通启动走 CLI/API,恢复通过真实 composer。截图不是 0.15.4 发布包验收。
- 同步后适配器与测试文件字节和第三方锁定依赖均未变,复用独立代码评审与上述桌面证据。公开材料已做隐私扫描。
- 未执行全量测试(仓库禁止本地全量执行)、CI、其他平台或发布安装包验证;短暂重复 Shell 文案原因未定位,不宣称本次修复。
回滚:
- 本提交仍为 HEAD 时执行 git revert HEAD;否则先用 git log --oneline --grep='修复 Pi 恢复子任务的状态和对话跟踪' 确认本提交哈希,再 git revert <确认的哈希>,不要重置或覆盖其他修改。
- 回滚后重跑该适配器测试并执行 npm run build:server;若需要部署回滚产物,另行安排服务切换,禁止未经授权重启正在使用的 Paseo daemon。
- 本提交仅改源码、测试和 QA 证据,无数据迁移;回滚不应删除已有 Pi 会话或异步目录。独立 QA 服务不由本提交启停。
* 补强 Pi 恢复对话的新消息与实时回放断言
回应 PR #9261 的 Greptile P2 评论 discussion_r4521416122。
改动:
- 只修改 pi-subagents/index.test.ts,按子任务 ID 提取 assistant_message 文本,精确检查 C 的历史与本轮内容。
- 观察 C reader 初始读取后再追加消息,在写入终态前验证新消息实时到达且 C 仍运行,避免只靠完成时刷新通过。
- 回放精确校验三条预期消息、顺序与 resume-c 归属,保留旧通知隔离、完成状态和 bg_wait 断言。
- 不修改生产实现、历史 QA 证据或普通启动行为。
实际验证:
- 临时在测试观察回调中丢弃 C 本轮消息:定向测试 exit 1,只有 Attempt b 的历史不能通过初始断言。
- 临时只丢弃 C 后追加消息:定向测试 exit 1,缺少 Attempt c follow-up 的断言在写入完成状态前失败。
- 两次故障注入已移除;最终在 packages/server 执行 npx vitest run src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts --bail=1,21/21 通过。
- npm run format、npm run typecheck、npm run lint 和 git diff --check 均通过;全仓 lint 为 0 warnings / 0 errors。
- LSP 推断项目模式仅报告未修改旧测试行的两处 results 类型警告;项目正式 typecheck 通过。
- 未运行全量测试、远端 CI 或重复桌面验收。本次只有测试变更,复用原产品评审和真实桌面验证,不将临时故障注入当作生产缺陷。
回滚:
- 本提交仍为 HEAD 时执行 git revert HEAD;否则用 git log --oneline --grep='补强 Pi 恢复对话的新消息与实时回放断言' 确认哈希后 git revert <该哈希>。
- 回滚后重跑上述适配器测试;这只撤销测试增强,不应回滚先前 #9200 产品修复,也无需重启 daemon 或删除任何会话数据。
This commit is contained in:
@@ -0,0 +1,67 @@
|
|||||||
|
# Pi resumed subagents — #9200
|
||||||
|
|
||||||
|
## Scope and provenance
|
||||||
|
|
||||||
|
An AI agent ran this QA through Playwright/CDP attached to the **real macOS Electron renderer**, using an isolated desktop-managed daemon. The user authorized the run; this is not a claim that the user personally operated the UI. No production daemon, installed package, or daily host configuration was changed.
|
||||||
|
|
||||||
|
Desktop evidence was captured on the modified **0.15.3 development checkout** at base `e260f93cc7b6098f6b175bd0e5c7eb24d3f03cf4`, with Electron 44.2.0, Pi 1.5.0, and installed pi-subagents 0.86.0. The final branch was subsequently fast-forwarded to `d46ee6558c043f9fb377d20f05ff662ce27a31f6` (0.15.4). The adapter and test file stayed byte-for-byte identical. The lockfile changes only workspace version references; third-party dependency entries did not change. Server declarations were rebuilt and the checks below passed on the final base. **These screenshots are not a 0.15.4 release-package test.**
|
||||||
|
|
||||||
|
## Before and automated checks
|
||||||
|
|
||||||
|
Issue #9200 reports ordinary async spawn appearing in the track while successive successful resumes disappear. The original adapter rejects any `action` and requires top-level `agent`/`task`, which a resume does not supply. A separate file-only transcript deduplication also prevents a new attempt row from following Pi's reused session file.
|
||||||
|
|
||||||
|
[Captured command output](validation.txt) contains:
|
||||||
|
|
||||||
|
- RED on the original adapter: a successful resume returns no descriptor instead of a running row.
|
||||||
|
- RED on the intermediate implementation with old file-only deduplication: the next resumed row gets no transcript.
|
||||||
|
- Final **21/21** tests passing, including consecutive resumes with a real shared temporary JSONL, polling, notice/replay routing, late old-notice isolation, `bg_wait`, failed/management calls, missing launch identity, and the task alias. Existing spawn fixtures still pass.
|
||||||
|
- Full `npm run format`, `npm run typecheck`, `npm run lint`, and `git diff --check` passing after the base update.
|
||||||
|
|
||||||
|
Only the specific adapter test file was run, not the full suite. CI has not been run. There is no old-desktop before screenshot; the reported failure and the two actual RED runs are the before evidence.
|
||||||
|
|
||||||
|
## Real desktop steps and observations
|
||||||
|
|
||||||
|
1. Built server/app dependencies and launched the existing desktop dev runner with a separate home, Electron userData, Pi configuration/session directory, and async root. The final endpoints were loopback-only: daemon 6768, Metro 8082, and CDP 9223. The renderer's desktop bridge confirmed its own managed daemon/home/server identity. Only the existing pi-subagents extension and a marker-only test agent were enabled; child tasks used `sleep`/`printf`, not workspace edits.
|
||||||
|
2. Created a Pi parent through the normal Paseo **CLI/API**, asking it to call `subagent({agent:"qa-marker", task, async:true})`. This produced A. The model first enabled the subagent tool, then performed the ordinary spawn. This step was not submitted through the composer.
|
||||||
|
3. Used the real Electron **composer** to ask Pi for `subagent({action:"resume", id:A, message})`, then resumed B the same way to produce C. No top-level `agent`, `task`, or `async` was supplied to these resume calls. All three finished with their requested markers.
|
||||||
|
4. Opened the provider-owned track while C ran: it showed `1 working`, the follow-up description, and `qa-marker`. Opening that row showed the reused child conversation and the current Shell call. Each resume had a new run/async directory, while all attempts reused the same session JSONL.
|
||||||
|
5. While C was still running, clicked **Archive finished subagents**. Completed A/B rows disappeared, while C remained visible as `1 working`.
|
||||||
|
6. Submitted a nonexistent resume ID through the composer. Pi returned an error and the parent reported “not found”; no new row or async run appeared.
|
||||||
|
7. The first B screenshot was taken after B finished because screenshot capture waited for fonts. It is not offered as running-state proof. A supplementary real resume from A, B_OBSERVE, was captured as `1 working` and opened successfully. It also finished. The final track contained exactly the four successful runs, not a fifth phantom from the failed resume.
|
||||||
|
|
||||||
|
The [selected real tool arguments/results, final statuses, and four completion notices](desktop-observations.json) preserve the test UUIDs. B's `revivedFrom` is A; C's is B. This file contains selected fields from persisted Pi entries/status files, not synthesized launch details. The [dialogue DOM excerpt](dialogue-excerpt.txt) records opening a resumed child. Test-root paths are replaced with `<QA_ROOT>` and the encoded workspace directory with `<QA_WORKSPACE>`; no auth or models configuration is included.
|
||||||
|
|
||||||
|
### Screenshots
|
||||||
|
|
||||||
|
Unmodified captures, except for the explicitly described dialogue crop. They contain only isolated test prompts, test UUIDs, and app UI; no account, credentials, or user-home paths.
|
||||||
|
|
||||||
|
**C running in the native provider-owned track:**
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**After archiving finished rows, C remains running:**
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Opened C conversation, current Shell call and parent link:** this is an unaltered crop of the bottom 880×245 pixels of the real child-pane capture, omitting the long temporary session path above it. No state was redrawn.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Final four completed rows, with the failed resume reported above:**
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Coverage and limits
|
||||||
|
|
||||||
|
| Surface | Tested |
|
||||||
|
| ---------------------------------- | ------------------------------------------------------------------------- |
|
||||||
|
| macOS Electron development desktop | Yes — real renderer, composer, track, child pane, archive-finished action |
|
||||||
|
| Pi RPC / Paseo WebSocket / daemon | Yes — real provider calls and persisted tool results/notices |
|
||||||
|
| Standalone browser web | No |
|
||||||
|
| iOS / Android | No |
|
||||||
|
| Windows / Linux desktop | No |
|
||||||
|
| Packaged release / CI | No |
|
||||||
|
|
||||||
|
Each resumed attempt intentionally gets its own row and reads the whole reused child transcript, including earlier history; it does not slice history by attempt. Old-notice isolation is also covered by the automated regression and the earlier real-notice host replay, rather than forcing a delayed notification in the desktop run.
|
||||||
|
|
||||||
|
Short-lived duplicate Shell text was observed in the streaming child pane; its cause was not established and it is not claimed fixed here. The earlier direct-SDK smoke's turn-end persistence error did not recur through this normal RPC run. All four test runners exited, the parent settled idle, and the temporary workspace stayed empty. No claims are made about old workflow issues or daemon-restart global adoption.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 111 KiB |
@@ -0,0 +1,157 @@
|
|||||||
|
{
|
||||||
|
"capture": "Selected fields from actual Pi session entries and final status files; omitted unrelated metadata. Test UUIDs unchanged; all test-root absolute paths replaced with <QA_ROOT>.",
|
||||||
|
"calls": [
|
||||||
|
{
|
||||||
|
"timestamp": "2026-11-10T04:08:31.691Z",
|
||||||
|
"toolCallId": "call_dab88a12b4db419586ac9d0eab565ca8|fc_0e57afa06f701795016af298be50c487d0844c674c8a61fa94",
|
||||||
|
"arguments": {
|
||||||
|
"agent": "qa-marker",
|
||||||
|
"task": "Run sleep 18 then printf PASEO_DESKTOP_A; do not read or write any files",
|
||||||
|
"async": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-11-10T04:08:32.010Z",
|
||||||
|
"toolCallId": "call_dab88a12b4db419586ac9d0eab565ca8|fc_0e57afa06f701795016af298be50c487d0844c674c8a61fa94",
|
||||||
|
"isError": false,
|
||||||
|
"details": {
|
||||||
|
"mode": "single",
|
||||||
|
"runId": "832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
|
||||||
|
"asyncId": "832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
|
||||||
|
"asyncDir": "<QA_ROOT>/async/async-subagent-runs/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
|
||||||
|
"results": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-11-10T04:10:22.901Z",
|
||||||
|
"toolCallId": "call_b636f1714b83481ba5b4ab0aee32b239|fc_0e57afa06f701795016af2992d215087d099231229062fa446",
|
||||||
|
"arguments": {
|
||||||
|
"action": "resume",
|
||||||
|
"id": "832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
|
||||||
|
"message": "Run sleep 18 then printf PASEO_DESKTOP_B; do not read or write files"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-11-10T04:10:23.219Z",
|
||||||
|
"toolCallId": "call_b636f1714b83481ba5b4ab0aee32b239|fc_0e57afa06f701795016af2992d215087d099231229062fa446",
|
||||||
|
"isError": false,
|
||||||
|
"details": {
|
||||||
|
"mode": "single",
|
||||||
|
"runId": "8ca757d4-7514-4e87-8eeb-af52e2228ccb",
|
||||||
|
"asyncId": "8ca757d4-7514-4e87-8eeb-af52e2228ccb",
|
||||||
|
"asyncDir": "<QA_ROOT>/async/async-subagent-runs/8ca757d4-7514-4e87-8eeb-af52e2228ccb",
|
||||||
|
"results": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-11-10T04:13:36.923Z",
|
||||||
|
"toolCallId": "call_8e4ef481e0964e25bbdad4defe620e12|fc_0e57afa06f701795016af299ef47c887d0ac84e70bd50ffc5a",
|
||||||
|
"arguments": {
|
||||||
|
"action": "resume",
|
||||||
|
"id": "8ca757d4-7514-4e87-8eeb-af52e2228ccb",
|
||||||
|
"message": "Run bash sleep 45 then printf PASEO_DESKTOP_C; do not read or write files"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-11-10T04:13:37.597Z",
|
||||||
|
"toolCallId": "call_8e4ef481e0964e25bbdad4defe620e12|fc_0e57afa06f701795016af299ef47c887d0ac84e70bd50ffc5a",
|
||||||
|
"isError": false,
|
||||||
|
"details": {
|
||||||
|
"mode": "single",
|
||||||
|
"runId": "f41865ed-0e62-44b5-a825-27ac4f0aa596",
|
||||||
|
"asyncId": "f41865ed-0e62-44b5-a825-27ac4f0aa596",
|
||||||
|
"asyncDir": "<QA_ROOT>/async/async-subagent-runs/f41865ed-0e62-44b5-a825-27ac4f0aa596",
|
||||||
|
"results": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-11-10T04:16:53.099Z",
|
||||||
|
"toolCallId": "call_60e8fa68de0b436fad2aaf8cc391dae2|fc_0e57afa06f701795016af29ab2b1b487d09020677eaaf9086e",
|
||||||
|
"arguments": {
|
||||||
|
"action": "resume",
|
||||||
|
"id": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"message": "Output PASEO_INVALID"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-11-10T04:16:53.109Z",
|
||||||
|
"toolCallId": "call_60e8fa68de0b436fad2aaf8cc391dae2|fc_0e57afa06f701795016af29ab2b1b487d09020677eaaf9086e",
|
||||||
|
"isError": true,
|
||||||
|
"details": {},
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "Async run not found for '00000000-0000-0000-0000-000000000000'."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-11-10T04:17:07.330Z",
|
||||||
|
"toolCallId": "call_e6eb985f16b24308be82922d6a1d7a57|fc_0e57afa06f701795016af29ac17d5c87d0b79c8763512f43da",
|
||||||
|
"arguments": {
|
||||||
|
"action": "resume",
|
||||||
|
"id": "832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
|
||||||
|
"message": "Run bash sleep 45 then printf PASEO_DESKTOP_B_OBSERVE; do not read or write files"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-11-10T04:17:07.668Z",
|
||||||
|
"toolCallId": "call_e6eb985f16b24308be82922d6a1d7a57|fc_0e57afa06f701795016af29ac17d5c87d0b79c8763512f43da",
|
||||||
|
"isError": false,
|
||||||
|
"details": {
|
||||||
|
"mode": "single",
|
||||||
|
"runId": "01ac3b2e-2ed6-472d-8438-4dfb91ea62f7",
|
||||||
|
"asyncId": "01ac3b2e-2ed6-472d-8438-4dfb91ea62f7",
|
||||||
|
"asyncDir": "<QA_ROOT>/async/async-subagent-runs/01ac3b2e-2ed6-472d-8438-4dfb91ea62f7",
|
||||||
|
"results": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"finalStatuses": [
|
||||||
|
{
|
||||||
|
"runId": "01ac3b2e-2ed6-472d-8438-4dfb91ea62f7",
|
||||||
|
"state": "complete",
|
||||||
|
"revivedFrom": "832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
|
||||||
|
"sessionFile": "<QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"runId": "832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
|
||||||
|
"state": "complete",
|
||||||
|
"sessionFile": "<QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"runId": "8ca757d4-7514-4e87-8eeb-af52e2228ccb",
|
||||||
|
"state": "complete",
|
||||||
|
"revivedFrom": "832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
|
||||||
|
"sessionFile": "<QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"runId": "f41865ed-0e62-44b5-a825-27ac4f0aa596",
|
||||||
|
"state": "complete",
|
||||||
|
"revivedFrom": "8ca757d4-7514-4e87-8eeb-af52e2228ccb",
|
||||||
|
"sessionFile": "<QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"completionNotices": [
|
||||||
|
{
|
||||||
|
"timestamp": "2026-11-10T04:09:07.245Z",
|
||||||
|
"customType": "subagent-notify",
|
||||||
|
"content": "Background task completed: **qa-marker**\n\nqa-marker:\nPASEO_DESKTOP_A\n\nRetention-managed async directory: <QA_ROOT>/async/async-subagent-runs/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122\n\nSession file: <QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl\n\nParent action: Read the saved results above and resume the already-authorized parent task, or report completion. If approval is required, explicitly ask the user. Do not silently yield, rerun completed work, or infer new authorization."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-11-10T04:10:53.423Z",
|
||||||
|
"customType": "subagent-notify",
|
||||||
|
"content": "Background task completed: **qa-marker**\n\nqa-marker:\nPASEO_DESKTOP_B\n\nRetention-managed async directory: <QA_ROOT>/async/async-subagent-runs/8ca757d4-7514-4e87-8eeb-af52e2228ccb\n\nSession file: <QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl\n\nParent action: Read the saved results above and resume the already-authorized parent task, or report completion. If approval is required, explicitly ask the user. Do not silently yield, rerun completed work, or infer new authorization."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-11-10T04:14:37.834Z",
|
||||||
|
"customType": "subagent-notify",
|
||||||
|
"content": "Background task completed: **qa-marker**\n\nqa-marker:\nPASEO_DESKTOP_C\n\nRetention-managed async directory: <QA_ROOT>/async/async-subagent-runs/f41865ed-0e62-44b5-a825-27ac4f0aa596\n\nSession file: <QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl\n\nParent action: Read the saved results above and resume the already-authorized parent task, or report completion. If approval is required, explicitly ask the user. Do not silently yield, rerun completed work, or infer new authorization."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-11-10T04:17:37.887Z",
|
||||||
|
"customType": "subagent-notify",
|
||||||
|
"content": "Background task completed: **qa-marker**\n\nqa-marker:\nPASEO_DESKTOP_B_OBSERVE\n\nRetention-managed async directory: <QA_ROOT>/async/async-subagent-runs/01ac3b2e-2ed6-472d-8438-4dfb91ea62f7\n\nSession file: <QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl\n\nParent action: Read the saved results above and resume the already-authorized parent task, or report completion. If approval is required, explicitly ask the user. Do not silently yield, rerun completed work, or infer new authorization."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
Excerpt of actual Electron renderer DOM captured after opening the resumed child.
|
||||||
|
Test-root paths replaced with <QA_ROOT>; unrelated earlier history omitted.
|
||||||
|
|
||||||
|
Task: You are reviving a previous subagent conversation.
|
||||||
|
|
||||||
|
Original run: 832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122
|
||||||
|
Original agent: qa-marker
|
||||||
|
Original session file: <QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl
|
||||||
|
|
||||||
|
Use the stored session context as background. Answer the orchestrator's follow-up below. Do not assume the original child session is still running.
|
||||||
|
|
||||||
|
Follow-up:
|
||||||
|
Run bash sleep 45 then printf PASEO_DESKTOP_B_OBSERVE; do not read or write files
|
||||||
|
Show more
|
||||||
|
12:17 PM
|
||||||
|
QA #9200 — Pi resume A→B→C
|
||||||
|
Open parent
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
Captured command output. Only private absolute paths are replaced with <REPO_ROOT>/<USER_HOME>.
|
||||||
|
RED runs are intentional regression failures, not final failures.
|
||||||
|
|
||||||
|
=== Before fix: resume arguments rejected (expected RED, exit 1) ===
|
||||||
|
$ cd packages/server && npx vitest run src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts --bail=1
|
||||||
|
|
||||||
|
RUN v4.1.6 <REPO_ROOT>/packages/server
|
||||||
|
|
||||||
|
stdout | src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts
|
||||||
|
[dotenv@17.3.1] injecting env (0) from .env.test -- tip: ⚙️ suppress all logs with { quiet: true }
|
||||||
|
[dotenv@17.3.1] injecting env (0) from ../.env -- tip: ⚙️ suppress all logs with { quiet: true }
|
||||||
|
|
||||||
|
❯ src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts (10 tests | 1 failed) 7ms
|
||||||
|
× registers a successful resume as a new async attempt without spawn arguments 6ms
|
||||||
|
|
||||||
|
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
|
||||||
|
|
||||||
|
FAIL src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts > pi-subagents adapter > registers a successful resume as a new async attempt without spawn arguments
|
||||||
|
AssertionError: expected undefined to deeply equal [ { type: 'upsert', …(5) } ]
|
||||||
|
|
||||||
|
- Expected:
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"description": "Continue",
|
||||||
|
"id": "resume-b",
|
||||||
|
"status": "running",
|
||||||
|
"title": "Subagent",
|
||||||
|
"toolCallId": "resume-b",
|
||||||
|
"type": "upsert",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
+ Received:
|
||||||
|
undefined
|
||||||
|
|
||||||
|
❯ src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts:66:32
|
||||||
|
64| },
|
||||||
|
65| });
|
||||||
|
66| expect(resumed?.subagents).toEqual([
|
||||||
|
| ^
|
||||||
|
67| {
|
||||||
|
68| type: "upsert",
|
||||||
|
|
||||||
|
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
|
||||||
|
|
||||||
|
|
||||||
|
Test Files 1 failed (1)
|
||||||
|
Tests 1 failed (10)
|
||||||
|
Start at 21:05:21
|
||||||
|
Duration 474ms (transform 245ms, setup 31ms, import 328ms, tests 7ms, environment 0ms)
|
||||||
|
|
||||||
|
|
||||||
|
=== Intermediate implementation: old file-only transcript deduplication (expected RED, exit 1) ===
|
||||||
|
$ cd packages/server && npx vitest run src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts --bail=1
|
||||||
|
|
||||||
|
RUN v4.1.6 <REPO_ROOT>/packages/server
|
||||||
|
|
||||||
|
stdout | src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts
|
||||||
|
[dotenv@17.3.1] injecting env (0) from .env.test -- tip: ⚡️ secrets for agents: https://dotenvx.com/as2
|
||||||
|
[dotenv@17.3.1] injecting env (0) from ../.env -- tip: ⚙️ override existing env vars with { override: true }
|
||||||
|
|
||||||
|
❯ src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts (16 tests | 1 failed) 1329ms
|
||||||
|
× follows consecutive resumed attempts and keeps stale notices on their own rows 1320ms
|
||||||
|
|
||||||
|
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
|
||||||
|
|
||||||
|
FAIL src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts > pi-subagents adapter > follows consecutive resumed attempts and keeps stale notices on their own rows
|
||||||
|
AssertionError: expected false to be true // Object.is equality
|
||||||
|
|
||||||
|
- Expected
|
||||||
|
+ Received
|
||||||
|
|
||||||
|
- true
|
||||||
|
+ false
|
||||||
|
|
||||||
|
❯ src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts:155:78
|
||||||
|
153| ]);
|
||||||
|
154| replay.mapToolCall(call);
|
||||||
|
155| await expect.poll(() => hasChildTimeline(received, `resume-${r…
|
||||||
|
| ^
|
||||||
|
156| }
|
||||||
|
157| const oldNotice = {
|
||||||
|
|
||||||
|
Caused by: Error: Matcher did not succeed in time.
|
||||||
|
❯ src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts:155:9
|
||||||
|
|
||||||
|
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
|
||||||
|
|
||||||
|
|
||||||
|
Test Files 1 failed (1)
|
||||||
|
Tests 1 failed | 6 passed (16)
|
||||||
|
Start at 21:09:04
|
||||||
|
Duration 1.85s (transform 247ms, setup 24ms, import 389ms, tests 1.33s, environment 0ms)
|
||||||
|
|
||||||
|
|
||||||
|
=== Final baseline d46ee655: GREEN, exit 0 ===
|
||||||
|
$ cd packages/server && npx vitest run src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts --bail=1
|
||||||
|
|
||||||
|
RUN v4.1.6 <REPO_ROOT>/packages/server
|
||||||
|
|
||||||
|
|
||||||
|
Test Files 1 passed (1)
|
||||||
|
Tests 21 passed (21)
|
||||||
|
Start at 13:07:04
|
||||||
|
Duration 1.97s (transform 256ms, setup 26ms, import 355ms, tests 1.45s, environment 0ms)
|
||||||
|
|
||||||
|
|
||||||
|
=== Final typecheck, exit 0 ===
|
||||||
|
$ npm run typecheck
|
||||||
|
|
||||||
|
> paseo@0.15.4 typecheck
|
||||||
|
> npm run typecheck --workspaces --if-present
|
||||||
|
|
||||||
|
|
||||||
|
> @getpaseo/builtin-plugins@0.15.4 typecheck
|
||||||
|
> tsgo -p tsconfig.json --noEmit
|
||||||
|
|
||||||
|
|
||||||
|
> @getpaseo/expo-two-way-audio@0.15.4 typecheck
|
||||||
|
> tsgo --noEmit
|
||||||
|
|
||||||
|
|
||||||
|
> @getpaseo/highlight@0.15.4 typecheck
|
||||||
|
> tsgo --noEmit
|
||||||
|
|
||||||
|
|
||||||
|
> @getpaseo/plugin@0.15.4 typecheck
|
||||||
|
> tsgo --noEmit && npm run typecheck:examples
|
||||||
|
|
||||||
|
|
||||||
|
> @getpaseo/plugin@0.15.4 typecheck:examples
|
||||||
|
> npm --prefix ../../plugin-examples/markdown-panel install --ignore-scripts --no-audit --no-fund && tsgo -p tsconfig.examples.json --noEmit
|
||||||
|
|
||||||
|
|
||||||
|
up to date in 433ms
|
||||||
|
|
||||||
|
> @getpaseo/protocol@0.15.4 pretypecheck
|
||||||
|
> npm run generate:validators
|
||||||
|
|
||||||
|
|
||||||
|
> @getpaseo/protocol@0.15.4 generate:validators
|
||||||
|
> node scripts/generate-validation-aot.mjs
|
||||||
|
|
||||||
|
generated src/generated/validation/ws-outbound.aot.ts: envelope + 250 session message validators
|
||||||
|
|
||||||
|
> @getpaseo/protocol@0.15.4 typecheck
|
||||||
|
> tsgo --noEmit
|
||||||
|
|
||||||
|
|
||||||
|
> @getpaseo/client@0.15.4 typecheck
|
||||||
|
> tsgo --noEmit
|
||||||
|
|
||||||
|
|
||||||
|
> @getpaseo/server@0.15.4 typecheck
|
||||||
|
> tsgo -p tsconfig.server.typecheck.json --noEmit
|
||||||
|
|
||||||
|
|
||||||
|
> @getpaseo/app@0.15.4 typecheck
|
||||||
|
> tsgo --noEmit
|
||||||
|
|
||||||
|
|
||||||
|
> @getpaseo/relay@0.15.4 typecheck
|
||||||
|
> tsgo --noEmit
|
||||||
|
|
||||||
|
|
||||||
|
> @getpaseo/website@0.15.4 typecheck
|
||||||
|
> tsgo --noEmit
|
||||||
|
|
||||||
|
|
||||||
|
> @getpaseo/desktop@0.15.4 typecheck
|
||||||
|
> tsgo --noEmit -p tsconfig.json
|
||||||
|
|
||||||
|
|
||||||
|
> @getpaseo/cli@0.15.4 typecheck
|
||||||
|
> tsgo --noEmit
|
||||||
|
|
||||||
|
|
||||||
|
=== Final lint, exit 0 ===
|
||||||
|
$ npm run lint
|
||||||
|
|
||||||
|
> paseo@0.15.4 lint
|
||||||
|
> oxlint
|
||||||
|
|
||||||
|
Found 0 warnings and 0 errors.
|
||||||
|
Finished in 2.3s on 5442 files with 177 rules using 12 threads.
|
||||||
|
|
||||||
|
=== Final full formatter, exit 0; no unrelated changes ===
|
||||||
|
$ npm run format
|
||||||
|
|
||||||
|
> paseo@0.15.4 format
|
||||||
|
> oxfmt .
|
||||||
|
|
||||||
|
Finished in 2684ms on 5928 files using 12 threads.
|
||||||
|
|
||||||
|
$ git diff --check
|
||||||
|
(exit 0; no output)
|
||||||
+265
-3
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
import { mkdtemp, writeFile, appendFile, rm, mkdir } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { createPiExtensionHost } from "../index.js";
|
import { createPiExtensionHost } from "../index.js";
|
||||||
@@ -30,12 +30,36 @@ function mapToolDetail(toolCall: PiTrackedToolCall, result: PiToolResult) {
|
|||||||
return mapping(toolCall, result)?.detail;
|
return mapping(toolCall, result)?.detail;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasChildTimeline(events: AgentStreamEvent[]) {
|
function hasChildTimeline(events: AgentStreamEvent[], id?: string) {
|
||||||
return events.some(
|
return events.some(
|
||||||
(event) => event.type === "provider_subagent" && event.event.type === "timeline",
|
(event) =>
|
||||||
|
event.type === "provider_subagent" &&
|
||||||
|
event.event.type === "timeline" &&
|
||||||
|
(!id || event.event.id === id),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function childAssistantTexts(events: AgentStreamEvent[], id: string) {
|
||||||
|
return events.flatMap((event) => {
|
||||||
|
if (
|
||||||
|
event.type === "provider_subagent" &&
|
||||||
|
event.event.type === "timeline" &&
|
||||||
|
event.event.id === id &&
|
||||||
|
event.event.item.type === "assistant_message"
|
||||||
|
)
|
||||||
|
return [event.event.item.text];
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastChildUpsert(events: AgentStreamEvent[], id: string) {
|
||||||
|
const last = events.findLast(
|
||||||
|
(event) =>
|
||||||
|
event.type === "provider_subagent" && event.event.type === "upsert" && event.event.id === id,
|
||||||
|
);
|
||||||
|
return last?.type === "provider_subagent" ? last.event : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function hasCompletedChild(events: AgentStreamEvent[]) {
|
function hasCompletedChild(events: AgentStreamEvent[]) {
|
||||||
return events.some(
|
return events.some(
|
||||||
(event) =>
|
(event) =>
|
||||||
@@ -46,6 +70,244 @@ function hasCompletedChild(events: AgentStreamEvent[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("pi-subagents adapter", () => {
|
describe("pi-subagents adapter", () => {
|
||||||
|
test("registers a successful resume as a new async attempt without spawn arguments", () => {
|
||||||
|
const host = createPiExtensionHost();
|
||||||
|
const resumed = host.mapToolCall({
|
||||||
|
callId: "resume-b",
|
||||||
|
toolName: "subagent",
|
||||||
|
args: { action: "resume", id: "run-a", message: "Continue" },
|
||||||
|
status: "completed",
|
||||||
|
result: {
|
||||||
|
details: {
|
||||||
|
mode: "single",
|
||||||
|
runId: "run-b",
|
||||||
|
asyncId: "run-b",
|
||||||
|
asyncDir: "/tmp/paseo-resume-b",
|
||||||
|
results: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(resumed?.subagents).toEqual([
|
||||||
|
{
|
||||||
|
type: "upsert",
|
||||||
|
id: "resume-b",
|
||||||
|
title: "Subagent",
|
||||||
|
description: "Continue",
|
||||||
|
toolCallId: "resume-b",
|
||||||
|
status: "running",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
test.each([
|
||||||
|
{ action: "resume", status: "running" as const, mode: "single" },
|
||||||
|
{ action: "resume", status: "failed" as const, mode: "single" },
|
||||||
|
{ action: "resume", status: "completed" as const, mode: "management" },
|
||||||
|
{ action: "status", status: "completed" as const, mode: "single" },
|
||||||
|
{ action: "steer", status: "completed" as const, mode: "single" },
|
||||||
|
])("does not create phantom rows for $action/$status/$mode", ({ action, status, mode }) => {
|
||||||
|
expect(
|
||||||
|
createPiExtensionHost().mapToolCall({
|
||||||
|
callId: "management",
|
||||||
|
toolName: "subagent",
|
||||||
|
args: { action, id: "run-a", message: "Continue" },
|
||||||
|
status,
|
||||||
|
result: {
|
||||||
|
details: { mode, asyncId: "run-b", asyncDir: "/tmp/paseo-resume-b", results: [] },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
{ mode: "single", results: [] },
|
||||||
|
{ mode: "single", asyncId: "run-b", results: [] },
|
||||||
|
{ mode: "single", asyncDir: "/tmp/paseo-resume-b", results: [] },
|
||||||
|
{
|
||||||
|
mode: "single",
|
||||||
|
asyncId: "run-b",
|
||||||
|
asyncDir: "/tmp/paseo-resume-b",
|
||||||
|
results: [{ agent: "scout", exitCode: 0 }],
|
||||||
|
},
|
||||||
|
])("does not register a resume without a new async launch: %j", (details) => {
|
||||||
|
expect(
|
||||||
|
createPiExtensionHost().mapToolCall({
|
||||||
|
callId: "not-launched",
|
||||||
|
toolName: "subagent",
|
||||||
|
args: { action: "resume", id: "run-a", message: "Continue" },
|
||||||
|
status: "completed",
|
||||||
|
result: { details },
|
||||||
|
}),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses the resume task alias as the follow-up description", () => {
|
||||||
|
expect(
|
||||||
|
createPiExtensionHost().mapToolCall({
|
||||||
|
callId: "resume-task",
|
||||||
|
toolName: "subagent",
|
||||||
|
args: { action: "resume", id: "source-prefix", task: "Continue via task" },
|
||||||
|
status: "completed",
|
||||||
|
result: {
|
||||||
|
details: {
|
||||||
|
mode: "single",
|
||||||
|
asyncId: "run-b",
|
||||||
|
asyncDir: "/tmp/paseo-resume-b",
|
||||||
|
results: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})?.subagents,
|
||||||
|
).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "resume-task",
|
||||||
|
description: "Continue via task",
|
||||||
|
status: "running",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("follows consecutive resumed attempts and keeps stale notices on their own rows", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "paseo-pi-resume-"));
|
||||||
|
const host = createPiExtensionHost();
|
||||||
|
const replay = createPiExtensionHost();
|
||||||
|
const received: AgentStreamEvent[] = [];
|
||||||
|
try {
|
||||||
|
host.follow((event) => received.push(event));
|
||||||
|
const initialTexts: string[] = [];
|
||||||
|
for (const [run, source] of [
|
||||||
|
["b", "a"],
|
||||||
|
["c", "b"],
|
||||||
|
]) {
|
||||||
|
const dir = join(root, run);
|
||||||
|
const file = join(root, "shared-child.jsonl");
|
||||||
|
await mkdir(dir);
|
||||||
|
await appendFile(
|
||||||
|
file,
|
||||||
|
JSON.stringify({
|
||||||
|
type: "message",
|
||||||
|
message: { role: "assistant", content: [{ type: "text", text: `Attempt ${run}` }] },
|
||||||
|
}) + "\n",
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
join(dir, "status.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
state: "running",
|
||||||
|
revivedFrom: `run-${source}`,
|
||||||
|
steps: [{ agent: "scout", status: "running", sessionFile: file }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const call = {
|
||||||
|
callId: `resume-${run}`,
|
||||||
|
toolName: "subagent",
|
||||||
|
args: { action: "resume", id: `run-${source}`, message: `Continue ${run}` },
|
||||||
|
status: "completed" as const,
|
||||||
|
result: {
|
||||||
|
details: {
|
||||||
|
mode: "single",
|
||||||
|
runId: `run-${run}`,
|
||||||
|
asyncId: `run-${run}`,
|
||||||
|
asyncDir: dir,
|
||||||
|
results: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(host.mapToolCall(call)?.subagents).toEqual([
|
||||||
|
expect.objectContaining({ id: `resume-${run}`, status: "running" }),
|
||||||
|
]);
|
||||||
|
replay.mapToolCall(call);
|
||||||
|
initialTexts.push(`Attempt ${run}`);
|
||||||
|
await expect
|
||||||
|
.poll(() => childAssistantTexts(received, `resume-${run}`))
|
||||||
|
.toEqual(initialTexts);
|
||||||
|
}
|
||||||
|
const oldNotice = {
|
||||||
|
role: "custom" as const,
|
||||||
|
customType: "subagent-notify",
|
||||||
|
content: `Background task completed: **scout**\nRetention-managed async directory: ${join(root, "b")}\nSession file: ${join(root, "shared-child.jsonl")}`,
|
||||||
|
};
|
||||||
|
expect(host.mapCustomMessage(oldNotice)?.subagents).toEqual([
|
||||||
|
{ type: "upsert", id: "resume-b", status: "completed" },
|
||||||
|
]);
|
||||||
|
const oldReplay = replay.mapCustomMessage(oldNotice);
|
||||||
|
expect(oldReplay?.subagents).toEqual([
|
||||||
|
{ type: "upsert", id: "resume-b", status: "completed" },
|
||||||
|
]);
|
||||||
|
expect(hasChildTimeline(await oldReplay!.hydration)).toBe(true);
|
||||||
|
expect(lastChildUpsert(received, "resume-c")).toEqual({
|
||||||
|
type: "upsert",
|
||||||
|
id: "resume-c",
|
||||||
|
title: "scout",
|
||||||
|
status: "running",
|
||||||
|
});
|
||||||
|
await appendFile(
|
||||||
|
join(root, "shared-child.jsonl"),
|
||||||
|
JSON.stringify({
|
||||||
|
type: "message",
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "Attempt c follow-up" }],
|
||||||
|
},
|
||||||
|
}) + "\n",
|
||||||
|
);
|
||||||
|
const expectedCTexts = ["Attempt b", "Attempt c", "Attempt c follow-up"];
|
||||||
|
await expect.poll(() => childAssistantTexts(received, "resume-c")).toEqual(expectedCTexts);
|
||||||
|
expect(lastChildUpsert(received, "resume-c")).toEqual({
|
||||||
|
type: "upsert",
|
||||||
|
id: "resume-c",
|
||||||
|
title: "scout",
|
||||||
|
status: "running",
|
||||||
|
});
|
||||||
|
await writeFile(
|
||||||
|
join(root, "c", "status.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
state: "complete",
|
||||||
|
steps: [
|
||||||
|
{ agent: "scout", status: "complete", sessionFile: join(root, "shared-child.jsonl") },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await expect
|
||||||
|
.poll(() => lastChildUpsert(received, "resume-c"))
|
||||||
|
.toEqual(expect.objectContaining({ status: "completed" }));
|
||||||
|
const notice = replay.mapCustomMessage({
|
||||||
|
...oldNotice,
|
||||||
|
content: `Background task completed: **scout**\nRetention-managed async directory: ${join(root, "c")}\nSession file: ${join(root, "shared-child.jsonl")}`,
|
||||||
|
});
|
||||||
|
expect(notice?.subagents).toEqual([{ type: "upsert", id: "resume-c", status: "completed" }]);
|
||||||
|
const hydration = await notice!.hydration;
|
||||||
|
expect(childAssistantTexts(hydration, "resume-c")).toEqual(expectedCTexts);
|
||||||
|
expect(hydration).toEqual(
|
||||||
|
expectedCTexts.map((text) => ({
|
||||||
|
type: "provider_subagent",
|
||||||
|
provider: "pi",
|
||||||
|
event: {
|
||||||
|
type: "timeline",
|
||||||
|
id: "resume-c",
|
||||||
|
item: expect.objectContaining({ type: "assistant_message", text }),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
host.mapToolCall({
|
||||||
|
callId: "wait-c",
|
||||||
|
toolName: "bg_wait",
|
||||||
|
args: {},
|
||||||
|
status: "completed",
|
||||||
|
result: {
|
||||||
|
details: {
|
||||||
|
mode: "wait",
|
||||||
|
results: [],
|
||||||
|
completions: [{ runId: "run-c", results: [{ agent: "scout", exitCode: 0 }] }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})?.subagents,
|
||||||
|
).toEqual([expect.objectContaining({ id: "resume-c", status: "completed" })]);
|
||||||
|
} finally {
|
||||||
|
host.close();
|
||||||
|
replay.close();
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("follows async status and transcript without bg_wait", async () => {
|
test("follows async status and transcript without bg_wait", async () => {
|
||||||
const dir = await mkdtemp(join(tmpdir(), "paseo-pi-async-"));
|
const dir = await mkdtemp(join(tmpdir(), "paseo-pi-async-"));
|
||||||
const file = join(dir, "child.jsonl");
|
const file = join(dir, "child.jsonl");
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const Args = z
|
|||||||
task: z.string().trim().min(1).optional(),
|
task: z.string().trim().min(1).optional(),
|
||||||
async: z.boolean().optional(),
|
async: z.boolean().optional(),
|
||||||
action: z.unknown().optional(),
|
action: z.unknown().optional(),
|
||||||
|
message: z.string().optional(),
|
||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
const Row = z
|
const Row = z
|
||||||
@@ -71,6 +72,13 @@ export const piSubagents: PiExtension = {
|
|||||||
createSession: () => {
|
createSession: () => {
|
||||||
const callsByRun = new Map<string, string>();
|
const callsByRun = new Map<string, string>();
|
||||||
const readSessions = new Set<string>();
|
const readSessions = new Set<string>();
|
||||||
|
// Pi resumes append to the source transcript, but each attempt has its own timeline reader.
|
||||||
|
const rememberSession = (id: string, file: string) => {
|
||||||
|
const key = JSON.stringify([id, file]);
|
||||||
|
if (readSessions.has(key)) return false;
|
||||||
|
readSessions.add(key);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
/** Keyed by async directory, which both status.json and the completion notice carry. */
|
/** Keyed by async directory, which both status.json and the completion notice carry. */
|
||||||
const asyncRuns = new Map<string, { owner: string; polling: boolean }>();
|
const asyncRuns = new Map<string, { owner: string; polling: boolean }>();
|
||||||
const rememberAsyncRun = (details: z.infer<typeof Details>, call: PiExtensionToolCall) => {
|
const rememberAsyncRun = (details: z.infer<typeof Details>, call: PiExtensionToolCall) => {
|
||||||
@@ -99,8 +107,7 @@ export const piSubagents: PiExtension = {
|
|||||||
? "failed"
|
? "failed"
|
||||||
: "completed",
|
: "completed",
|
||||||
});
|
});
|
||||||
if (row.sessionFile && !readSessions.has(row.sessionFile)) {
|
if (row.sessionFile && rememberSession(id, row.sessionFile)) {
|
||||||
readSessions.add(row.sessionFile);
|
|
||||||
childSessions.push({ id, file: row.sessionFile });
|
childSessions.push({ id, file: row.sessionFile });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,10 +130,42 @@ export const piSubagents: PiExtension = {
|
|||||||
}
|
}
|
||||||
return subagents.length ? { subagents, childSessions } : undefined;
|
return subagents.length ? { subagents, childSessions } : undefined;
|
||||||
};
|
};
|
||||||
|
const mapResume = (call: PiExtensionToolCall, description?: string) => {
|
||||||
|
// A resume can reject or only reattach to existing work. Declare a new attempt only once
|
||||||
|
// Pi confirms it launched an async single run; its new directory owns all later signals.
|
||||||
|
if (call.status !== "completed") return undefined;
|
||||||
|
const details = Details.safeParse(
|
||||||
|
typeof call.result === "object" ? call.result?.details : null,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!details.success ||
|
||||||
|
details.data.mode !== "single" ||
|
||||||
|
!details.data.asyncId ||
|
||||||
|
!details.data.asyncDir ||
|
||||||
|
details.data.results.length !== 0
|
||||||
|
)
|
||||||
|
return undefined;
|
||||||
|
callsByRun.set(details.data.runId ?? details.data.asyncId, call.callId);
|
||||||
|
rememberAsyncRun(details.data, call);
|
||||||
|
return {
|
||||||
|
subagents: [
|
||||||
|
{
|
||||||
|
type: "upsert" as const,
|
||||||
|
id: call.callId,
|
||||||
|
title: "Subagent",
|
||||||
|
description,
|
||||||
|
toolCallId: call.callId,
|
||||||
|
status: "running" as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
const mapSpawn = (call: PiExtensionToolCall) => {
|
const mapSpawn = (call: PiExtensionToolCall) => {
|
||||||
const args = Args.safeParse(call.args);
|
const args = Args.safeParse(call.args);
|
||||||
if (!args.success || args.data.action !== undefined || !args.data.agent || !args.data.task)
|
if (!args.success) return undefined;
|
||||||
return undefined;
|
if (args.data.action === "resume")
|
||||||
|
return mapResume(call, args.data.message ?? args.data.task);
|
||||||
|
if (args.data.action !== undefined || !args.data.agent || !args.data.task) return undefined;
|
||||||
const detail = {
|
const detail = {
|
||||||
type: "sub_agent" as const,
|
type: "sub_agent" as const,
|
||||||
subAgentType: args.data.agent,
|
subAgentType: args.data.agent,
|
||||||
@@ -199,8 +238,7 @@ export const piSubagents: PiExtension = {
|
|||||||
}
|
}
|
||||||
const file = owner ? line.match(/^Session file: (.+)$/)?.[1] : undefined;
|
const file = owner ? line.match(/^Session file: (.+)$/)?.[1] : undefined;
|
||||||
if (!owner || !file) continue;
|
if (!owner || !file) continue;
|
||||||
if (!readSessions.has(file)) {
|
if (rememberSession(owner, file)) {
|
||||||
readSessions.add(file);
|
|
||||||
childSessions.push({ id: owner, file });
|
childSessions.push({ id: owner, file });
|
||||||
}
|
}
|
||||||
owner = undefined;
|
owner = undefined;
|
||||||
@@ -225,8 +263,7 @@ export const piSubagents: PiExtension = {
|
|||||||
const id = parsed.data.steps.length === 1 ? run.owner : `${run.owner}:${index}`;
|
const id = parsed.data.steps.length === 1 ? run.owner : `${run.owner}:${index}`;
|
||||||
const status = asyncChildStatus(parsed.data.state, step.status);
|
const status = asyncChildStatus(parsed.data.state, step.status);
|
||||||
subagents.push({ type: "upsert", id, title: step.agent, status });
|
subagents.push({ type: "upsert", id, title: step.agent, status });
|
||||||
if (step.sessionFile && !readSessions.has(step.sessionFile)) {
|
if (step.sessionFile && rememberSession(id, step.sessionFile)) {
|
||||||
readSessions.add(step.sessionFile);
|
|
||||||
childSessions.push({ id, file: step.sessionFile });
|
childSessions.push({ id, file: step.sessionFile });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user