@d3ara1n/pi-subagent 2.2.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -16,18 +16,14 @@
16
16
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
17
17
  import { Type } from "typebox";
18
18
  import { getModelRolesAPI } from "@d3ara1n/pi-model-roles";
19
- import type {
20
- CollectedRun,
21
- SubagentConfig,
22
- SubagentResult,
23
- SubagentRole,
24
- } from "./types.ts";
19
+ import type { SubagentConfig, SubagentResult, SubagentRole } from "./types.ts";
25
20
  import { DEFAULT_CONFIG } from "./types.ts";
26
21
  import { loadSubagentConfig } from "./config.ts";
27
22
  import { BUILTIN_ROLES } from "./roles.ts";
28
23
  import { getPiInvocation } from "./spawn.ts";
29
24
  import {
30
25
  AsyncSemaphore,
26
+ collectDeliveredIds,
31
27
  createThrottler,
32
28
  describeCurrentActivity,
33
29
  formatBudgetNote,
@@ -44,9 +40,11 @@ import {
44
40
  } from "./utils.ts";
45
41
  import { startSubagentRun, type RunHandle } from "./run.ts";
46
42
  import { buildInboxReminder, injectReminder } from "./reminder.ts";
43
+ import { serializeInheritedConversation } from "./inheritance.ts";
47
44
  import { renderDelegateCall, renderDelegateResult } from "./render.ts";
48
45
  import { createViewPanel } from "./view.ts";
49
46
  import {
47
+ createSteerCallRender,
50
48
  renderBackgroundDelegateCall,
51
49
  renderBackgroundDelegateResult,
52
50
  renderCancelCall,
@@ -54,6 +52,7 @@ import {
54
52
  renderCheckCall,
55
53
  renderCheckResult,
56
54
  renderCompletionNotice,
55
+ renderSteerResult,
57
56
  renderWaitCall,
58
57
  renderWaitResult,
59
58
  } from "./render-async.ts";
@@ -101,14 +100,15 @@ export default function subagentExtension(pi: ExtensionAPI) {
101
100
  refreshAvailableRoles();
102
101
 
103
102
  // ── Background run registry ────────────────────────────────────
104
- // Process-lifetime map of unclaimed background runs (queued, running, and
103
+ // Process-lifetime map of background runs (queued, running, and
105
104
  // finished/failed alike). Foreground delegate runs are NOT registered —
106
- // their lifecycle is the tool call itself. subagent_check on a terminal run
107
- // collects it: the handle is freed and a lightweight CollectedRun tombstone
108
- // takes its place, so ids stay resolvable while memory does not grow with
109
- // full results.
105
+ // their lifecycle is the tool call itself. Runs stay registered for the
106
+ // whole session: subagent_check is idempotent and re-delivers the terminal
107
+ // snapshot on every call, so branch navigation or compaction can never
108
+ // strand a result outside the model's reach. Whether a run still needs
109
+ // reminding is NOT tracked here — it derives from the session tree (see
110
+ // collectDeliveredIds + the context handler), the single source of truth.
110
111
  const backgroundRuns = new Map<string, RunHandle>();
111
- const collectedRuns = new Map<string, CollectedRun>();
112
112
  let runCounter = 0;
113
113
  let sessionGeneration = 0;
114
114
 
@@ -146,11 +146,13 @@ export default function subagentExtension(pi: ExtensionAPI) {
146
146
  "- Delegate ONLY when a task involves significant work (heavy analysis, multi-step investigation, large-scope changes) AND you only care about the conclusion, not intermediate steps. A good test: the task would clutter your context with 3+ turns of raw tool output.",
147
147
  "- DO NOT delegate simple tasks — a single read, a one-line edit, a basic grep, or straightforward changes touching 1-2 files. Just do them yourself; spawning a child process costs more than the task.",
148
148
  "",
149
- "SELF-CONTAINED DELEGATION (subagents have NO memory of this conversation):",
149
+ "DELEGATION CONTEXT MODES:",
150
150
  "",
151
- "- The child sees exactly three things: task, context, files nothing else of this chat exists for it. Any requirement, decision, or constraint it must know has to be restated in those channels.",
152
- "- Never write phantom references (\"as discussed above\", \"per the requirements\") the child has never seen that material. Inline the actual content instead.",
153
- "- The isolation is deliberate: don't dump your whole conversation either. Pass what the task needs and nothing more, so the child's judgment isn't steered by irrelevant history.",
151
+ "- Self-contained (default): when task, context, and files contain everything the child needs, omit inheritConversation. The child receives no parent dialogue.",
152
+ '- Conversation-relative: when the task is intentionally written as a delta against this chat — e.g. "implement the approved approach", "review the requirements above", or "continue from our discussion" set inheritConversation: true.',
153
+ "- Never send a conversation-relative task without inheritConversation: true. Either enable inheritance or rewrite the task to be self-contained.",
154
+ "- With inheritance enabled, keep task focused on the work to perform; do not duplicate the conversation into context. Use context for additional non-conversation background and files for source material.",
155
+ "- Inherited history may be compacted or truncated. If an older detail is essential and may fall outside the retained history, include it explicitly in task or context.",
154
156
  "",
155
157
  "AVAILABLE ROLES:",
156
158
  ...entries.map(([name, role]) => ` - ${name}: ${role.description}`),
@@ -250,7 +252,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
250
252
  rebuildGuidelines(availableRoles);
251
253
  });
252
254
 
253
- pi.on("context", async (event) => {
255
+ pi.on("context", async (event, ctx) => {
254
256
  // Completion notices are persisted custom messages so the user can see
255
257
  // them in the transcript, but they are deliberately UI-only. Keep the
256
258
  // model on the reminder/check path instead of duplicating the notice in
@@ -260,10 +262,16 @@ export default function subagentExtension(pi: ExtensionAPI) {
260
262
  message.role !== "custom" || message.customType !== BACKGROUND_COMPLETION_MESSAGE_TYPE,
261
263
  );
262
264
 
263
- // The model's inbox: every unclaimed background run, injected at a
264
- // cache-stable head position before every provider call. Empty inbox and
265
- // no filtered notices context stays untouched, cache fully stable.
266
- const reminder = buildInboxReminder(backgroundRuns.values());
265
+ // The model's inbox: every background run not yet checked on the active
266
+ // branch, injected at a cache-stable head position before every provider
267
+ // call. Delivery state is derived from the session tree (append-only:
268
+ // branching away drops the check entry, branching back restores it), so
269
+ // the inbox re-arms itself after tree navigation. Empty inbox and no
270
+ // filtered notices → context stays untouched, cache fully stable.
271
+ const reminder = buildInboxReminder(
272
+ backgroundRuns.values(),
273
+ collectDeliveredIds(ctx.sessionManager.buildContextEntries()),
274
+ );
267
275
  if (!reminder && messages.length === event.messages.length) return;
268
276
  return { messages: reminder ? injectReminder(messages, reminder) : messages };
269
277
  });
@@ -295,7 +303,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
295
303
  name: "subagent_delegate",
296
304
  label: "Delegate to subagent",
297
305
  description:
298
- "Delegate a task to a specialized subagent. By default the call blocks until the run finishes and returns the final output — intermediate tool output stays out of your context. With background: true it returns an id immediately and you collect the result later with subagent_wait/subagent_check. Subagents have no access to this conversation everything they need must arrive through the parameters.",
306
+ "Delegate a task to a specialized subagent. By default the call blocks until the run finishes and returns the final output — intermediate tool output stays out of your context. With background: true it returns an id immediately and you collect the result later with subagent_wait/subagent_check. Subagents are isolated by default; inheritConversation optionally injects a filtered snapshot of the active parent branch.",
299
307
  promptSnippet: "Delegate tasks to specialized subagents",
300
308
  promptGuidelines: guidelines,
301
309
 
@@ -303,7 +311,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
303
311
  role: Type.String({ description: "Subagent role to use" }),
304
312
  task: Type.String({
305
313
  description:
306
- "The work to do self-contained: the subagent cannot see this conversation, so restate any requirement or constraint it needs here or in `context`. Instructions only — background material belongs in `context`, reference file paths in `files`.",
314
+ "The work to do. Without conversation inheritance it must be self-contained, with every requirement or constraint restated here or in `context`; with inheritance it may be a delta against that history. Instructions only — background material belongs in `context`, reference file paths in `files`.",
307
315
  }),
308
316
  context: Type.Optional(
309
317
  Type.String({
@@ -317,6 +325,12 @@ export default function subagentExtension(pi: ExtensionAPI) {
317
325
  'Reference file paths for the subagent to read directly (e.g. ["src/auth.ts", "docs/api.md"]). Injected as @file attachments — content stays out of your context window. Prefer this over pasting file contents into context.',
318
326
  }),
319
327
  ),
328
+ inheritConversation: Type.Optional(
329
+ Type.Boolean({
330
+ description:
331
+ "Opt in to a text-only, compaction-aware snapshot of the active parent conversation. Omit or false for isolation; true lets task be a delta against inherited history, which may be filtered or truncated.",
332
+ }),
333
+ ),
320
334
  background: Type.Optional(
321
335
  Type.Boolean({
322
336
  description:
@@ -361,6 +375,13 @@ export default function subagentExtension(pi: ExtensionAPI) {
361
375
  );
362
376
  }
363
377
 
378
+ const inheritedConversation = params.inheritConversation
379
+ ? serializeInheritedConversation(
380
+ ctx.sessionManager.buildContextEntries(),
381
+ config.inheritance.maxChars,
382
+ )
383
+ : undefined;
384
+
364
385
  const run = startSubagentRun({
365
386
  id: `sub-${++runCounter}`,
366
387
  toolCallId: _toolCallId,
@@ -369,6 +390,9 @@ export default function subagentExtension(pi: ExtensionAPI) {
369
390
  task: params.task,
370
391
  context: params.context,
371
392
  files: params.files,
393
+ inheritConversation: params.inheritConversation === true,
394
+ inheritedConversation: inheritedConversation?.text,
395
+ inheritedConversationTruncated: inheritedConversation?.truncated,
372
396
  cwd: params.cwd ?? ctx.cwd,
373
397
  depth: CURRENT_DEPTH + 1,
374
398
  // Foreground runs die with the tool call; background runs outlive the turn.
@@ -386,9 +410,10 @@ export default function subagentExtension(pi: ExtensionAPI) {
386
410
  backgroundRuns.set(run.id, run);
387
411
  const runGeneration = sessionGeneration;
388
412
  void run.promise.then((result) => {
389
- // A collected run already has a visible check result. Do not emit a
390
- // second notice, and never publish completions from an old session.
391
- if (!backgroundRuns.has(run.id) || runGeneration !== sessionGeneration) return;
413
+ // Never publish completions from an old session. Within a session
414
+ // every completion emits its UI-only notice card once check
415
+ // results never suppress it (they are not LLM-visible either way).
416
+ if (runGeneration !== sessionGeneration) return;
392
417
 
393
418
  const outcome = isFailedResult(result)
394
419
  ? "failed"
@@ -420,6 +445,9 @@ export default function subagentExtension(pi: ExtensionAPI) {
420
445
  task: params.task,
421
446
  context: params.context,
422
447
  files: params.files,
448
+ inheritConversation: params.inheritConversation === true,
449
+ inheritedConversationChars: inheritedConversation?.text.length,
450
+ inheritedConversationTruncated: inheritedConversation?.truncated,
423
451
  },
424
452
  };
425
453
  }
@@ -543,16 +571,9 @@ export default function subagentExtension(pi: ExtensionAPI) {
543
571
  const unknown = ids.filter((id) => !backgroundRuns.has(id));
544
572
  if (unknown.length > 0) {
545
573
  const active = [...backgroundRuns.values()].map((r) => `${r.id} (${r.role})`);
546
- const collectedNotes = unknown
547
- .filter((id) => collectedRuns.has(id))
548
- .map((id) => `${id} was already collected (result is in your history)`);
549
- const trulyUnknown = unknown.filter((id) => !collectedRuns.has(id));
550
- const parts = [
551
- `Unknown subagent id(s): ${trulyUnknown.length > 0 ? trulyUnknown.join(", ") : "(none)"}.`,
552
- collectedNotes.length > 0 ? `${collectedNotes.join("; ")}.` : "",
553
- `Active: ${active.length > 0 ? active.join(", ") : "(none)"}.`,
554
- ].filter(Boolean);
555
- throw new Error(parts.join(" "));
574
+ throw new Error(
575
+ `Unknown subagent id(s): ${unknown.join(", ")}. Active: ${active.length > 0 ? active.join(", ") : "(none)"}.`,
576
+ );
556
577
  }
557
578
  const runs = ids.map((id) => backgroundRuns.get(id)!);
558
579
  const timeoutMs =
@@ -659,7 +680,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
659
680
  name: "subagent_check",
660
681
  label: "Check a background subagent",
661
682
  description:
662
- "Get an instant snapshot of ONE background subagent run: queued / running (with current activity) / finished (with the full output as the run result) / failed (with reason and partial output). Does not wait — use subagent_wait for that. Checking a terminal run collects it: the output is returned once and the run leaves the background registry. One id per call because results can be large.",
683
+ "Get an instant snapshot of ONE background subagent run: queued / running (with current activity) / finished (with the full output as the run result) / failed (with reason and partial output). Does not wait — use subagent_wait for that. Idempotent: checking a terminal run again re-delivers the same snapshot, so the result stays reachable even after branch navigation or compaction. One id per call because results can be large.",
663
684
  promptSnippet: "Inspect a background subagent run",
664
685
  parameters: Type.Object({
665
686
  id: Type.String({ description: "Run id returned by a background delegate call" }),
@@ -668,12 +689,6 @@ export default function subagentExtension(pi: ExtensionAPI) {
668
689
  async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
669
690
  const run = backgroundRuns.get(params.id);
670
691
  if (!run) {
671
- const collected = collectedRuns.get(params.id);
672
- if (collected) {
673
- throw new Error(
674
- `${params.id} (${collected.role}) was already collected — its result is in your conversation history. Check the remaining runs or delegate new ones.`,
675
- );
676
- }
677
692
  const active = [...backgroundRuns.values()].map((r) => `${r.id} (${r.role})`);
678
693
  throw new Error(
679
694
  `Unknown subagent id: ${params.id}. Active: ${active.length > 0 ? active.join(", ") : "(none)"}.`,
@@ -683,19 +698,6 @@ export default function subagentExtension(pi: ExtensionAPI) {
683
698
  // Freeze live frames so the snapshot's elapsed time stays static.
684
699
  const snap = run.result ? run.snapshot : freezeFrame(run.snapshot);
685
700
 
686
- // Read-once collection: a terminal check returns the result AND frees
687
- // the run — the output now lives in the conversation history, so the
688
- // registry keeps only a lightweight tombstone for id resolution.
689
- if (run.state === "finished" || run.state === "failed") {
690
- backgroundRuns.delete(run.id);
691
- collectedRuns.set(run.id, {
692
- id: run.id,
693
- role: run.role,
694
- task: taskPreview(run.task),
695
- state: run.state,
696
- });
697
- }
698
-
699
701
  return {
700
702
  content: [{ type: "text", text: formatCheckText(run.id, run.role, snap) }],
701
703
  details: { id: run.id, role: run.role, result: snap },
@@ -710,7 +712,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
710
712
  name: "subagent_steer",
711
713
  label: "Steer a running background subagent",
712
714
  description:
713
- "Queue a mid-run correction into ONE running background subagent — typically right after subagent_check showed it heading down a wrong path. The message is delivered after the child finishes its current tool batch, before its next LLM call; the run keeps its progress (unlike cancel). Only running runs accept steering; queued runs reject it, and terminal runs are collected by check instead. Typical flow: check → steer → check again later.",
715
+ "Queue a mid-run correction into ONE running background subagent — typically right after subagent_check showed it heading down a wrong path. The message is delivered after the child finishes its current tool batch, before its next LLM call; the run keeps its progress (unlike cancel). Only running runs accept steering; queued runs reject it, and check is the tool for terminal runs. Typical flow: check → steer → check again later.",
714
716
  promptSnippet: "Send a mid-run correction to a background subagent",
715
717
  parameters: Type.Object({
716
718
  id: Type.String({ description: "Run id returned by a background delegate call" }),
@@ -723,12 +725,6 @@ export default function subagentExtension(pi: ExtensionAPI) {
723
725
  async execute(_toolCallId, params) {
724
726
  const run = backgroundRuns.get(params.id);
725
727
  if (!run) {
726
- const collected = collectedRuns.get(params.id);
727
- if (collected) {
728
- throw new Error(
729
- `${params.id} (${collected.role}) was already collected — nothing left to steer. Delegate a new run if a correction is still needed.`,
730
- );
731
- }
732
728
  const active = [...backgroundRuns.values()].map((r) => `${r.id} (${r.role})`);
733
729
  throw new Error(
734
730
  `Unknown subagent id: ${params.id}. Active: ${active.length > 0 ? active.join(", ") : "(none)"}.`,
@@ -752,16 +748,19 @@ export default function subagentExtension(pi: ExtensionAPI) {
752
748
  text: `Steer queued for ${params.id} (${run.role}) — delivered after its current tool batch. Verify the effect with subagent_check later.`,
753
749
  },
754
750
  ],
755
- details: { id: params.id, role: run.role },
751
+ details: { id: params.id, role: run.role, message: params.message },
756
752
  };
757
753
  },
754
+
755
+ renderCall: createSteerCallRender((id) => backgroundRuns.get(id)?.role),
756
+ renderResult: renderSteerResult,
758
757
  });
759
758
 
760
759
  pi.registerTool({
761
760
  name: "subagent_cancel",
762
761
  label: "Cancel a background subagent",
763
762
  description:
764
- "Cancel ONE background subagent run (queued or running): the child process is killed and the run settles as cancelled (its own stop reason, same family as timeout — partial output kept), NOT as a plain failure. The reason is recorded with the run: whoever reads the partial output later via subagent_check sees why it was stopped. Cancelling does not collect — check still returns the partial output once. A finished/failed run cannot be cancelled; check it instead.",
763
+ "Cancel ONE background subagent run (queued or running): the child process is killed and the run settles as cancelled (its own stop reason, same family as timeout — partial output kept), NOT as a plain failure. The reason is recorded with the run: whoever reads the partial output later via subagent_check sees why it was stopped. Cancelling does not remove the run — check still returns the partial output. A finished/failed run cannot be cancelled; check it instead.",
765
764
  promptSnippet: "Cancel a background subagent run",
766
765
  parameters: Type.Object({
767
766
  id: Type.String({ description: "Run id returned by a background delegate call" }),
@@ -776,21 +775,16 @@ export default function subagentExtension(pi: ExtensionAPI) {
776
775
  async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
777
776
  const run = backgroundRuns.get(params.id);
778
777
  if (!run) {
779
- const collected = collectedRuns.get(params.id);
780
- if (collected) {
781
- throw new Error(
782
- `${params.id} (${collected.role}) was already collected — its result is in your conversation history. There is nothing left to cancel.`,
783
- );
784
- }
785
778
  const active = [...backgroundRuns.values()].map((r) => `${r.id} (${r.role})`);
786
779
  throw new Error(
787
780
  `Unknown subagent id: ${params.id}. Active: ${active.length > 0 ? active.join(", ") : "(none)"}.`,
788
781
  );
789
782
  }
790
783
 
791
- // Terminal runs cannot be cancelled — point at the collector instead.
784
+ // Terminal runs cannot be cancelled — point at check instead.
792
785
  if (run.state === "finished" || run.state === "failed") {
793
- const what = run.state === "finished" ? "its result" : "the failure reason and partial output";
786
+ const what =
787
+ run.state === "finished" ? "its result" : "the failure reason and partial output";
794
788
  const text =
795
789
  `${params.id} (${run.role}) already ${run.state} — nothing to cancel. ` +
796
790
  `subagent_check(${params.id}) returns ${what}.`;
@@ -822,8 +816,8 @@ export default function subagentExtension(pi: ExtensionAPI) {
822
816
  pi.registerCommand("subagent:view", {
823
817
  description: "Open the live subagent activity view (watch progress, steer runs)",
824
818
  handler: async (_args, ctx) => {
825
- // Union of every known run: background registry (until collected) plus
826
- // live in-flight runs (foreground delegate calls included). Dedupe by id —
819
+ // Union of every known run: the background registry plus live
820
+ // in-flight runs (foreground delegate calls included). Dedupe by id —
827
821
  // background runs appear in both.
828
822
  const runsProvider = () => {
829
823
  const seen = new Set<string>();
@@ -942,40 +936,31 @@ export default function subagentExtension(pi: ExtensionAPI) {
942
936
  pi.registerCommand("subagent:status", {
943
937
  description: "List background subagent runs and their current state",
944
938
  handler: async (_args, ctx) => {
945
- if (backgroundRuns.size === 0 && collectedRuns.size === 0) {
939
+ if (backgroundRuns.size === 0) {
946
940
  ctx.ui.notify("No background runs.", "info");
947
941
  return;
948
942
  }
949
943
  const lines: string[] = [];
950
- if (backgroundRuns.size > 0) {
951
- lines.push("Active:");
952
- for (const run of backgroundRuns.values()) {
953
- // Freeze live frames so elapsed-dependent details don't drift in the listing.
954
- const snap = run.result ? run.snapshot : freezeFrame(run.snapshot);
955
- let icon: string;
956
- let detail: string;
957
- if (run.state === "failed") {
958
- icon = "\u2717";
959
- detail = snap.errorMessage || "unknown error";
960
- } else if (run.state === "finished") {
961
- icon = "\u2713";
962
- detail = snap.summary || taskPreview(snap.output) || "(no output)";
963
- } else if (run.state === "queued") {
964
- icon = "\u23F8";
965
- detail = "queued — waiting for a concurrency slot";
966
- } else {
967
- icon = "\u23F3";
968
- detail = `running — ${describeCurrentActivity(snap)}`;
969
- }
970
- lines.push(`${icon} ${run.id} (${run.role}): ${detail}`);
971
- }
972
- }
973
- if (collectedRuns.size > 0) {
974
- if (lines.length > 0) lines.push("");
975
- lines.push("Collected (result already returned via subagent_check):");
976
- for (const c of collectedRuns.values()) {
977
- lines.push(`\u2713 ${c.id} (${c.role}): ${c.state} — "${c.task}"`);
944
+ lines.push("Runs:");
945
+ for (const run of backgroundRuns.values()) {
946
+ // Freeze live frames so elapsed-dependent details don't drift in the listing.
947
+ const snap = run.result ? run.snapshot : freezeFrame(run.snapshot);
948
+ let icon: string;
949
+ let detail: string;
950
+ if (run.state === "failed") {
951
+ icon = "\u2717";
952
+ detail = snap.errorMessage || "unknown error";
953
+ } else if (run.state === "finished") {
954
+ icon = "\u2713";
955
+ detail = snap.summary || taskPreview(snap.output) || "(no output)";
956
+ } else if (run.state === "queued") {
957
+ icon = "\u23F8";
958
+ detail = "queued — waiting for a concurrency slot";
959
+ } else {
960
+ icon = "\u23F3";
961
+ detail = `running — ${describeCurrentActivity(snap)}`;
978
962
  }
963
+ lines.push(`${icon} ${run.id} (${run.role}): ${detail}`);
979
964
  }
980
965
  ctx.ui.notify(lines.join("\n"), "info");
981
966
  },
@@ -1018,13 +1003,9 @@ export default function subagentExtension(pi: ExtensionAPI) {
1018
1003
  }
1019
1004
 
1020
1005
  if (target !== "all" && !backgroundRuns.has(target)) {
1021
- const collected = collectedRuns.get(target);
1022
1006
  const active = [...backgroundRuns.values()].map((r) => `${r.id} (${r.role})`);
1023
1007
  ctx.ui.notify(
1024
- (collected
1025
- ? `${target} (${collected.role}) was already collected — nothing to cancel.`
1026
- : `Unknown subagent id: ${target}.`) +
1027
- ` Active: ${active.length > 0 ? active.join(", ") : "(none)"}.`,
1008
+ `Unknown subagent id: ${target}. Active: ${active.length > 0 ? active.join(", ") : "(none)"}.`,
1028
1009
  "error",
1029
1010
  );
1030
1011
  return;
@@ -1034,7 +1015,9 @@ export default function subagentExtension(pi: ExtensionAPI) {
1034
1015
  // reported as already-terminal instead of cancelled.
1035
1016
  const targets =
1036
1017
  target === "all"
1037
- ? [...backgroundRuns.values()].filter((r) => r.state === "queued" || r.state === "running")
1018
+ ? [...backgroundRuns.values()].filter(
1019
+ (r) => r.state === "queued" || r.state === "running",
1020
+ )
1038
1021
  : [backgroundRuns.get(target)!];
1039
1022
  if (targets.length === 0) {
1040
1023
  ctx.ui.notify("No live background runs to cancel.", "info");
@@ -0,0 +1,217 @@
1
+ /** Tests for deterministic parent-conversation serialization. */
2
+
3
+ import test from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { SessionManager, type SessionEntry } from "@earendil-works/pi-coding-agent";
6
+ import { serializeInheritedConversation as createSnapshot } from "./inheritance.ts";
7
+
8
+ function serializeInheritedConversation(entries: SessionEntry[], maxChars: number): string {
9
+ return createSnapshot(entries, maxChars).text;
10
+ }
11
+
12
+ function entries(items: unknown[]): SessionEntry[] {
13
+ return items as SessionEntry[];
14
+ }
15
+
16
+ const base = (type: string, id: string, extra: Record<string, unknown> = {}) => ({
17
+ type,
18
+ id,
19
+ parentId: null,
20
+ timestamp: "2026-01-01T00:00:00.000Z",
21
+ ...extra,
22
+ });
23
+
24
+ test("serializes only user/assistant text while filtering tool and UI state", () => {
25
+ const output = serializeInheritedConversation(
26
+ entries([
27
+ base("message", "u", { message: { role: "user", content: "Need a change" } }),
28
+ base("message", "a", {
29
+ message: {
30
+ role: "assistant",
31
+ content: [
32
+ { type: "thinking", thinking: "private" },
33
+ { type: "text", text: "I will delegate this." },
34
+ {
35
+ type: "toolCall",
36
+ id: "call",
37
+ name: "subagent_delegate",
38
+ arguments: { secret: "no" },
39
+ },
40
+ ],
41
+ },
42
+ }),
43
+ base("message", "tool", {
44
+ message: {
45
+ role: "toolResult",
46
+ toolName: "bash",
47
+ content: [{ type: "text", text: "secret output" }],
48
+ },
49
+ }),
50
+ base("custom_message", "ui", { content: "UI state" }),
51
+ base("custom", "state", { data: { token: "no" } }),
52
+ base("message", "bash", { message: { role: "bashExecution", output: "shell output" } }),
53
+ ]),
54
+ 10_000,
55
+ );
56
+
57
+ assert.equal(output, "[user]\nNeed a change\n\n[assistant]\nI will delegate this.");
58
+ assert.ok(!output.includes("private"));
59
+ assert.ok(!output.includes("secret"));
60
+ assert.ok(!output.includes("UI state"));
61
+ assert.ok(!output.includes("shell output"));
62
+ });
63
+
64
+ test("preserves active entry order, compaction summaries, and retained tails", () => {
65
+ const output = serializeInheritedConversation(
66
+ entries([
67
+ base("compaction", "c", {
68
+ summary: "Earlier work",
69
+ retainedTail: [
70
+ { role: "compactionSummary", summary: "Retained compacted context" },
71
+ { role: "user", content: "Kept request" },
72
+ { role: "assistant", content: [{ type: "text", text: "Kept reply" }] },
73
+ { role: "branchSummary", summary: "Retained branch context" },
74
+ ],
75
+ }),
76
+ base("branch_summary", "b", { summary: "Abandoned branch" }),
77
+ base("message", "u", { message: { role: "user", content: "Current request" } }),
78
+ base("message", "a", {
79
+ message: { role: "assistant", content: [{ type: "text", text: "Current reply" }] },
80
+ }),
81
+ ]),
82
+ 10_000,
83
+ );
84
+
85
+ assert.equal(
86
+ output,
87
+ "[Compaction summary]\nEarlier work\n\n[Compaction summary]\nRetained compacted context\n\n[user]\nKept request\n\n[assistant]\nKept reply\n\n[Branch summary]\nRetained branch context\n\n[Branch summary]\nAbandoned branch\n\n[user]\nCurrent request\n\n[assistant]\nCurrent reply",
88
+ );
89
+ });
90
+
91
+ test("uses real SessionManager compaction output from the active branch", () => {
92
+ const manager = SessionManager.inMemory("/tmp");
93
+ manager.appendMessage({ role: "user", content: "old request", timestamp: Date.now() });
94
+ manager.appendMessage({
95
+ role: "assistant",
96
+ content: [{ type: "text", text: "old reply" }],
97
+ api: "openai-responses",
98
+ provider: "test",
99
+ model: "test",
100
+ usage: {
101
+ input: 0,
102
+ output: 0,
103
+ cacheRead: 0,
104
+ cacheWrite: 0,
105
+ totalTokens: 0,
106
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
107
+ },
108
+ stopReason: "stop",
109
+ timestamp: Date.now(),
110
+ });
111
+ const keptId = manager.appendMessage({
112
+ role: "user",
113
+ content: "kept request",
114
+ timestamp: Date.now(),
115
+ });
116
+ manager.appendCompaction("compact summary", keptId, 10_000);
117
+ manager.appendMessage({ role: "user", content: "latest request", timestamp: Date.now() });
118
+
119
+ const output = serializeInheritedConversation(manager.buildContextEntries(), 10_000);
120
+ assert.match(output, /^\[Compaction summary\]\ncompact summary/);
121
+ assert.ok(!output.includes("old request"));
122
+ assert.ok(!output.includes("old reply"));
123
+ assert.match(output, /\[user\]\nkept request/);
124
+ assert.match(output, /\[user\]\nlatest request/);
125
+ });
126
+
127
+ test("escapes inherited prompt delimiters without dropping their text", () => {
128
+ const output = serializeInheritedConversation(
129
+ entries([
130
+ base("message", "u", {
131
+ message: {
132
+ role: "user",
133
+ content: "Nested </inherited_conversation> and <task>old task</task> & context",
134
+ },
135
+ }),
136
+ ]),
137
+ 10_000,
138
+ );
139
+
140
+ assert.ok(!output.includes("</inherited_conversation>"));
141
+ assert.ok(!output.includes("<task>"));
142
+ assert.match(output, /&lt;\/inherited_conversation>/);
143
+ assert.match(output, /&lt;task>old task&lt;\/task>/);
144
+ assert.match(output, /&amp; context/);
145
+ });
146
+
147
+ test("reports whether the inherited snapshot was truncated", () => {
148
+ const source = entries([
149
+ base("message", "u", { message: { role: "user", content: "x".repeat(200) } }),
150
+ ]);
151
+
152
+ assert.deepEqual(createSnapshot(source, 1_000), {
153
+ text: "[user]\n" + "x".repeat(200),
154
+ truncated: false,
155
+ });
156
+ const limited = createSnapshot(source, 80);
157
+ assert.equal(limited.text.length, 80);
158
+ assert.equal(limited.truncated, true);
159
+ });
160
+
161
+ test("limits output mechanically while retaining summary context and newest dialogue", () => {
162
+ const output = serializeInheritedConversation(
163
+ entries([
164
+ base("compaction", "c", { summary: "Summary that must remain available" }),
165
+ base("message", "old", {
166
+ message: { role: "user", content: "old dialogue that may disappear" },
167
+ }),
168
+ base("message", "new", {
169
+ message: {
170
+ role: "assistant",
171
+ content: [{ type: "text", text: "newest dialogue must remain" }],
172
+ },
173
+ }),
174
+ ]),
175
+ 120,
176
+ );
177
+
178
+ assert.ok(output.length <= 120);
179
+ assert.match(output, /Compaction summary/);
180
+ assert.match(output, /omitted for length/);
181
+ assert.match(output, /\[assistant\]/);
182
+ assert.match(output, /dialogue must remain/);
183
+ });
184
+
185
+ test("honors every hard limit, including delimiter expansion and tiny bounds", () => {
186
+ const source = entries([
187
+ base("compaction", "c", { summary: `<summary>${"S".repeat(120)}</summary>` }),
188
+ base("message", "u", {
189
+ message: { role: "user", content: `<task>${"U".repeat(180)}</task>` },
190
+ }),
191
+ base("message", "a", {
192
+ message: { role: "assistant", content: [{ type: "text", text: "A".repeat(180) }] },
193
+ }),
194
+ ]);
195
+
196
+ for (let limit = 1; limit <= 300; limit += 1) {
197
+ assert.ok(serializeInheritedConversation(source, limit).length <= limit);
198
+ }
199
+ });
200
+
201
+ test("redistributes unused summary budget to recent complete dialogue", () => {
202
+ const output = serializeInheritedConversation(
203
+ entries([
204
+ base("compaction", "c", { summary: "short" }),
205
+ base("message", "old", { message: { role: "user", content: "O".repeat(220) } }),
206
+ base("message", "new", {
207
+ message: { role: "assistant", content: `latest-${"N".repeat(80)}` },
208
+ }),
209
+ ]),
210
+ 240,
211
+ );
212
+
213
+ assert.equal(output.length, 240);
214
+ assert.match(output, /^\[Compaction summary\]\nshort/);
215
+ assert.match(output, /\[assistant\]\nlatest-/);
216
+ assert.match(output, /Earlier text in this message omitted/);
217
+ });