@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.
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Deterministic, text-only serialization of an active pi session branch for
3
+ * optional subagent conversation inheritance.
4
+ */
5
+
6
+ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
7
+
8
+ const OMISSION_MARKER = "[Earlier inherited conversation omitted for length.]";
9
+ const MESSAGE_OMISSION_MARKER = "[Earlier text in this message omitted.]";
10
+
11
+ type EntryLike = {
12
+ type?: unknown;
13
+ summary?: unknown;
14
+ message?: unknown;
15
+ retainedTail?: unknown;
16
+ };
17
+
18
+ type MessageLike = {
19
+ role?: unknown;
20
+ content?: unknown;
21
+ summary?: unknown;
22
+ };
23
+
24
+ type Chunk = {
25
+ kind: "summary" | "dialogue";
26
+ text: string;
27
+ };
28
+
29
+ export interface InheritedConversationSnapshot {
30
+ /** Delimiter-safe text delivered to the child. */
31
+ text: string;
32
+ /** True when eligible inherited content was omitted to satisfy maxChars. */
33
+ truncated: boolean;
34
+ }
35
+
36
+ /** Keep inherited text from being interpreted as one of the surrounding prompt tags. */
37
+ function escapePromptText(text: string): string {
38
+ return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;");
39
+ }
40
+
41
+ function textContent(content: unknown): string {
42
+ if (typeof content === "string") return escapePromptText(content);
43
+ if (!Array.isArray(content)) return "";
44
+ return content
45
+ .flatMap((part) => {
46
+ if (!part || typeof part !== "object") return [];
47
+ const block = part as { type?: unknown; text?: unknown };
48
+ return block.type === "text" && typeof block.text === "string"
49
+ ? [escapePromptText(block.text)]
50
+ : [];
51
+ })
52
+ .join("");
53
+ }
54
+
55
+ function serializeMessage(message: unknown): Chunk | undefined {
56
+ if (!message || typeof message !== "object") return undefined;
57
+ const { role, content, summary } = message as MessageLike;
58
+ if (role === "user" || role === "assistant") {
59
+ const text = textContent(content);
60
+ return text ? { kind: "dialogue", text: `[${role}]\n${text}` } : undefined;
61
+ }
62
+ if (role === "compactionSummary" && typeof summary === "string" && summary) {
63
+ return {
64
+ kind: "summary",
65
+ text: `[Compaction summary]\n${escapePromptText(summary)}`,
66
+ };
67
+ }
68
+ if (role === "branchSummary" && typeof summary === "string" && summary) {
69
+ return { kind: "summary", text: `[Branch summary]\n${escapePromptText(summary)}` };
70
+ }
71
+ return undefined;
72
+ }
73
+
74
+ function truncateDialogueChunk(text: string, maxChars: number): string {
75
+ if (text.length <= maxChars) return text;
76
+ const newline = text.indexOf("\n");
77
+ const label = newline >= 0 ? text.slice(0, newline) : "[message]";
78
+ const prefix = `${label}\n${MESSAGE_OMISSION_MARKER}\n`;
79
+ if (prefix.length >= maxChars) return prefix.slice(0, maxChars);
80
+ return prefix + text.slice(text.length - (maxChars - prefix.length));
81
+ }
82
+
83
+ /** Select newest complete dialogue chunks, truncating only the newest chunk as a last resort. */
84
+ function newestDialogue(chunks: Chunk[], maxChars: number): string {
85
+ if (maxChars <= 0 || chunks.length === 0) return "";
86
+ const selected: string[] = [];
87
+ let used = 0;
88
+ for (let i = chunks.length - 1; i >= 0; i -= 1) {
89
+ const separator = selected.length > 0 ? 2 : 0;
90
+ const remaining = maxChars - used - separator;
91
+ if (remaining <= 0) break;
92
+ const text = chunks[i].text;
93
+ if (text.length <= remaining) {
94
+ selected.unshift(text);
95
+ used += separator + text.length;
96
+ continue;
97
+ }
98
+ selected.unshift(truncateDialogueChunk(text, remaining));
99
+ break;
100
+ }
101
+ return selected.join("\n\n");
102
+ }
103
+
104
+ /**
105
+ * Serialize the supplied active, compaction-aware session entries in order.
106
+ * Only compaction/branch summaries and user/assistant text are retained.
107
+ */
108
+ export function serializeInheritedConversation(
109
+ entries: SessionEntry[],
110
+ maxChars: number,
111
+ ): InheritedConversationSnapshot {
112
+ const limit = Number.isFinite(maxChars) ? Math.max(0, Math.floor(maxChars)) : 0;
113
+ if (limit === 0) return { text: "", truncated: false };
114
+ const chunks: Chunk[] = [];
115
+
116
+ const addMessage = (message: unknown) => {
117
+ const serialized = serializeMessage(message);
118
+ if (serialized) chunks.push(serialized);
119
+ };
120
+
121
+ for (const rawEntry of entries) {
122
+ const entry = rawEntry as EntryLike;
123
+ if (entry.type === "compaction") {
124
+ if (typeof entry.summary === "string" && entry.summary) {
125
+ chunks.push({
126
+ kind: "summary",
127
+ text: `[Compaction summary]\n${escapePromptText(entry.summary)}`,
128
+ });
129
+ }
130
+ // Newer compaction entries materialize their kept context here. The
131
+ // installed firstKeptEntryId shape returns those entries separately.
132
+ if (Array.isArray(entry.retainedTail)) {
133
+ for (const message of entry.retainedTail) addMessage(message);
134
+ }
135
+ continue;
136
+ }
137
+ if (entry.type === "branch_summary") {
138
+ if (typeof entry.summary === "string" && entry.summary) {
139
+ chunks.push({
140
+ kind: "summary",
141
+ text: `[Branch summary]\n${escapePromptText(entry.summary)}`,
142
+ });
143
+ }
144
+ continue;
145
+ }
146
+ if (entry.type === "message") addMessage(entry.message);
147
+ }
148
+
149
+ const full = chunks.map((chunk) => chunk.text).join("\n\n");
150
+ if (full.length <= limit) return { text: full, truncated: false };
151
+
152
+ const summaryText = chunks
153
+ .filter((chunk) => chunk.kind === "summary")
154
+ .map((chunk) => chunk.text)
155
+ .join("\n\n");
156
+ const dialogueChunks = chunks.filter((chunk) => chunk.kind === "dialogue");
157
+ const dialogueText = dialogueChunks.map((chunk) => chunk.text).join("\n\n");
158
+
159
+ // Reserve an explicit marker, then retain summary context plus the newest
160
+ // dialogue. Start with a 40/60 split, but redistribute every unused char so
161
+ // a short side never wastes capacity. This is deterministic and model-free.
162
+ const hasSummary = summaryText.length > 0;
163
+ const hasDialogue = dialogueText.length > 0;
164
+ const separatorChars = (hasSummary ? 2 : 0) + (hasDialogue ? 2 : 0);
165
+ if (limit <= OMISSION_MARKER.length + separatorChars) {
166
+ return { text: OMISSION_MARKER.slice(0, limit), truncated: true };
167
+ }
168
+ const available = limit - OMISSION_MARKER.length - separatorChars;
169
+ let summaryBudget =
170
+ hasSummary && hasDialogue ? Math.floor(available * 0.4) : hasSummary ? available : 0;
171
+ let dialogueBudget = hasDialogue ? available - summaryBudget : 0;
172
+
173
+ if (summaryText.length < summaryBudget) {
174
+ dialogueBudget += summaryBudget - summaryText.length;
175
+ summaryBudget = summaryText.length;
176
+ }
177
+ if (dialogueText.length < dialogueBudget) {
178
+ summaryBudget += dialogueBudget - dialogueText.length;
179
+ dialogueBudget = dialogueText.length;
180
+ }
181
+
182
+ const selectedSummary = summaryBudget > 0 ? summaryText.slice(0, summaryBudget) : "";
183
+ const selectedDialogue = newestDialogue(dialogueChunks, dialogueBudget);
184
+ return {
185
+ text: [selectedSummary, OMISSION_MARKER, selectedDialogue].filter(Boolean).join("\n\n"),
186
+ truncated: true,
187
+ };
188
+ }
@@ -5,9 +5,10 @@
5
5
  * node --test packages/pi-subagent/src/reminder.test.ts
6
6
  *
7
7
  * Coverage: row formatting per state (queued/running/finished/failed/budget),
8
- * byte-stability between calls (the cache-prefix contract), empty-inbox
9
- * no-op, and cache-stable head injection (string content, block content,
10
- * non-user first message, empty transcript).
8
+ * delivery-derived row removal (terminal rows checked on the active branch
9
+ * drop out; live rows never do), byte-stability between calls (the
10
+ * cache-prefix contract), empty-inbox no-op, and cache-stable head injection
11
+ * (string content, block content, non-user first message, empty transcript).
11
12
  */
12
13
 
13
14
  import { test, describe } from "node:test";
@@ -40,19 +41,22 @@ function entry(partial: Partial<InboxEntry> & Pick<InboxEntry, "id" | "state">):
40
41
 
41
42
  describe("buildInboxReminder", () => {
42
43
  test("empty inbox returns undefined (zero injection)", () => {
43
- assert.equal(buildInboxReminder([]), undefined);
44
+ assert.equal(buildInboxReminder([], new Set()), undefined);
44
45
  });
45
46
 
46
47
  test("queued and running rows carry no time-derived detail", () => {
47
- const text = buildInboxReminder([
48
- entry({ id: "sub-3", state: "queued", snapshot: frame({ exitCode: -1, queued: true }) }),
49
- entry({
50
- id: "sub-2",
51
- state: "running",
52
- // Live frame: startTime present — a naive formatter would derive elapsed from it.
53
- snapshot: frame({ exitCode: -1, startTime: Date.now() - 60_000 }),
54
- }),
55
- ])!;
48
+ const text = buildInboxReminder(
49
+ [
50
+ entry({ id: "sub-3", state: "queued", snapshot: frame({ exitCode: -1, queued: true }) }),
51
+ entry({
52
+ id: "sub-2",
53
+ state: "running",
54
+ // Live frame: startTime present a naive formatter would derive elapsed from it.
55
+ snapshot: frame({ exitCode: -1, startTime: Date.now() - 60_000 }),
56
+ }),
57
+ ],
58
+ new Set(),
59
+ )!;
56
60
  assert.match(text, /\n- sub-3 \(worker\) — queued — "Investigate flaky tests/);
57
61
  assert.match(text, /\n- sub-2 \(worker\) — running — "Investigate flaky tests/);
58
62
  // No seconds anywhere on live rows — byte-stability contract.
@@ -60,56 +64,68 @@ describe("buildInboxReminder", () => {
60
64
  });
61
65
 
62
66
  test("finished row freezes duration from elapsedMs", () => {
63
- const text = buildInboxReminder([
64
- entry({
65
- id: "sub-1",
66
- state: "finished",
67
- snapshot: frame({ exitCode: 0, elapsedMs: 192_000 }),
68
- }),
69
- ])!;
67
+ const text = buildInboxReminder(
68
+ [
69
+ entry({
70
+ id: "sub-1",
71
+ state: "finished",
72
+ snapshot: frame({ exitCode: 0, elapsedMs: 192_000 }),
73
+ }),
74
+ ],
75
+ new Set(),
76
+ )!;
70
77
  assert.match(text, /\n- sub-1 \(worker\) — finished \(ran 3m12s\) — "Investigate flaky tests/);
71
78
  });
72
79
 
73
80
  test("budget-stopped finished row is flagged partial", () => {
74
- const text = buildInboxReminder([
75
- entry({
76
- id: "sub-1",
77
- state: "finished",
78
- snapshot: frame({ exitCode: 0, elapsedMs: 300_000, stopReason: "budget_exceeded" }),
79
- }),
80
- ])!;
81
+ const text = buildInboxReminder(
82
+ [
83
+ entry({
84
+ id: "sub-1",
85
+ state: "finished",
86
+ snapshot: frame({ exitCode: 0, elapsedMs: 300_000, stopReason: "budget_exceeded" }),
87
+ }),
88
+ ],
89
+ new Set(),
90
+ )!;
81
91
  assert.match(text, /— finished, partial — budget exceeded \(ran 5m\) —/);
82
92
  });
83
93
 
84
94
  test("failed row carries the error preview, first line only", () => {
85
- const text = buildInboxReminder([
86
- entry({
87
- id: "sub-4",
88
- state: "failed",
89
- snapshot: frame({
90
- exitCode: 1,
91
- elapsedMs: 5_000,
92
- errorMessage: "provider timeout\nretry hint: check quota",
95
+ const text = buildInboxReminder(
96
+ [
97
+ entry({
98
+ id: "sub-4",
99
+ state: "failed",
100
+ snapshot: frame({
101
+ exitCode: 1,
102
+ elapsedMs: 5_000,
103
+ errorMessage: "provider timeout\nretry hint: check quota",
104
+ }),
93
105
  }),
94
- }),
95
- ])!;
106
+ ],
107
+ new Set(),
108
+ )!;
96
109
  assert.match(text, /— failed — provider timeout \(ran 5s\) —/);
97
110
  assert.doesNotMatch(text, /retry hint/);
98
111
  });
99
112
 
100
113
  test("cancelled row carries the cancel label, not failed", () => {
101
- const text = buildInboxReminder([
102
- entry({
103
- id: "sub-5",
104
- state: "failed",
105
- snapshot: frame({
106
- exitCode: 1,
107
- stopReason: "cancelled",
108
- elapsedMs: 7_000,
109
- errorMessage: "user: wrong direction after review",
114
+ const text = buildInboxReminder(
115
+ [
116
+ entry({
117
+ id: "sub-5",
118
+ state: "failed",
119
+ snapshot: frame({
120
+ exitCode: 1,
121
+ stopReason: "cancelled",
122
+ elapsedMs: 7_000,
123
+ errorMessage: "user: wrong direction after review",
124
+ }),
110
125
  }),
111
- }),
112
- ])!;
126
+ ],
127
+ new Set(),
128
+ )!;
113
129
  assert.match(text, /— cancelled — user: wrong direction after review \(ran 7s\) —/);
114
130
  });
115
131
 
@@ -118,19 +134,52 @@ describe("buildInboxReminder", () => {
118
134
  entry({ id: "sub-1", state: "finished", snapshot: frame({ exitCode: 0, elapsedMs: 42_000 }) }),
119
135
  entry({ id: "sub-2", state: "running", snapshot: frame({ exitCode: -1, startTime: 123 }) }),
120
136
  ];
121
- assert.equal(buildInboxReminder(entries), buildInboxReminder(entries));
137
+ assert.equal(buildInboxReminder(entries, new Set()), buildInboxReminder(entries, new Set()));
122
138
  });
123
139
 
124
140
  test("long task text is truncated to the shared 70-char preview cap", () => {
125
141
  const long = "x".repeat(120);
126
- const text = buildInboxReminder([entry({ id: "sub-9", state: "running", task: long })])!;
142
+ const text = buildInboxReminder([entry({ id: "sub-9", state: "running", task: long })], new Set())!;
127
143
  assert.ok(text.includes(`"${"x".repeat(70)}..."`));
128
144
  });
129
145
 
130
146
  test("header explains pull-only collection semantics", () => {
131
- const text = buildInboxReminder([entry({ id: "sub-1", state: "running" })])!;
147
+ const text = buildInboxReminder([entry({ id: "sub-1", state: "running" })], new Set())!;
132
148
  assert.match(text, /^\[background subagent runs — results are pull-only for the model/);
133
- assert.match(text, /already collected\]/);
149
+ assert.match(text, /already checked on this branch\]/);
150
+ });
151
+
152
+ test("terminal rows in the delivered set drop out of the inbox", () => {
153
+ const text = buildInboxReminder(
154
+ [
155
+ entry({ id: "sub-1", state: "finished", snapshot: frame({ exitCode: 0, elapsedMs: 42_000 }) }),
156
+ entry({ id: "sub-2", state: "failed", snapshot: frame({ exitCode: 1, elapsedMs: 5_000 }) }),
157
+ ],
158
+ new Set(["sub-1"]),
159
+ )!;
160
+ assert.doesNotMatch(text, /sub-1/);
161
+ assert.match(text, /sub-2 \(worker\) — failed/);
162
+ });
163
+
164
+ test("live rows stay listed even when their id is in the delivered set", () => {
165
+ // A live frame checked mid-run does not count as delivery — the result
166
+ // was not final yet, so the run keeps nagging until a terminal check.
167
+ const text = buildInboxReminder(
168
+ [
169
+ entry({ id: "sub-1", state: "queued", snapshot: frame({ exitCode: -1, queued: true }) }),
170
+ entry({ id: "sub-2", state: "running", snapshot: frame({ exitCode: -1, startTime: 99 }) }),
171
+ ],
172
+ new Set(["sub-1", "sub-2"]),
173
+ )!;
174
+ assert.match(text, /sub-1 \(worker\) — queued/);
175
+ assert.match(text, /sub-2 \(worker\) — running/);
176
+ });
177
+
178
+ test("all terminal rows delivered returns undefined (zero injection)", () => {
179
+ const entries = [
180
+ entry({ id: "sub-1", state: "finished", snapshot: frame({ exitCode: 0, elapsedMs: 42_000 }) }),
181
+ ];
182
+ assert.equal(buildInboxReminder(entries, new Set(["sub-1"])), undefined);
134
183
  });
135
184
  });
136
185
 
package/src/reminder.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  /**
2
- * The model's inbox of unclaimed background subagent runs.
2
+ * The model's inbox of background subagent runs.
3
3
  *
4
4
  * Injected into the LLM context before every provider call via the `context`
5
- * event. The reminder lists every delegated run whose result has not been
6
- * claimed yet — queued, running, and finished/failed alike — so the model
7
- * cannot forget about them. `subagent_check` on a terminal run returns the
8
- * output AND removes the run from this list (read-once collection).
5
+ * event. The reminder lists every delegated run not yet delivered by a
6
+ * subagent_check on the active branch — queued, running, and
7
+ * finished/failed alike so the model cannot forget about them. Delivery
8
+ * state is derived from the session tree (see collectDeliveredIds), not
9
+ * tracked in the registry: branching past a check re-arms the inbox,
10
+ * branching back silences it, and compaction un-delivers naturally.
9
11
  *
10
12
  * Cache discipline: the reminder is prepended to the FIRST user message, so
11
13
  * it sits at a stable position in the message prefix. Its content must stay
@@ -28,7 +30,7 @@ export interface InboxEntry {
28
30
  }
29
31
 
30
32
  const INBOX_HEADER =
31
- "[background subagent runs — results are pull-only for the model: no completion notice wakes you. subagent_wait, then subagent_check to collect each run; a terminal check removes it from this list; runs missing here were already collected]";
33
+ "[background subagent runs — results are pull-only for the model: no completion notice wakes you. subagent_wait, then subagent_check to collect each run; a terminal check removes it from this list; runs missing here were already checked on this branch]";
32
34
 
33
35
  /** `42s`, `3m12s`, `4m` — whole seconds, no live clocks. */
34
36
  function formatDuration(totalSec: number): string {
@@ -61,12 +63,16 @@ function inboxStatus(entry: InboxEntry): string {
61
63
 
62
64
  /**
63
65
  * Build the inbox reminder text, or undefined when every delegated run has
64
- * been collected (nothing to remind about — inject nothing, keep the context
65
- * untouched and the provider cache fully stable).
66
+ * already been checked on the active branch (nothing to remind about —
67
+ * inject nothing, keep the context untouched and the provider cache fully
68
+ * stable). Queued/running runs are always listed regardless of delivery
69
+ * state — their result is not final yet, so a past check (of a live frame)
70
+ * never counts as delivered.
66
71
  */
67
- export function buildInboxReminder(entries: Iterable<InboxEntry>): string | undefined {
72
+ export function buildInboxReminder(entries: Iterable<InboxEntry>, delivered: Set<string>): string | undefined {
68
73
  const rows: string[] = [];
69
74
  for (const entry of entries) {
75
+ if (entry.state !== "queued" && entry.state !== "running" && delivered.has(entry.id)) continue;
70
76
  rows.push(`- ${entry.id} (${entry.role}) — ${inboxStatus(entry)} — "${taskPreview(entry.task)}"`);
71
77
  }
72
78
  if (rows.length === 0) return undefined;
@@ -33,6 +33,7 @@ import type {
33
33
  CheckDetails,
34
34
  CompletionNoticeDetails,
35
35
  RunViewEntry,
36
+ SteerDetails,
36
37
  SubagentResult,
37
38
  WaitDetails,
38
39
  } from "./types.ts";
@@ -46,6 +47,7 @@ import {
46
47
  deriveRunState,
47
48
  ensureElapsedTimer,
48
49
  formatFallback,
50
+ formatInheritedConversationInput,
49
51
  formatThinking,
50
52
  formatTimePart,
51
53
  formatToolCall,
@@ -282,10 +284,13 @@ export const renderCompletionNotice: MessageRenderer<CompletionNoticeDetails> =
282
284
 
283
285
  export const renderBackgroundDelegateCall: RenderCallFn = (args, theme) => {
284
286
  const roleName = (args as any).role || "...";
287
+ const mode = (args as any).inheritConversation
288
+ ? " (background · inherits conversation)"
289
+ : " (background)";
285
290
  const text =
286
291
  theme.fg("toolTitle", theme.bold("subagent_delegate ")) +
287
292
  theme.fg("accent", roleName) +
288
- theme.fg("dim", " (background)");
293
+ theme.fg("dim", mode);
289
294
  return new Text(text, 0, 0);
290
295
  };
291
296
 
@@ -301,7 +306,7 @@ export const renderBackgroundDelegateResult: RenderResultFn = (result, { expande
301
306
 
302
307
  if (!expanded) return collapsedText(summaryLine);
303
308
 
304
- // Expanded: full input — reference files, context size, task text.
309
+ // Expanded: full input — reference files, context/inheritance metadata, task text.
305
310
  const container = new Container();
306
311
  container.addChild(new Text(summaryLine, 0, 0));
307
312
  container.addChild(new Spacer(1));
@@ -313,6 +318,21 @@ export const renderBackgroundDelegateResult: RenderResultFn = (result, { expande
313
318
  if (details.context) {
314
319
  container.addChild(new Text(fg("dim", `ctx ${details.context.length} chars`), 0, 0));
315
320
  }
321
+ if (details.inheritConversation) {
322
+ container.addChild(
323
+ new Text(
324
+ fg(
325
+ "dim",
326
+ formatInheritedConversationInput(
327
+ details.inheritedConversationChars ?? 0,
328
+ details.inheritedConversationTruncated === true,
329
+ ),
330
+ ),
331
+ 0,
332
+ 0,
333
+ ),
334
+ );
335
+ }
316
336
  container.addChild(new Text(fg("dim", details.task), 0, 0));
317
337
  return container;
318
338
  };
@@ -387,6 +407,54 @@ export const renderCheckResult: RenderResultFn = (result, { expanded }, theme, _
387
407
  return collapsedText(checkEntryCollapsedText(details.result, fg));
388
408
  };
389
409
 
410
+ // ── steer: correction echo (check verifies the effect) ──
411
+
412
+ /**
413
+ * Factory: the call line shows the run's role, but args carry only id +
414
+ * message — the registry lookup is injected. Unknown id (e.g. re-rendering a
415
+ * persisted session where the registry is empty) degrades to the bare id.
416
+ */
417
+ export function createSteerCallRender(roleOf: (id: string) => string | undefined): RenderCallFn {
418
+ return (args, theme) => {
419
+ const id = (args as any).id || "...";
420
+ const role = roleOf(id);
421
+ const label = role ? `${id} (${role})` : id;
422
+ const text = theme.fg("toolTitle", theme.bold("subagent_steer ")) + theme.fg("accent", label);
423
+ return new Text(text, 0, 0);
424
+ };
425
+ }
426
+
427
+ export const renderSteerResult: RenderResultFn = (result, { expanded }, theme) => {
428
+ const details = result.details as SteerDetails | undefined;
429
+ if (!details) return collapsedText(contentText(result));
430
+
431
+ const fg = theme.fg.bind(theme) as Fg;
432
+ const icon = fg("accent", "\u21a9"); // ↩ — same marker as steer entries in the activity stream
433
+ const message = details.message.trim() || "(empty message)";
434
+
435
+ // Body carries the correction only — no id/role prefix; the target lives in
436
+ // the call line and the delivery hint below.
437
+ if (!expanded) {
438
+ const firstLine = message.split("\n")[0];
439
+ return collapsedText(`${icon} ${fg("text", firstLine)}`);
440
+ }
441
+
442
+ const container = new Container();
443
+ container.addChild(new Text(`${icon} ${fg("text", message)}`, 0, 0));
444
+ container.addChild(new Spacer(1));
445
+ container.addChild(
446
+ new Text(
447
+ fg(
448
+ "dim",
449
+ `${details.id} (${details.role}) — delivered after the current tool batch, verify with subagent_check later`,
450
+ ),
451
+ 0,
452
+ 0,
453
+ ),
454
+ );
455
+ return container;
456
+ };
457
+
390
458
  // ── cancel: confirmation-only view (check is the result-fetcher) ──
391
459
 
392
460
  export const renderCancelCall: RenderCallFn = (args, theme) => {
@@ -0,0 +1,63 @@
1
+ /** Tests for inherited-conversation TUI observability. */
2
+
3
+ import assert from "node:assert/strict";
4
+ import test from "node:test";
5
+ import { renderBackgroundDelegateCall, renderBackgroundDelegateResult } from "./render-async.ts";
6
+ import { renderDelegateCall } from "./render.ts";
7
+
8
+ const theme = {
9
+ fg: (_color: string, text: string) => text,
10
+ bold: (text: string) => text,
11
+ } as any;
12
+
13
+ function rendered(component: { render(width: number): string[] }): string {
14
+ return component
15
+ .render(200)
16
+ .map((line) => line.trimEnd())
17
+ .join("\n");
18
+ }
19
+
20
+ test("delegate call titles mark inherited conversation without changing isolated mode", () => {
21
+ const isolated = rendered(renderDelegateCall({ role: "worker" } as any, theme, {} as any));
22
+ const inherited = rendered(
23
+ renderDelegateCall({ role: "worker", inheritConversation: true } as any, theme, {} as any),
24
+ );
25
+
26
+ assert.equal(isolated, "subagent_delegate worker");
27
+ assert.equal(inherited, "subagent_delegate worker (inherits conversation)");
28
+ });
29
+
30
+ test("background call and expanded input expose only safe inheritance metadata", () => {
31
+ const call = rendered(
32
+ renderBackgroundDelegateCall(
33
+ { role: "worker", background: true, inheritConversation: true } as any,
34
+ theme,
35
+ {} as any,
36
+ ),
37
+ );
38
+ assert.equal(call, "subagent_delegate worker (background · inherits conversation)");
39
+
40
+ const result = rendered(
41
+ renderBackgroundDelegateResult(
42
+ {
43
+ content: [{ type: "text", text: "started" }],
44
+ details: {
45
+ id: "sub-1",
46
+ role: "worker",
47
+ task: "Implement the delta",
48
+ context: "explicit context",
49
+ inheritConversation: true,
50
+ inheritedConversationChars: 50_000,
51
+ inheritedConversationTruncated: true,
52
+ },
53
+ } as any,
54
+ { expanded: true, isPartial: false },
55
+ theme,
56
+ {} as any,
57
+ ),
58
+ );
59
+
60
+ assert.match(result, /ctx 16 chars/);
61
+ assert.match(result, /conversation 50000 chars · truncated/);
62
+ assert.ok(!result.includes("inherited_conversation"));
63
+ });
package/src/render.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  contentText,
16
16
  ensureElapsedTimer,
17
17
  formatFallback,
18
+ formatInheritedConversationInput,
18
19
  formatThinking,
19
20
  formatTimePart,
20
21
  formatToolCall,
@@ -35,7 +36,13 @@ type RenderResultFn = NonNullable<ToolDefinition["renderResult"]>;
35
36
 
36
37
  export const renderDelegateCall: RenderCallFn = (args, theme, _context) => {
37
38
  const roleName = (args as any).role || "...";
38
- const text = theme.fg("toolTitle", theme.bold("subagent_delegate ")) + theme.fg("accent", roleName);
39
+ const inheritance = (args as any).inheritConversation
40
+ ? theme.fg("dim", " (inherits conversation)")
41
+ : "";
42
+ const text =
43
+ theme.fg("toolTitle", theme.bold("subagent_delegate ")) +
44
+ theme.fg("accent", roleName) +
45
+ inheritance;
39
46
  return new Text(text, 0, 0);
40
47
  };
41
48
 
@@ -104,8 +111,8 @@ export const renderDelegateResult: RenderResultFn = (result, { expanded }, theme
104
111
  container.addChild(new Text(fallbackLine, 0, 0));
105
112
  }
106
113
 
107
- // Input block: reference files + context char count + task full text,
108
- // grouped without inner spacing (they are all subagent input).
114
+ // Input block: reference files + context/inherited-conversation metadata
115
+ // + task full text, grouped without inner spacing (all subagent input).
109
116
  container.addChild(new Spacer(1));
110
117
  if (r.files) {
111
118
  for (const f of r.files) {
@@ -115,6 +122,21 @@ export const renderDelegateResult: RenderResultFn = (result, { expanded }, theme
115
122
  if (r.context) {
116
123
  container.addChild(new Text(theme.fg("dim", `ctx ${r.context.length} chars`), 0, 0));
117
124
  }
125
+ if (r.inheritConversation) {
126
+ container.addChild(
127
+ new Text(
128
+ theme.fg(
129
+ "dim",
130
+ formatInheritedConversationInput(
131
+ r.inheritedConversationChars ?? 0,
132
+ r.inheritedConversationTruncated === true,
133
+ ),
134
+ ),
135
+ 0,
136
+ 0,
137
+ ),
138
+ );
139
+ }
118
140
  container.addChild(new Text(theme.fg("dim", r.task), 0, 0));
119
141
 
120
142
  // Activity stream (shown while running and after completion).