diff --git a/docs/qa-evidence/pi-resumed-subagents/README.md b/docs/qa-evidence/pi-resumed-subagents/README.md new file mode 100644 index 0000000000..7a1e862ae5 --- /dev/null +++ b/docs/qa-evidence/pi-resumed-subagents/README.md @@ -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 `` and the encoded workspace directory with ``; 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:** + +![C running](c-running.png) + +**After archiving finished rows, C remains running:** + +![Archive keeps running C](archive-keeps-running.png) + +**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. + +![C child conversation crop](c-dialogue-crop.png) + +**Final four completed rows, with the failed resume reported above:** + +![Completed rows and failed resume](completed-and-failed-resume.png) + +## 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. diff --git a/docs/qa-evidence/pi-resumed-subagents/archive-keeps-running.png b/docs/qa-evidence/pi-resumed-subagents/archive-keeps-running.png new file mode 100644 index 0000000000..ab5f945dbe Binary files /dev/null and b/docs/qa-evidence/pi-resumed-subagents/archive-keeps-running.png differ diff --git a/docs/qa-evidence/pi-resumed-subagents/c-dialogue-crop.png b/docs/qa-evidence/pi-resumed-subagents/c-dialogue-crop.png new file mode 100644 index 0000000000..4ddb50941f Binary files /dev/null and b/docs/qa-evidence/pi-resumed-subagents/c-dialogue-crop.png differ diff --git a/docs/qa-evidence/pi-resumed-subagents/c-running.png b/docs/qa-evidence/pi-resumed-subagents/c-running.png new file mode 100644 index 0000000000..6fe37f3222 Binary files /dev/null and b/docs/qa-evidence/pi-resumed-subagents/c-running.png differ diff --git a/docs/qa-evidence/pi-resumed-subagents/completed-and-failed-resume.png b/docs/qa-evidence/pi-resumed-subagents/completed-and-failed-resume.png new file mode 100644 index 0000000000..0f4ef1e440 Binary files /dev/null and b/docs/qa-evidence/pi-resumed-subagents/completed-and-failed-resume.png differ diff --git a/docs/qa-evidence/pi-resumed-subagents/desktop-observations.json b/docs/qa-evidence/pi-resumed-subagents/desktop-observations.json new file mode 100644 index 0000000000..5dba058ad5 --- /dev/null +++ b/docs/qa-evidence/pi-resumed-subagents/desktop-observations.json @@ -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 .", + "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": "/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": "/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": "/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": "/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": "/pi/sessions//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": "/pi/sessions//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": "/pi/sessions//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": "/pi/sessions//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: /async/async-subagent-runs/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122\n\nSession file: /pi/sessions//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: /async/async-subagent-runs/8ca757d4-7514-4e87-8eeb-af52e2228ccb\n\nSession file: /pi/sessions//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: /async/async-subagent-runs/f41865ed-0e62-44b5-a825-27ac4f0aa596\n\nSession file: /pi/sessions//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: /async/async-subagent-runs/01ac3b2e-2ed6-472d-8438-4dfb91ea62f7\n\nSession file: /pi/sessions//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." + } + ] +} diff --git a/docs/qa-evidence/pi-resumed-subagents/dialogue-excerpt.txt b/docs/qa-evidence/pi-resumed-subagents/dialogue-excerpt.txt new file mode 100644 index 0000000000..c7f920728d --- /dev/null +++ b/docs/qa-evidence/pi-resumed-subagents/dialogue-excerpt.txt @@ -0,0 +1,17 @@ +Excerpt of actual Electron renderer DOM captured after opening the resumed child. +Test-root paths replaced with ; 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: /pi/sessions//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 diff --git a/docs/qa-evidence/pi-resumed-subagents/validation.txt b/docs/qa-evidence/pi-resumed-subagents/validation.txt new file mode 100644 index 0000000000..c8bc05e2da --- /dev/null +++ b/docs/qa-evidence/pi-resumed-subagents/validation.txt @@ -0,0 +1,196 @@ +Captured command output. Only private absolute paths are replaced with /. +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 /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 /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 /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) diff --git a/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts index 740d66aadc..69014f3dbf 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; 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 { join } from "node:path"; import { createPiExtensionHost } from "../index.js"; @@ -30,12 +30,36 @@ function mapToolDetail(toolCall: PiTrackedToolCall, result: PiToolResult) { return mapping(toolCall, result)?.detail; } -function hasChildTimeline(events: AgentStreamEvent[]) { +function hasChildTimeline(events: AgentStreamEvent[], id?: string) { 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[]) { return events.some( (event) => @@ -46,6 +70,244 @@ function hasCompletedChild(events: AgentStreamEvent[]) { } 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 () => { const dir = await mkdtemp(join(tmpdir(), "paseo-pi-async-")); const file = join(dir, "child.jsonl"); diff --git a/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.ts b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.ts index 058171e8df..02448dfc8a 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.ts @@ -10,6 +10,7 @@ const Args = z task: z.string().trim().min(1).optional(), async: z.boolean().optional(), action: z.unknown().optional(), + message: z.string().optional(), }) .passthrough(); const Row = z @@ -71,6 +72,13 @@ export const piSubagents: PiExtension = { createSession: () => { const callsByRun = new Map(); const readSessions = new Set(); + // 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. */ const asyncRuns = new Map(); const rememberAsyncRun = (details: z.infer, call: PiExtensionToolCall) => { @@ -99,8 +107,7 @@ export const piSubagents: PiExtension = { ? "failed" : "completed", }); - if (row.sessionFile && !readSessions.has(row.sessionFile)) { - readSessions.add(row.sessionFile); + if (row.sessionFile && rememberSession(id, row.sessionFile)) { childSessions.push({ id, file: row.sessionFile }); } } @@ -123,10 +130,42 @@ export const piSubagents: PiExtension = { } 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 args = Args.safeParse(call.args); - if (!args.success || args.data.action !== undefined || !args.data.agent || !args.data.task) - return undefined; + if (!args.success) 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 = { type: "sub_agent" as const, subAgentType: args.data.agent, @@ -199,8 +238,7 @@ export const piSubagents: PiExtension = { } const file = owner ? line.match(/^Session file: (.+)$/)?.[1] : undefined; if (!owner || !file) continue; - if (!readSessions.has(file)) { - readSessions.add(file); + if (rememberSession(owner, file)) { childSessions.push({ id: owner, file }); } owner = undefined; @@ -225,8 +263,7 @@ export const piSubagents: PiExtension = { const id = parsed.data.steps.length === 1 ? run.owner : `${run.owner}:${index}`; const status = asyncChildStatus(parsed.data.state, step.status); subagents.push({ type: "upsert", id, title: step.agent, status }); - if (step.sessionFile && !readSessions.has(step.sessionFile)) { - readSessions.add(step.sessionFile); + if (step.sessionFile && rememberSession(id, step.sessionFile)) { childSessions.push({ id, file: step.sessionFile }); } }