@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.
- package/CHANGELOG.md +13 -0
- package/dist/build-info.js +1 -1
- package/package.json +1 -1
- package/scripts/archive-retired-history.mjs +19 -12
- package/scripts/build.mjs +54 -27
- package/scripts/check-compat.mjs +18 -9
- package/scripts/compat-host.mjs +22 -10
- package/scripts/intercom-bridge-credentials.mjs +69 -25
- package/scripts/intercom-bridge-wake-preflight.mjs +9 -8
- package/scripts/intercom-remote.mjs +68 -31
- package/scripts/local-install-smoke.mjs +10 -5
- package/scripts/measure-size.mjs +5 -1
- package/scripts/native-package-smoke-assertions.mjs +84 -0
- package/scripts/native-package-smoke.mjs +122 -120
- package/scripts/package-smoke.mjs +44 -14
- package/scripts/prepare.mjs +20 -12
- package/scripts/real-pi-live.mjs +259 -69
- package/scripts/real-pi-processes.mjs +117 -54
- package/scripts/real-pi-smoke.mjs +56 -22
- package/scripts/run-tests.mjs +45 -17
- package/tsconfig.json +1 -1
package/scripts/real-pi-live.mjs
CHANGED
|
@@ -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 (
|
|
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(
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
130
|
+
details !== undefined &&
|
|
131
|
+
(details.receipt === "launch" || details.receipt === "continuation")
|
|
69
132
|
) {
|
|
70
|
-
|
|
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(
|
|
79
|
-
|
|
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
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
113
|
-
return { events, quiescent, close: () =>
|
|
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(
|
|
117
|
-
|
|
118
|
-
|
|
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(
|
|
121
|
-
|
|
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 (
|
|
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(
|
|
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 (
|
|
277
|
+
if (responses === 0) {
|
|
183
278
|
throw new Error("No saved assistant messages found for model audit.");
|
|
184
279
|
}
|
|
185
|
-
if (
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
259
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
326
|
-
|
|
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
|
-
|
|
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
|
-
|
|
339
|
-
|
|
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
|
|
346
|
-
const
|
|
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
|
-
|
|
350
|
-
first
|
|
351
|
-
|
|
352
|
-
continued
|
|
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(
|
|
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";
|