@fitchmultz/pi-subagents 0.52.10 → 0.52.11

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.
@@ -7,12 +7,64 @@ import { setTimeout as delay } from "node:timers/promises";
7
7
  import { isChildTreeAlive } from "../src/shared/post-exit-stdio-guard.ts";
8
8
  import { filesIn, processRef } from "./real-pi-processes.mjs";
9
9
 
10
+ /**
11
+ * One JSON record decoded from the RPC main's stdout stream.
12
+ * @typedef {Record<string, unknown> & { type?: string }} RpcEvent
13
+ */
14
+ /**
15
+ * A message record projected from a message_end event.
16
+ * @typedef {object} StreamedMessage
17
+ * @property {string | undefined} customType
18
+ * @property {string | undefined} role
19
+ * @property {{ type?: string, text?: string }[] | undefined} content
20
+ * @property {string | undefined} stopReason
21
+ * @property {SessionNotice | undefined} details
22
+ */
23
+ /**
24
+ * A completion notification saved from a custom agent-completion message.
25
+ * @typedef {object} SessionNotice
26
+ * @property {string | undefined} id
27
+ * @property {number | undefined} index
28
+ * @property {{ status?: string } | undefined} result
29
+ * @property {{ text?: string } | undefined} page
30
+ */
31
+ /**
32
+ * A launch/continuation receipt projected from a tool_execution_end event.
33
+ * @typedef {object} SessionReceipt
34
+ * @property {string | undefined} tool
35
+ * @property {string | undefined} receipt
36
+ * @property {string | undefined} name
37
+ * @property {string | undefined} id
38
+ * @property {string | undefined} sessionFile
39
+ * @property {string | undefined} context
40
+ * @property {number | undefined} index
41
+ * @property {string | undefined} predecessor
42
+ */
43
+ /**
44
+ * The verified view of one live RPC session.
45
+ * @typedef {object} SessionView
46
+ * @property {RpcEvent[]} events
47
+ * @property {SessionReceipt[]} receipts
48
+ * @property {Map<string | undefined, SessionNotice>} notices
49
+ * @property {{ index: number, text: string, stopReason: string | undefined }[]} replies
50
+ */
51
+ /**
52
+ * The shared smoke runner options (built by scripts/real-pi-smoke.mjs).
53
+ * @typedef {object} LiveSmokeOptions
54
+ * @property {string} root
55
+ * @property {boolean | undefined} [llmFull]
56
+ * @property {{ PI_CODING_AGENT_DIR: string }} env
57
+ * @property {number} timeoutMs
58
+ * @property {AbortSignal} signal
59
+ * @property {{ run: (label: string, command: string, args: string[], input: string | ((child: import("node:child_process").ChildProcess) => void)) => Promise<string> }} processes
60
+ */
61
+
10
62
  const authAgentDir =
11
63
  process.env.PI_REAL_SMOKE_AUTH_AGENT_DIR ??
12
- (process.env.HOME ? join(process.env.HOME, ".pi", "agent") : undefined);
64
+ (process.env.HOME !== undefined ? join(process.env.HOME, ".pi", "agent") : undefined);
13
65
 
14
- function copyLiveAuth(agentDir) {
15
- if (!authAgentDir || !existsSync(authAgentDir)) {
66
+ function copyLiveAuth(/** @type {string} */ agentDir) {
67
+ if (authAgentDir === undefined || !existsSync(authAgentDir)) {
16
68
  return [];
17
69
  }
18
70
  mkdirSync(agentDir, { recursive: true });
@@ -30,10 +82,10 @@ function copyLiveAuth(agentDir) {
30
82
 
31
83
  function modelArgs() {
32
84
  const args = [];
33
- if (process.env.PI_REAL_SMOKE_PROVIDER) {
85
+ if (process.env.PI_REAL_SMOKE_PROVIDER !== undefined) {
34
86
  args.push("--provider", process.env.PI_REAL_SMOKE_PROVIDER);
35
87
  }
36
- if (process.env.PI_REAL_SMOKE_MODEL) {
88
+ if (process.env.PI_REAL_SMOKE_MODEL !== undefined) {
37
89
  args.push(
38
90
  "--model",
39
91
  process.env.PI_REAL_SMOKE_MODEL,
@@ -44,11 +96,15 @@ function modelArgs() {
44
96
  return args;
45
97
  }
46
98
 
47
- function addMessage(view, message, index) {
48
- if (message?.customType === "agent-completion") {
49
- view.notices.set(message.details?.id, { ...message.details, index });
99
+ function addMessage(
100
+ /** @type {SessionView} */ view,
101
+ /** @type {StreamedMessage | undefined} */ message,
102
+ /** @type {number} */ index,
103
+ ) {
104
+ if (message?.customType === "agent-completion" && message.details !== undefined) {
105
+ view.notices.set(message.details.id, { ...message.details, index });
50
106
  } else if (message?.role === "assistant") {
51
- const text = message.content.filter((part) => part.type === "text");
107
+ const text = (message.content ?? []).filter((part) => part.type === "text");
52
108
  view.replies.push({
53
109
  index,
54
110
  text: text.map((part) => part.text).join("\n"),
@@ -57,17 +113,25 @@ function addMessage(view, message, index) {
57
113
  }
58
114
  }
59
115
 
60
- function sessionView(events) {
116
+ function sessionView(/** @type {RpcEvent[]} */ events) {
117
+ /** @type {SessionView} */
61
118
  const view = { events, receipts: [], notices: new Map(), replies: [] };
62
119
  for (const [index, event] of events.entries()) {
63
- const details = event.result?.details;
120
+ // oxlint-disable typescript/no-unsafe-type-assertion, typescript/no-unnecessary-type-assertion -- RpcEvent carries unknown payloads
121
+ const details = /** @type {SessionReceipt | undefined} */ (
122
+ /** @type {{ details?: SessionReceipt } | undefined} */ (event.result)?.details
123
+ );
124
+ // oxlint-enable typescript/no-unsafe-type-assertion, typescript/no-unnecessary-type-assertion
64
125
  if (event.type === "message_end") {
65
- addMessage(view, event.message, index);
126
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- RpcEvent carries unknown payloads
127
+ addMessage(view, /** @type {StreamedMessage} */ (event.message), index);
66
128
  } else if (
67
129
  event.type === "tool_execution_end" &&
68
- ["launch", "continuation"].includes(details?.receipt)
130
+ details !== undefined &&
131
+ (details.receipt === "launch" || details.receipt === "continuation")
69
132
  ) {
70
- view.receipts.push({ ...details, index, tool: event.toolName });
133
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- RpcEvent carries unknown payloads
134
+ view.receipts.push({ ...details, index, tool: /** @type {string} */ (event.toolName) });
71
135
  }
72
136
  }
73
137
  return view;
@@ -75,30 +139,38 @@ function sessionView(events) {
75
139
 
76
140
  // Persistent RPC main: launch/continuation receipts are not completion, so the main stays
77
141
  // alive until every receipt's native completion notification has resumed and settled it.
78
- function rpcSession(child, prompt) {
79
- const events = [];
142
+ function rpcSession(
143
+ /** @type {import("node:child_process").ChildProcess} */ child,
144
+ /** @type {string} */ prompt,
145
+ ) {
146
+ /** @type {RpcEvent[]} */ const events = [];
80
147
  let buffer = "";
81
- let settle;
82
- const quiescent = new Promise((resolve, reject) => {
83
- settle = { resolve, reject };
84
- });
85
- child.stdout.on("data", (chunk) => {
86
- buffer += chunk;
148
+ /** @type {{ promise: Promise<SessionView>, resolve: (view: SessionView) => void, reject: (error: Error) => void }} */
149
+ const settle = Promise.withResolvers();
150
+ const quiescent = settle.promise;
151
+ const stdout = child.stdout;
152
+ const stdin = child.stdin;
153
+ if (stdout === null || stdin === null) {
154
+ throw new Error("RPC child is missing piped stdio streams");
155
+ }
156
+ stdout.on("data", (/** @type {Buffer | string} */ chunk) => {
157
+ buffer += chunk.toString();
87
158
  for (let end = buffer.indexOf("\n"); end >= 0; end = buffer.indexOf("\n")) {
88
159
  const line = buffer.slice(0, end).replace(/\r$/, "");
89
160
  buffer = buffer.slice(end + 1);
90
- let record;
161
+ /** @type {RpcEvent} */ let record;
91
162
  try {
163
+ // oxlint-disable-next-line typescript/no-unsafe-assignment
92
164
  record = JSON.parse(line);
93
165
  } catch {
94
166
  continue;
95
167
  }
96
168
  events.push(record);
97
169
  if (record.type === "response" && record.id === "smoke" && record.success !== true) {
98
- settle.reject(new Error(`RPC prompt was rejected: ${record.error}`));
170
+ settle.reject(new Error(`RPC prompt was rejected: ${JSON.stringify(record.error)}`));
99
171
  }
100
172
  if (record.type === "agent_settled") {
101
- if (record.aborted) {
173
+ if (record.aborted === true) {
102
174
  settle.reject(new Error("RPC main was aborted before the live smoke completed."));
103
175
  continue;
104
176
  }
@@ -109,17 +181,29 @@ function rpcSession(child, prompt) {
109
181
  }
110
182
  }
111
183
  });
112
- child.stdin.write(`${JSON.stringify({ id: "smoke", type: "prompt", message: prompt })}\n`);
113
- return { events, quiescent, close: () => child.stdin.end() };
184
+ stdin.write(`${JSON.stringify({ id: "smoke", type: "prompt", message: prompt })}\n`);
185
+ return { events, quiescent, close: () => stdin.end() };
114
186
  }
115
187
 
116
- async function runLiveSession(options, { label, prompt, tool, verify }) {
117
- let session;
118
- let view;
188
+ async function runLiveSession(
189
+ /** @type {LiveSmokeOptions} */ options,
190
+ /** @type {{ label: string, prompt: string, tool: string, verify: (view: SessionView) => void | Promise<void> }} */
191
+ { label, prompt, tool, verify },
192
+ ) {
193
+ /** @type {ReturnType<typeof rpcSession> | undefined} */ let session;
194
+ /** @type {SessionView | undefined} */ let view;
119
195
  const args = ["--mode", "rpc", "--session-dir", join(options.root, "sessions"), "--approve"];
120
- const exited = options.processes.run(label, "pi", [...args, ...modelArgs()], (child) => {
121
- session = rpcSession(child, prompt);
122
- });
196
+ const exited = options.processes.run(
197
+ label,
198
+ "pi",
199
+ [...args, ...modelArgs()],
200
+ (/** @type {import("node:child_process").ChildProcess} */ child) => {
201
+ session = rpcSession(child, prompt);
202
+ },
203
+ );
204
+ if (session === undefined) {
205
+ throw new Error(`${label} RPC session never started`);
206
+ }
123
207
  try {
124
208
  view = await Promise.race([
125
209
  session.quiescent,
@@ -135,9 +219,9 @@ async function runLiveSession(options, { label, prompt, tool, verify }) {
135
219
  );
136
220
  }
137
221
  const errors = view.events.filter(
138
- (event) => event.type === "tool_execution_end" && event.isError,
222
+ (event) => event.type === "tool_execution_end" && event.isError === true,
139
223
  );
140
- if (errors.length) {
224
+ if (errors.length > 0) {
141
225
  throw new Error(
142
226
  `${label} had ${errors.length} failed tool call(s); inspect its saved wire log.`,
143
227
  );
@@ -145,29 +229,40 @@ async function runLiveSession(options, { label, prompt, tool, verify }) {
145
229
  // Verify while the main is alive; only then request its orderly shutdown.
146
230
  await verify(view);
147
231
  } finally {
232
+ // oxlint-disable-next-line typescript/no-unnecessary-condition -- the finally also runs when startup failed
148
233
  session?.close();
149
234
  writeFileSync(
150
235
  join(options.root, `${label.replace(/ /g, "-")}.jsonl`),
236
+ // oxlint-disable-next-line typescript/no-unnecessary-condition, typescript/strict-boolean-expressions -- the finally also runs when startup failed
151
237
  session ? session.events.map((event) => JSON.stringify(event)).join("\n") : "",
152
238
  );
153
239
  }
154
240
  await exited;
241
+ // oxlint-disable-next-line typescript/no-unnecessary-condition -- flow analysis misses the race failure path
242
+ if (view === undefined) {
243
+ throw new Error(`${label} settled without a session view`);
244
+ }
155
245
  return view;
156
246
  }
157
247
 
158
- function* savedAssistantModels(file) {
248
+ function* savedAssistantModels(/** @type {string} */ file) {
159
249
  for (const line of readFileSync(file, "utf8").split("\n")) {
160
- if (!line.trim()) {
250
+ if (line.trim() === "") {
161
251
  continue;
162
252
  }
253
+ /** @type {{ type?: string, message?: { role?: string, provider?: string, model?: string } }} */
254
+ // oxlint-disable-next-line typescript/no-unsafe-assignment
163
255
  const entry = JSON.parse(line);
164
256
  if (entry.type === "message" && entry.message?.role === "assistant") {
165
- yield `${entry.message.provider}/${entry.message.model}`;
257
+ yield `${entry.message.provider ?? ""}/${entry.message.model ?? ""}`;
166
258
  }
167
259
  }
168
260
  }
169
261
 
170
- function auditSavedModels(directories, expectedModel) {
262
+ function auditSavedModels(
263
+ /** @type {string[]} */ directories,
264
+ /** @type {string | undefined} */ expectedModel,
265
+ ) {
171
266
  const models = new Set();
172
267
  let responses = 0;
173
268
  for (const file of directories.flatMap((dir) => [...filesIn(dir)])) {
@@ -179,10 +274,13 @@ function auditSavedModels(directories, expectedModel) {
179
274
  models.add(model);
180
275
  }
181
276
  }
182
- if (!responses) {
277
+ if (responses === 0) {
183
278
  throw new Error("No saved assistant messages found for model audit.");
184
279
  }
185
- if (expectedModel?.includes("/") && [...models].some((model) => model !== expectedModel)) {
280
+ if (
281
+ expectedModel?.includes("/") === true &&
282
+ [...models].some((model) => model !== expectedModel)
283
+ ) {
186
284
  throw new Error(`Unexpected model in saved parent/child sessions: ${[...models].join(", ")}`);
187
285
  }
188
286
  console.log(
@@ -190,19 +288,27 @@ function auditSavedModels(directories, expectedModel) {
190
288
  );
191
289
  }
192
290
 
193
- function requireOutput(label, output, pattern) {
291
+ function requireOutput(
292
+ /** @type {string} */ label,
293
+ /** @type {string} */ output,
294
+ /** @type {RegExp} */ pattern,
295
+ ) {
194
296
  if (!pattern.test(output)) {
195
- throw new Error(`${label} did not include expected evidence ${pattern}.\nOutput:\n${output}`);
297
+ throw new Error(
298
+ `${label} did not include expected evidence ${pattern.toString()}.\nOutput:\n${output}`,
299
+ );
196
300
  }
197
301
  const compact = output.trim().split(/\r?\n/).slice(-8).join("\n");
198
302
  console.log(`[real-pi-smoke] ${label} output evidence:\n${compact}`);
199
303
  }
200
304
 
201
- function asyncCompletion(dir, runId) {
305
+ function asyncCompletion(/** @type {string} */ dir, /** @type {string} */ runId) {
202
306
  const resultPath = join(dir, "result.json");
203
307
  if (!existsSync(resultPath)) {
204
308
  return { status: "missing" };
205
309
  }
310
+ /** @type {{ id: string, status: string, error?: string }} */
311
+ // oxlint-disable-next-line typescript/no-unsafe-assignment
206
312
  const result = JSON.parse(readFileSync(resultPath, "utf8"));
207
313
  if (result.id !== runId) {
208
314
  throw new Error("Async completion belongs to another saved run");
@@ -214,6 +320,8 @@ function asyncCompletion(dir, runId) {
214
320
  if (!existsSync(processPath)) {
215
321
  return { status: "completed; actor exit unconfirmed" };
216
322
  }
323
+ /** @type {{ pid: number }} */
324
+ // oxlint-disable-next-line typescript/no-unsafe-assignment
217
325
  const actor = JSON.parse(readFileSync(processPath, "utf8"));
218
326
  return {
219
327
  status: result.status,
@@ -223,13 +331,21 @@ function asyncCompletion(dir, runId) {
223
331
  };
224
332
  }
225
333
 
226
- async function waitForAsyncCompletion(run, pattern, { env, timeoutMs, signal }) {
334
+ async function waitForAsyncCompletion(
335
+ /** @type {SessionReceipt} */ run,
336
+ /** @type {RegExp} */ pattern,
337
+ /** @type {LiveSmokeOptions} */ { env, timeoutMs, signal },
338
+ ) {
339
+ if (run.sessionFile === undefined || run.id === undefined) {
340
+ throw new Error("Async receipt did not identify this smoke's saved run");
341
+ }
227
342
  const dir = dirname(run.sessionFile);
228
343
  const within = relative(join(env.PI_CODING_AGENT_DIR, "sessions", "agent-runs"), dir);
229
344
  if (isAbsolute(within) || within.startsWith("..") || !/^[a-zA-Z0-9_-]{1,128}$/.test(run.id)) {
230
345
  throw new Error("Async receipt did not identify this smoke's private saved run");
231
346
  }
232
347
  const deadline = Date.now() + timeoutMs;
348
+ /** @type {string} */
233
349
  let lastState = "missing";
234
350
  while (Date.now() < deadline) {
235
351
  signal.throwIfAborted();
@@ -248,18 +364,23 @@ async function waitForAsyncCompletion(run, pattern, { env, timeoutMs, signal })
248
364
  );
249
365
  }
250
366
 
251
- export async function runLiveSmoke(options, runOptions, agentDir) {
367
+ export async function runLiveSmoke(
368
+ /** @type {{ llmFull?: boolean }} */ options,
369
+ /** @type {LiveSmokeOptions} */ runOptions,
370
+ /** @type {string} */ agentDir,
371
+ ) {
252
372
  const copiedAuthFiles = copyLiveAuth(agentDir);
253
373
  if (copiedAuthFiles.length > 0) {
254
374
  console.log(
255
375
  `[real-pi-smoke] copied ${copiedAuthFiles.join(" and ")} into isolated Pi agent dir for live provider auth`,
256
376
  );
257
377
  }
258
- const childModelInstruction = process.env.PI_REAL_SMOKE_MODEL
259
- ? ` Pass model '${process.env.PI_REAL_SMOKE_MODEL}' to every delegate call.`
260
- : "";
378
+ const childModelInstruction =
379
+ process.env.PI_REAL_SMOKE_MODEL !== undefined
380
+ ? ` Pass model '${process.env.PI_REAL_SMOKE_MODEL}' to every delegate call.`
381
+ : "";
261
382
  await runBasicLiveSmoke(runOptions, childModelInstruction);
262
- if (options.llmFull) {
383
+ if (options.llmFull === true) {
263
384
  await runFullLiveSmoke(runOptions, childModelInstruction);
264
385
  }
265
386
  auditSavedModels(
@@ -268,9 +389,13 @@ export async function runLiveSmoke(options, runOptions, agentDir) {
268
389
  );
269
390
  }
270
391
 
271
- function requireFinalReply(label, view, pattern) {
392
+ function requireFinalReply(
393
+ /** @type {string} */ label,
394
+ /** @type {SessionView} */ view,
395
+ /** @type {RegExp} */ pattern,
396
+ ) {
272
397
  const last = view.replies.at(-1);
273
- const notified = Math.max(-1, ...[...view.notices.values()].map(({ index }) => index));
398
+ const notified = Math.max(-1, ...[...view.notices.values()].map(({ index }) => index ?? -1));
274
399
  if (last === undefined || last.stopReason !== "stop" || last.index < notified) {
275
400
  throw new Error(
276
401
  `${label} did not finish a successful reply after its final completion notification.`,
@@ -279,14 +404,22 @@ function requireFinalReply(label, view, pattern) {
279
404
  requireOutput(label, last.text, pattern);
280
405
  }
281
406
 
282
- function requireCompletion(view, receipt, expected) {
407
+ function requireCompletion(
408
+ /** @type {SessionView} */ view,
409
+ /** @type {SessionReceipt} */ receipt,
410
+ /** @type {string} */ expected,
411
+ ) {
283
412
  const notice = view.notices.get(receipt.id);
284
413
  if (notice?.result?.status !== "completed" || notice.page?.text?.trim() !== expected) {
285
- throw new Error(`Run ${receipt.id} completion notification did not report: ${expected}`);
414
+ throw new Error(`Run ${receipt.id ?? ""} completion notification did not report: ${expected}`);
286
415
  }
287
416
  }
288
417
 
289
- async function runBasicLiveSmoke(runOptions, childModelInstruction) {
418
+ async function runBasicLiveSmoke(
419
+ /** @type {LiveSmokeOptions} */ runOptions,
420
+ /** @type {string} */ childModelInstruction,
421
+ ) {
422
+ /** @type {[string, string, string][]} */
290
423
  const prompts = [
291
424
  [
292
425
  "intercom",
@@ -320,10 +453,11 @@ async function runBasicLiveSmoke(runOptions, childModelInstruction) {
320
453
  const [receipt] = view.receipts;
321
454
  if (
322
455
  view.receipts.length !== 1 ||
456
+ !receipt ||
323
457
  receipt.receipt !== "launch" ||
324
458
  receipt.name !== "smoke-delegate" ||
325
- !receipt.id ||
326
- !receipt.sessionFile
459
+ receipt.id === undefined ||
460
+ receipt.sessionFile === undefined
327
461
  ) {
328
462
  throw new Error("Native delegate did not return one named saved launch receipt");
329
463
  }
@@ -332,25 +466,78 @@ async function runBasicLiveSmoke(runOptions, childModelInstruction) {
332
466
  },
333
467
  });
334
468
  // A completed actor keeps serving its attached main; closing that main must retire it.
335
- await waitForAsyncCompletion(receipts[0], new RegExp(`^${expected}$`, "m"), runOptions);
469
+ const [asyncReceipt] = receipts;
470
+ if (asyncReceipt === undefined) {
471
+ throw new Error("Native delegate did not return a saved launch receipt");
472
+ }
473
+ await waitForAsyncCompletion(asyncReceipt, new RegExp(`^${expected}$`, "m"), runOptions);
336
474
  }
337
475
 
338
- function requireForkTopology(view) {
339
- const kinds = view.receipts.map(({ tool, receipt, context }) => `${tool}:${receipt}:${context}`);
476
+ /**
477
+ * @param {import("./real-pi-live.mjs").SessionReceipt | undefined} forked
478
+ * @param {import("./real-pi-live.mjs").SessionNotice | undefined} firstNotice
479
+ */
480
+ function forkedAfterFirstNotice(forked, firstNotice) {
481
+ return (
482
+ forked?.index !== undefined &&
483
+ firstNotice?.index !== undefined &&
484
+ forked.index > firstNotice.index
485
+ );
486
+ }
487
+
488
+ /**
489
+ * @param {SessionView} view
490
+ * @param {string[]} kinds
491
+ * @param {string[]} expected
492
+ */
493
+ function topologyReceiptsComplete(view, kinds, expected) {
494
+ const [first] = view.receipts;
495
+ const forked = view.receipts.find(({ context }) => context === "fork");
496
+ const continued = view.receipts.find(({ tool }) => tool === "agent_runs");
497
+ const firstNotice = first?.id === undefined ? undefined : view.notices.get(first.id);
498
+ return {
499
+ first,
500
+ forked,
501
+ continued,
502
+ complete:
503
+ kinds.sort().join() === expected.join() &&
504
+ first?.context === "fresh" &&
505
+ firstNotice !== undefined &&
506
+ forked !== undefined &&
507
+ continued !== undefined,
508
+ };
509
+ }
510
+
511
+ /**
512
+ * @param {import("./real-pi-live.mjs").SessionReceipt} first
513
+ * @param {import("./real-pi-live.mjs").SessionReceipt} continued
514
+ * @param {import("./real-pi-live.mjs").SessionReceipt | undefined} forked
515
+ * @param {import("./real-pi-live.mjs").SessionNotice | undefined} firstNotice
516
+ */
517
+ function topologyLinksPreserved(first, continued, forked, firstNotice) {
518
+ return (
519
+ forkedAfterFirstNotice(forked, firstNotice) &&
520
+ continued.predecessor === first.id &&
521
+ continued.sessionFile === first.sessionFile
522
+ );
523
+ }
524
+
525
+ function requireForkTopology(/** @type {SessionView} */ view) {
526
+ const kinds = view.receipts.map(
527
+ ({ tool, receipt, context }) => `${tool ?? ""}:${receipt ?? ""}:${context ?? ""}`,
528
+ );
340
529
  const expected = [
341
530
  "agent_runs:continuation:fresh",
342
531
  "delegate:launch:fork",
343
532
  "delegate:launch:fresh",
344
533
  ];
345
- const [first] = view.receipts;
346
- const forked = view.receipts.find(({ context }) => context === "fork");
347
- const continued = view.receipts.find(({ tool }) => tool === "agent_runs");
534
+ const { first, forked, continued, complete } = topologyReceiptsComplete(view, kinds, expected);
535
+ const firstNotice = first?.id === undefined ? undefined : view.notices.get(first.id);
348
536
  if (
349
- kinds.sort().join() !== expected.join() ||
350
- first?.context !== "fresh" ||
351
- !(forked.index > view.notices.get(first.id).index) ||
352
- continued.predecessor !== first.id ||
353
- continued.sessionFile !== first.sessionFile
537
+ !complete ||
538
+ first === undefined ||
539
+ continued === undefined ||
540
+ !topologyLinksPreserved(first, continued, forked, firstNotice)
354
541
  ) {
355
542
  throw new Error(
356
543
  "Fork/continuation smoke did not fork after the first completion and continue its saved conversation",
@@ -358,7 +545,10 @@ function requireForkTopology(view) {
358
545
  }
359
546
  }
360
547
 
361
- async function runFullLiveSmoke(runOptions, childModelInstruction) {
548
+ async function runFullLiveSmoke(
549
+ /** @type {LiveSmokeOptions} */ runOptions,
550
+ /** @type {string} */ childModelInstruction,
551
+ ) {
362
552
  const fact = "CALIBRATION_FACT=packed-otter-7319";
363
553
  const prompt = `First call delegate with prompt 'Reply exactly ${fact}', name 'fact', and context fresh.${childModelInstruction} Delegate and agent_runs return receipts only; never wait or poll, and end your turn after each receipt. When the fact run's completion notification arrives, call delegate with context fork and prompt 'Read the inherited conversation. Without tools, filesystem reads, or guessing, return the CALIBRATION_FACT line from the preceding agent completion notification exactly; return FACT_NOT_VISIBLE if absent.' and call agent_runs with action continue, the fact run id, and prompt 'Without tools, repeat the CALIBRATION_FACT line from your own saved conversation exactly.' After both of those runs' completion notifications arrive, reply exactly 'real-pi-smoke fork ok' only if both report completed with ${fact}. There are no profiles, task prefixes, output settings, or canned role instructions.`;
364
554
  const label = "real Pi fork prompt";