Skip to content

fix(subagents): nest third-party subagent sessions under their parent - #834

Open
elrond298 wants to merge 2 commits into
agegr:mainfrom
elrond298:fix/nest-parent-sessions
Open

elrond298 wants to merge 2 commits into
agegr:mainfrom
elrond298:fix/nest-parent-sessions

Conversation

@elrond298

@elrond298 elrond298 commented Sep 13, 2026

Copy link
Copy Markdown

What this PR does

Two commits, so the second can be reviewed (or kept) on its own:

  1. fix: nest parent-linked sessions in sidebar — every session with a resolvable parentSession
    becomes a nested, collapsible sidebar row again, instead of only sessions carrying
    pi-web:subagent metadata.
  2. feat(subagents): group third-party subagent sessions under their parent — identifies sessions
    spawned by other subagent extensions and gives them a real relation.kind: "subagent", so they
    also appear in the Agents panel with their profile, description and status.

Commit 2 alone would already list third-party runs in the Agents panel (and aggregate their state
onto the parent row); commit 1 is the sidebar-rows change, which is a UX decision you may want to
drop.

中文一句话:v0.8.10 把侧边栏会话树换成了只认 pi-web:subagent 元数据的 session family,
于是 @tintinweb/pi-subagents 创建的子会话变成顶层独立会话;这里用父会话自己记录的 run id
把它们重新挂回父会话,并让它们进入 Agents 面板。

Problem

Sessions created by a third-party subagent extension appear as independent top-level sessions, with
no nesting and no collapse control, and never show up in the Agents panel.

Reproduce:

  1. Install @tintinweb/pi-subagents, keep Pi Web's built-in subagents off.
  2. Spawn any agent from a session.
  3. The child session (<agent>#<8 hex>) is a top-level row next to its parent.

0.8.9 and earlier nested it correctly, so this is a regression introduced in 0.8.10.

What changed, and why it broke third-party children

Version Sidebar grouping Third-party child
≤ 0.8.9 buildSessionTree() — nests any session with a resolvable parentSession nested, collapsible
0.8.10 → listSessionFamilies() — groups only relation.kind === "subagent" flat top-level row
  • The tree was removed by e4b4743 "feat: add subagent session switcher" (2026-08-20), merged to
    main as f106531, first released in v0.8.10 (git merge-base --is-ancestor e4b4743 v0.8.9 is
    false, which pins the boundary).
  • That commit replaced sidebar child rows with components/AgentSessionPanel.tsx and introduced
    lib/session-family.ts, keyed on Pi Web's own pi-web:subagent metadata. Forks and third-party
    children are outside that key.
  • Fallout: lib/session-tree.ts is still in the tree but is imported only by its own test — dead
    code.

The new rule is defensible: the Agents panel needs a profile, a description and a status, and a
session labelled "subagent" without those would be a lie. The problem is that the only thing a
third-party child has is parentSession — the exact field a fork writes. Looking at the child
alone, Pi Web cannot tell "agent" from "user fork".

The missing link lives in the parent

The parent session already records what the child cannot prove. From a real parent file:

{"type": "custom", "customType": "subagents:record",
 "data": {"id": "35a50c91-c27b-473", "type": "general-purpose",
          "description": "Wait one minute", "status": "steered", "result": "succeed",
          "startedAt": 1787999060817, "completedAt": 1787999132437}}
{"type": "message",
 "message": {"role": "toolResult",
             "details": {"subagentType": "Explore", "description": "",
                         "status": "background", "agentId": "aa11bb22-cc33"}}}

and the child's own header/naming:

{"type": "session", "parentSession": "…/01a04d0c-…jsonl"}
{"type": "session_info", "name": "general-purpose#35a50c91"}
  • subagents:record is written per finished run — the extension's own comment says it exists "for
    cross-extension history reconstruction".
  • A background spawn's tool result carries details.agentId before the run finishes, so a
    running agent is identifiable too.
  • The child name is <profile>#<first 8 chars of the run id> — the extension's naming contract.

Matching a child's name suffix against ids the parent itself recorded is not guessing from a
name shape: a fork could only be misread if the parent recorded an id whose first 8 hex characters
equal the fork's #… suffix.

Implementation

File Change
lib/subagents.ts externalSubagentRunFromEntry() reads a run identity from a record entry or a tool result; matchExternalSubagentRun() matches it against the child name. Status map covers queued / running / background / completed / steered / aborted / stopped / error.
lib/session-list-scanner.ts Collects runs during the read it already performs (no extra file I/O), keeps the last entry per run id, caps the list at 500, caches them in the persisted index. INDEX_FORMAT_VERSION → 2 (a one-time rescan on upgrade); a malformed persisted field drops that index entry so the file is rescanned instead of serving wrong relations.
lib/session-reader.ts loadAllSessions() maps a match to relation.kind: "subagent" with source: "external", only when the parent session resolves. An orphaned child stays a visible top-level row instead of disappearing (Pi Web's own subagent sessions are hidden in that case).
lib/types.ts source?: "external" on the subagent relation; absent means Pi Web's own metadata.
app/api/sessions/[id]/route.ts The single-session detail route reuses the cached catalogue for the same relation, so an external subagent opened directly is classified identically to its sidebar row.
lib/session-family.ts Family roots now follow any parent link (fork or subagent) and expose children; subagents is still the metadata-confirmed subset.
components/SessionSidebar.tsx Renders one windowed row per visible session (root plus children when expanded), keeping the fixed row height the virtualization relies on.

Everything downstream then works unchanged: nesting, collapse, the Agents panel listing, the
subagent icon, and the "silent subagent completion" behaviour.

Not included: the Open sub-agent session button on Agent tool-call cards — it is driven by Pi
Web's own tool-call → session mapping, which third-party runs do not have.

Decisions for review

  1. Forks are nested too (commit 1). Upstream deliberately kept them top-level; if that is still
    the intent, commit 1 can be dropped and the third-party case still works.
  2. Delete does not cascade to third-party runs. app/api/sessions/[id]/route.ts deletes every
    subagent below the deleted session (for Bug: deleting a parent session detaches its subagent sessions as standalone sessions #762). Runs marked source: "external" are excluded and
    take the re-parent path instead, because they belong to another runtime and the delete
    confirmation never mentions child sessions. Removing the source === "external" guard makes them
    cascade like Pi Web's own subagents — a product decision, not a technical one.

Verification

Real sessions on disk, after the change:

general-purpose#35a50c91 -> {"kind":"subagent","parentSessionId":"01a04d0c-…",
                             "profile":"general-purpose","description":"Wait one minute",
                             "status":"completed","source":"external"}
code-reviewer#5ce27468   -> profile code-reviewer, description "Review adaptive widget changes", completed

Browser: with the external subagent present, selecting the parent session shows the Agents button
with badge 1; the panel lists Main agent plus Wait one minute / general-purpose · 15 days ago / ✓ Completed. The child row is indented under its parent, and the parent shows Collapse subagents.

Tests:

  • lib/subagents.test.mjs — extraction from both entry shapes, rejection of entries without a run
    id, matching (hit, unrecorded id, truncated suffix, name without #, no runs).
  • lib/session-list-scanner.test.mjs — end-to-end with real files: a completed run, a still running
    background run, and a plain fork under the same parent; classification survives an index restart;
    two malformed-index cases for the new field.
  • npm test → 1022/1024 pass (the two failures are an unrelated, local work-in-progress).
    tsc --noEmit and eslint are clean for the touched files.

Remaining limits

  • Only implementations that leave a parent-side record (or a run id in a tool result) can be
    identified; a runner that persists nothing on the parent side stays a flat row.
  • A resumed run is matched through the same id; state always comes from the last entry for that id
    in the parent file.
  • Pi Web only classifies these sessions; spawning, steering and results are unchanged.

Follow-up (not a blocker)

A stable, documented marker in the child session would remove the inference entirely:

{"type": "custom", "customType": "pi-subagents:session",
 "data": {"version": 1, "parentSessionId": "", "agent": "Explore", "runId": ""}}

The exact schema does not matter; what matters is that a session browser can distinguish "agent"
from "fork" without parsing names or reading the parent's history.

Sessions spawned by other subagent extensions carry only `parentSession`, the
same field a fork writes, so the sidebar showed them as unrelated top-level
sessions and the Agents panel could not list them.

The parent's own session file proves the relationship: pi-subagents persists a
`subagents:record` entry per finished run "for cross-extension history
reconstruction", and its spawn tool result carries `details.agentId` while the
agent is still running. Matching those run ids against the child name
convention (`<profile>#<run id, first 8 chars>`) identifies the child without
guessing from its name.

The scanner already streams every session file, so the runs are collected there
and cached in the persisted index (version bumped to 2). A match becomes
`relation.kind: "subagent"` with `source: "external"`, which makes sidebar
nesting, the Agents panel, and subagent-specific behavior work unchanged.

The delete cascade deliberately skips `source: "external"`: those transcripts
belong to another runtime, so they are re-parented like any other child instead
of being deleted with the parent.
@elrond298
elrond298 force-pushed the fix/nest-parent-sessions branch from eae1e4c to 25a4ef3 Compare September 13, 2026 09:53
@elrond298
elrond298 marked this pull request as ready for review September 13, 2026 09:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant