Merge remote-tracking branch 'origin/main' into diagnose/9391-c51458dd

This commit is contained in:
paseo-bot[bot]
2026-11-11 13:51:43 +00:00
11 changed files with 774 additions and 35 deletions
@@ -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:**
![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.
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)
@@ -10,6 +10,7 @@ import {
selectComposerDraftRange,
} from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { expectVisibleAgentSurfacesIdle } from "../support/helpers/agent-stream";
// The popover has no semantic role; its rows are React Native Pressables.
function fileSuggestions(page: Page) {
@@ -32,31 +33,33 @@ async function sendEmailIntact(page: Page, email: string): Promise<void> {
await expect(page.getByText(email, { exact: true })).toBeVisible();
}
for (const email of [
"user <12345+user@noreply.example.com>",
"alice@example.com",
"noreply+@example.com",
]) {
test(`Enter sends ${email} intact and still completes file mentions`, async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-email-",
title: "Email mention regression",
repo: { files: [{ path: "example.com", content: "A file matching the email domain.\n" }] },
});
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await test.step("complete a workspace file mention with Enter", async () => {
await selectReadmeMention(page);
});
await test.step("send an email without offering or inserting workspace files", async () => {
await sendEmailIntact(page, email);
});
} finally {
await agent.cleanup();
}
test("Enter sends email formats intact and still completes file mentions", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-email-",
title: "Email mention regression",
repo: { files: [{ path: "example.com", content: "A file matching the email domain.\n" }] },
});
}
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
for (const email of [
"user <12345+user@noreply.example.com>",
"alice@example.com",
"noreply+@example.com",
]) {
await test.step(`complete a file mention, then send ${email} intact`, async () => {
await selectReadmeMention(page);
await sendEmailIntact(page, email);
// Start the next draft only after the daemon and visible agent have settled.
const reply = await agent.client.waitForFinish(agent.agentId, 15_000);
expect(reply.status).toBe("idle");
await expectVisibleAgentSurfacesIdle(page);
});
}
} finally {
await agent.cleanup();
}
});
test("fullscreen mention keys edit the draft while inline keys still complete files", async ({
page,
@@ -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");
@@ -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<string, 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. */
const asyncRuns = new Map<string, { owner: string; polling: boolean }>();
const rememberAsyncRun = (details: z.infer<typeof Details>, 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 });
}
}