@d3ara1n/pi-subagent 3.0.0 → 3.2.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
+ }
@@ -47,6 +47,7 @@ import {
47
47
  deriveRunState,
48
48
  ensureElapsedTimer,
49
49
  formatFallback,
50
+ formatInheritedConversationInput,
50
51
  formatThinking,
51
52
  formatTimePart,
52
53
  formatToolCall,
@@ -283,10 +284,13 @@ export const renderCompletionNotice: MessageRenderer<CompletionNoticeDetails> =
283
284
 
284
285
  export const renderBackgroundDelegateCall: RenderCallFn = (args, theme) => {
285
286
  const roleName = (args as any).role || "...";
287
+ const mode = (args as any).inheritConversation
288
+ ? " (background · inherits conversation)"
289
+ : " (background)";
286
290
  const text =
287
291
  theme.fg("toolTitle", theme.bold("subagent_delegate ")) +
288
292
  theme.fg("accent", roleName) +
289
- theme.fg("dim", " (background)");
293
+ theme.fg("dim", mode);
290
294
  return new Text(text, 0, 0);
291
295
  };
292
296
 
@@ -302,7 +306,7 @@ export const renderBackgroundDelegateResult: RenderResultFn = (result, { expande
302
306
 
303
307
  if (!expanded) return collapsedText(summaryLine);
304
308
 
305
- // Expanded: full input — reference files, context size, task text.
309
+ // Expanded: full input — reference files, context/inheritance metadata, task text.
306
310
  const container = new Container();
307
311
  container.addChild(new Text(summaryLine, 0, 0));
308
312
  container.addChild(new Spacer(1));
@@ -314,6 +318,21 @@ export const renderBackgroundDelegateResult: RenderResultFn = (result, { expande
314
318
  if (details.context) {
315
319
  container.addChild(new Text(fg("dim", `ctx ${details.context.length} chars`), 0, 0));
316
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
+ }
317
336
  container.addChild(new Text(fg("dim", details.task), 0, 0));
318
337
  return container;
319
338
  };
@@ -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).
package/src/roles.ts CHANGED
@@ -40,17 +40,24 @@ export const BUILTIN_ROLES: Record<string, SubagentRole> = {
40
40
  fallbackRole: "default",
41
41
  timeout: 3600,
42
42
  description:
43
- "READ-ONLY code review & analysis — audit code, assess architecture, review diffs, run tests for evidence. Reports findings and suggested fixes but never implements them.",
43
+ "READ-ONLY code review & analysis — audit code, assess architecture, review diffs, run tests for evidence. Reports findings and suggested fixes but never implements them. Can delegate to explorer/researcher.",
44
44
  examples: [
45
45
  "Review the error handling in src/api/ for security issues",
46
46
  "Audit this PR diff for performance regressions",
47
47
  ],
48
48
  decisionTrigger: "Task audits or reviews code quality?",
49
- tools: ["read", "bash", "grep", "find"],
49
+ tools: ["read", "bash", "grep", "find", "subagent_delegate"],
50
+ subagentRoles: ["explorer", "researcher"],
50
51
  systemPrompt: [
51
52
  "Senior code reviewer. READ-ONLY — you must NOT modify any file.",
52
53
  "If the task asks you to fix or implement, do NOT do it: report findings and suggested fixes, and state that implementation is out of scope for this role.",
53
54
  "Run only read-only commands (git diff/log/show, test runs). Never use sed, tee, echo >, or any write command.",
55
+ "",
56
+ "## Delegation",
57
+ "You have a `subagent_delegate` tool — spend it to keep your review context focused:",
58
+ "- subagent_delegate(role=explorer) to map unfamiliar code touched by the change under review",
59
+ "- subagent_delegate(role=researcher) to verify third-party library APIs/versions against official docs",
60
+ "Don't delegate the review itself — reading and judging the code is your job.",
54
61
  "Provide evidence-backed findings with file:line references.",
55
62
  "",
56
63
  "Output format (prioritize critical issues first):",
package/src/run.test.ts CHANGED
@@ -167,7 +167,9 @@ test("spawned runs persist to history on every terminal path; pre-run failures d
167
167
  spawnImpl: async (_m, _t, options) => {
168
168
  options.onProgress?.({
169
169
  output: "partial",
170
- activityLog: [{ kind: "toolCall", id: "t1", status: "running", toolName: "bash", args: {} }],
170
+ activityLog: [
171
+ { kind: "toolCall", id: "t1", status: "running", toolName: "bash", args: {} },
172
+ ],
171
173
  });
172
174
  throw new Error("Subagent was aborted");
173
175
  },
@@ -229,6 +231,54 @@ test("provider error on first attempt retries on the fallback role", async () =>
229
231
  assert.strictEqual(result.fallbackFrom.model, "test/model-fast");
230
232
  });
231
233
 
234
+ test("forwards an immutable inherited conversation to first and fallback spawns", async () => {
235
+ const received: Array<{
236
+ model: string;
237
+ inheritConversation?: boolean;
238
+ inheritedConversation?: string;
239
+ }> = [];
240
+ const spawnImpl: SpawnImpl = async (model, _task, options) => {
241
+ received.push({
242
+ model,
243
+ inheritConversation: options.inheritConversation,
244
+ inheritedConversation: options.inheritedConversation,
245
+ });
246
+ return received.length === 1
247
+ ? makeResult({ exitCode: 1, errorMessage: "429 quota exceeded", stderr: "HTTP 429" })
248
+ : makeResult({ output: "fallback ok" });
249
+ };
250
+
251
+ const run = startSubagentRun(
252
+ makeDeps({
253
+ roleDef: { ...roleDef, fallbackRole: "default" },
254
+ inheritConversation: true,
255
+ inheritedConversation: "[user]\\nParent requirement",
256
+ inheritedConversationTruncated: true,
257
+ spawnImpl,
258
+ }),
259
+ );
260
+ const result = await run.promise;
261
+
262
+ assert.deepEqual(received, [
263
+ {
264
+ model: "test/model-fast",
265
+ inheritConversation: true,
266
+ inheritedConversation: "[user]\\nParent requirement",
267
+ },
268
+ {
269
+ model: "test/model-default",
270
+ inheritConversation: true,
271
+ inheritedConversation: "[user]\\nParent requirement",
272
+ },
273
+ ]);
274
+ assert.equal(run.inheritConversation, true);
275
+ assert.equal(run.inheritedConversationChars, "[user]\\nParent requirement".length);
276
+ assert.equal(run.inheritedConversationTruncated, true);
277
+ assert.equal(result.inheritConversation, true);
278
+ assert.equal(result.inheritedConversationChars, "[user]\\nParent requirement".length);
279
+ assert.equal(result.inheritedConversationTruncated, true);
280
+ });
281
+
232
282
  test("prerun failure (roles api unavailable) becomes a failed run, not a throw", async () => {
233
283
  const run = startSubagentRun(
234
284
  makeDeps({
package/src/run.ts CHANGED
@@ -48,6 +48,9 @@ export interface RunHandle {
48
48
  readonly task: string;
49
49
  readonly context?: string;
50
50
  readonly files?: string[];
51
+ readonly inheritConversation?: boolean;
52
+ readonly inheritedConversationChars?: number;
53
+ readonly inheritedConversationTruncated?: boolean;
51
54
  /** Lifecycle state, kept in sync with the latest snapshot frame. */
52
55
  readonly state: RunState;
53
56
  /** Latest frame: queued placeholder, live progress, or terminal result. */
@@ -76,6 +79,12 @@ export interface StartRunOptions {
76
79
  task: string;
77
80
  context?: string;
78
81
  files?: string[];
82
+ /** Opt in to a text-only snapshot of the parent's active conversation. */
83
+ inheritConversation?: boolean;
84
+ /** Immutable serialized parent-conversation body; never persisted to history. */
85
+ inheritedConversation?: string;
86
+ /** Whether maxChars shortened the serialized parent conversation. */
87
+ inheritedConversationTruncated?: boolean;
79
88
  cwd: string;
80
89
  /** Nesting depth for the child (CURRENT_DEPTH + 1). */
81
90
  depth: number;
@@ -98,6 +107,13 @@ export interface StartRunOptions {
98
107
  export function startSubagentRun(opts: StartRunOptions): RunHandle {
99
108
  const spawn = opts.spawnImpl ?? spawnSubagent;
100
109
  const listeners = new Set<() => void>();
110
+ const inheritanceMetadata = opts.inheritConversation
111
+ ? {
112
+ inheritConversation: true as const,
113
+ inheritedConversationChars: opts.inheritedConversation?.length ?? 0,
114
+ inheritedConversationTruncated: opts.inheritedConversationTruncated ?? false,
115
+ }
116
+ : {};
101
117
 
102
118
  const inputFrame = (exitCode: number, queued: boolean): SubagentResult => ({
103
119
  role: opts.role,
@@ -110,6 +126,7 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
110
126
  activityLog: [],
111
127
  files: opts.files,
112
128
  context: opts.context,
129
+ ...inheritanceMetadata,
113
130
  });
114
131
 
115
132
  let currentState: RunState = "queued";
@@ -163,6 +180,7 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
163
180
  task: opts.task,
164
181
  context: opts.context,
165
182
  files: opts.files,
183
+ ...inheritanceMetadata,
166
184
  get state() {
167
185
  return currentState;
168
186
  },
@@ -198,8 +216,7 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
198
216
  try {
199
217
  await opts.gate.acquire(controller.signal);
200
218
  } catch {
201
- const msg =
202
- "still queued for a concurrency slot" + (abortReason ? ` (${abortReason})` : "");
219
+ const msg = "still queued for a concurrency slot" + (abortReason ? ` (${abortReason})` : "");
203
220
  finish(
204
221
  { ...inputFrame(1, false), stopReason: "cancelled", errorMessage: msg },
205
222
  new Error(msg),
@@ -279,9 +296,11 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
279
296
  pauseStart: partial.pauseStart,
280
297
  files: opts.files,
281
298
  context: opts.context,
299
+ ...inheritanceMetadata,
282
300
  fallbackFrom: activeFallbackFrom,
283
301
  });
284
- const emitProgress = (partial: Partial<SubagentResult>) => setFrame(liveFrame(partial), "running");
302
+ const emitProgress = (partial: Partial<SubagentResult>) =>
303
+ setFrame(liveFrame(partial), "running");
285
304
 
286
305
  // Running placeholder now that we hold a slot.
287
306
  setFrame(liveFrame({}), "running");
@@ -294,6 +313,8 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
294
313
  systemPrompt: opts.roleDef.systemPrompt,
295
314
  context: opts.context,
296
315
  contextFiles: opts.files,
316
+ inheritConversation: opts.inheritConversation,
317
+ inheritedConversation: opts.inheritedConversation,
297
318
  subagentRoles: opts.roleDef.subagentRoles,
298
319
  timeoutMs: timeoutBudgetMs,
299
320
  maxTurns,
@@ -330,6 +351,8 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
330
351
  systemPrompt: opts.roleDef.systemPrompt,
331
352
  context: opts.context,
332
353
  contextFiles: opts.files,
354
+ inheritConversation: opts.inheritConversation,
355
+ inheritedConversation: opts.inheritedConversation,
333
356
  subagentRoles: opts.roleDef.subagentRoles,
334
357
  timeoutMs: timeoutBudgetMs,
335
358
  maxTurns,
@@ -351,6 +374,7 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
351
374
  runResult.role = opts.role;
352
375
  runResult.files = opts.files;
353
376
  runResult.context = opts.context;
377
+ Object.assign(runResult, inheritanceMetadata);
354
378
  runResult.elapsedMs = Date.now() - startTime;
355
379
 
356
380
  // Compress/truncate oversized output before it reaches the main model or TUI.
@@ -398,9 +422,7 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
398
422
  activityLog: partial.activityLog,
399
423
  budgetMs: partial.budgetMs,
400
424
  elapsedMs: partial.startTime ? Date.now() - partial.startTime : undefined,
401
- errorMessage: wasCancelled
402
- ? abortReason || "cancelled"
403
- : err?.message || String(err),
425
+ errorMessage: wasCancelled ? abortReason || "cancelled" : err?.message || String(err),
404
426
  };
405
427
  // The run spawned before throwing — audit it like any terminal state.
406
428
  // The partial output is raw (compression never ran on it).
package/src/spawn.test.ts CHANGED
@@ -17,7 +17,7 @@ describe("composeInitialMessage", () => {
17
17
  try {
18
18
  const fileA = path.join(dir, "a.md");
19
19
  fs.writeFileSync(fileA, "alpha content");
20
- const message = await composeInitialMessage([fileA], "some ctx", "do the thing");
20
+ const message = await composeInitialMessage([fileA], undefined, "some ctx", "do the thing");
21
21
  assert.equal(
22
22
  message,
23
23
  `<file name="${fileA}">\nalpha content\n</file>\n\n<context>\nsome ctx\n</context>\n\n<task>\ndo the thing\n</task>`,
@@ -28,24 +28,40 @@ describe("composeInitialMessage", () => {
28
28
  });
29
29
 
30
30
  test("omits absent channels and preserves relative block order", async () => {
31
- const message = await composeInitialMessage(undefined, undefined, "only a task");
31
+ const message = await composeInitialMessage(undefined, undefined, undefined, "only a task");
32
32
  assert.equal(message, "<task>\nonly a task\n</task>");
33
- const ctxOnly = await composeInitialMessage(undefined, "ctx body", "");
33
+ const ctxOnly = await composeInitialMessage(undefined, undefined, "ctx body", "");
34
34
  assert.equal(ctxOnly, "<context>\nctx body\n</context>");
35
35
  });
36
36
 
37
37
  test("unreadable files degrade to a placeholder instead of failing the run", async () => {
38
38
  const missing = path.join(os.tmpdir(), "pi-sub-test-does-not-exist.md");
39
- const message = await composeInitialMessage([missing], undefined, "t");
39
+ const message = await composeInitialMessage([missing], undefined, undefined, "t");
40
40
  assert.match(message, /^\[?<file name="/);
41
41
  assert.match(message, /failed to read file/);
42
42
  assert.match(message, /\n\n<task>\nt\n<\/task>$/);
43
43
  });
44
44
 
45
45
  test("blank (whitespace-only) context is dropped", async () => {
46
- const message = await composeInitialMessage(undefined, " \n\t", "t");
46
+ const message = await composeInitialMessage(undefined, undefined, " \n\t", "t");
47
47
  assert.equal(message, "<task>\nt\n</task>");
48
48
  });
49
+
50
+ test("puts inherited conversation after files and before context/task", async () => {
51
+ const message = await composeInitialMessage(
52
+ ["missing.txt"],
53
+ "parent text",
54
+ "explicit ctx",
55
+ "delta task",
56
+ );
57
+ assert.ok(
58
+ message.indexOf('<file name="missing.txt">') < message.indexOf("<inherited_conversation>"),
59
+ );
60
+ assert.ok(message.indexOf("<inherited_conversation>") < message.indexOf("<context>"));
61
+ assert.ok(message.indexOf("<context>") < message.lastIndexOf("<task>"));
62
+ assert.match(message, /text-only background/i);
63
+ assert.match(message, /separate task block remains authoritative/);
64
+ });
49
65
  });
50
66
 
51
67
  describe("buildChildArgs", () => {
@@ -92,12 +108,20 @@ describe("buildChildArgs", () => {
92
108
  assert.ok(!args.includes("--exclude-tools"));
93
109
  });
94
110
 
111
+ test("policy is conditional on conversation inheritance", () => {
112
+ const isolatedArgs = buildChildArgs("m", {}, "/t");
113
+ assert.deepEqual(buildChildArgs("m", { inheritConversation: false }, "/t"), isolatedArgs);
114
+ const isolated = isolatedArgs.join("\n");
115
+ const inherited = buildChildArgs("m", { inheritConversation: true }, "/t").join("\n");
116
+ assert.match(isolated, /you have NO prior conversation/);
117
+ assert.ok(!isolated.includes("<inherited_conversation> block"));
118
+ assert.match(inherited, /<inherited_conversation> block/);
119
+ assert.match(inherited, /may be compacted or truncated/);
120
+ assert.ok(!inherited.includes("you have NO prior conversation"));
121
+ });
122
+
95
123
  test("thinking and role system prompt are wrapped in their blocks", () => {
96
- const args = buildChildArgs(
97
- "m",
98
- { thinking: "high", systemPrompt: " Be brief. " },
99
- "/t",
100
- );
124
+ const args = buildChildArgs("m", { thinking: "high", systemPrompt: " Be brief. " }, "/t");
101
125
  assert.equal(args[args.indexOf("--thinking") + 1], "high");
102
126
  const roleIdx = args.findIndex((a) => a.startsWith("<subagent_role>"));
103
127
  assert.ok(roleIdx > 0);