@deepseek-ai/dsh-session-snapshot 0.1.2-alpha.2

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/lib/index.js ADDED
@@ -0,0 +1,2534 @@
1
+ import { createRequire } from "node:module";
2
+ import { cp, mkdtemp, readFile, readdir, readlink, rm, writeFile } from "node:fs/promises";
3
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, symlinkSync, writeFileSync } from "node:fs";
4
+ import { createHash } from "node:crypto";
5
+ import { tmpdir } from "node:os";
6
+ import { basename, delimiter, dirname, isAbsolute, join, resolve } from "node:path";
7
+ import { describe, expect, it, vi } from "vitest";
8
+ import { PROTOCOL_VERSION, client, methods, ndJsonStream } from "@agentclientprotocol/sdk";
9
+ import { spawn } from "node:child_process";
10
+ import { Readable, Writable } from "node:stream";
11
+ import { pathToFileURL } from "node:url";
12
+ import * as yaml from "js-yaml";
13
+ import { entryListSchema } from "@deepseek-ai/cordis-plugin-include";
14
+ import { resolveExampleLaunch } from "@deepseek-ai/dsh-loader-smoke";
15
+ import { decodeSeqRanges, decodeStorageRecord, packChunkRuns } from "@deepseek-ai/dsh-session";
16
+ import { isSurfaceEligibleType } from "@deepseek-ai/dsh-session/surface";
17
+ //#region lib/types/identity.js
18
+ /** Relationship-preserving identity redaction for committed session snapshots. */
19
+ const UUID_FRAGMENT_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
20
+ const LEGACY_TOKEN_RE = /^\{\{(?:sessionId|messageId)\}\}$/;
21
+ const CANONICAL_TOKEN_RE = /^\{\{(session|message|approval|workflow|command|rpc|retry|id):([1-9]\d*)\}\}$/;
22
+ const ID_KEY_RE = /(?:^id$|Id$|Ids$)/;
23
+ function isRecord$1(value) {
24
+ return value !== null && typeof value === "object" && !Array.isArray(value);
25
+ }
26
+ function parseLog(log) {
27
+ return {
28
+ records: log.split(/\r?\n/).filter((line) => line.trim() !== "").map((line) => JSON.parse(line)),
29
+ trailingNewline: log.endsWith("\n")
30
+ };
31
+ }
32
+ function messageId(value) {
33
+ if (!isRecord$1(value) || typeof value.id !== "string" || typeof value.role !== "string" || !Array.isArray(value.content) || !isRecord$1(value.source)) return void 0;
34
+ return value.id;
35
+ }
36
+ function redactedCandidate(value) {
37
+ return UUID_FRAGMENT_RE.test(value) || LEGACY_TOKEN_RE.test(value) || CANONICAL_TOKEN_RE.test(value);
38
+ }
39
+ /**
40
+ * Replace volatile opaque ids while preserving equality relationships across a parent and its child logs.
41
+ * @param logs - one scenario's primary-first session JSONL fixtures.
42
+ * @returns compact JSONL with typed first-seen identity tokens.
43
+ */
44
+ function redactSessionSnapshotIds(logs) {
45
+ const parsed = logs.map(parseLog);
46
+ const tokenByValue = /* @__PURE__ */ new Map();
47
+ const nextByKind = /* @__PURE__ */ new Map();
48
+ const claim = (value, kind, always = false) => {
49
+ if (typeof value !== "string" || value.length === 0 || tokenByValue.has(value)) return;
50
+ if (!always && !redactedCandidate(value)) return;
51
+ const canonical = CANONICAL_TOKEN_RE.exec(value);
52
+ if (canonical !== null) {
53
+ const canonicalKind = canonical[1];
54
+ const ordinal = Number(canonical[2]);
55
+ nextByKind.set(canonicalKind, Math.max(nextByKind.get(canonicalKind) ?? 0, ordinal));
56
+ tokenByValue.set(value, value);
57
+ return;
58
+ }
59
+ const next = (nextByKind.get(kind) ?? 0) + 1;
60
+ nextByKind.set(kind, next);
61
+ tokenByValue.set(value, `{{${kind}:${next}}}`);
62
+ };
63
+ for (const log of parsed) {
64
+ const header = log.records[0];
65
+ if (header?.type === "session") claim(header.id, "session", true);
66
+ }
67
+ const collect = (value, recordType) => {
68
+ if (typeof value === "string") {
69
+ for (const match of value.matchAll(/\bas message ([0-9a-f-]{36})\b/gi)) claim(match[1], "message");
70
+ for (const match of value.matchAll(/\bAnonymous user: ([0-9a-f-]{36})\b/gi)) claim(match[1], "id");
71
+ return;
72
+ }
73
+ if (Array.isArray(value)) {
74
+ for (const item of value) collect(item, recordType);
75
+ return;
76
+ }
77
+ if (!isRecord$1(value)) return;
78
+ const identifiedMessage = messageId(value);
79
+ if (identifiedMessage !== void 0) claim(identifiedMessage, "message");
80
+ for (const [childKey, item] of Object.entries(value)) {
81
+ if (recordType === "approval/asked" || recordType === "approval/decided") {
82
+ if (childKey === "id") claim(item, "approval");
83
+ } else if (childKey === "commandId") claim(item, "command", true);
84
+ else if (childKey === "rpcId") claim(item, "rpc", true);
85
+ else if (childKey === "retryId") claim(item, "retry");
86
+ else if (childKey === "runId") claim(item, "workflow");
87
+ else if (ID_KEY_RE.test(childKey)) claim(item, "id");
88
+ collect(item, recordType);
89
+ }
90
+ };
91
+ for (const log of parsed) for (const record of log.records) collect(record, record.type);
92
+ const replacements = [...tokenByValue].sort(([left], [right]) => right.length - left.length);
93
+ const replace = (value) => {
94
+ if (typeof value === "string") {
95
+ const exact = tokenByValue.get(value);
96
+ if (exact !== void 0) return exact;
97
+ let output = value;
98
+ for (const [source, token] of replacements) output = output.split(source).join(token);
99
+ return output;
100
+ }
101
+ if (Array.isArray(value)) return value.map(replace);
102
+ if (isRecord$1(value)) return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, replace(item)]));
103
+ return value;
104
+ };
105
+ return parsed.map((log) => {
106
+ const content = log.records.map((record) => JSON.stringify(replace(record))).join("\n");
107
+ return log.trailingNewline ? `${content}\n` : content;
108
+ });
109
+ }
110
+ //#endregion
111
+ //#region lib/types/launcher.js
112
+ /**
113
+ * Shared launcher for ACP tests that drive an agent subprocess over JSON-RPC
114
+ * stdio. It owns source-or-built launch resolution, workspace environment,
115
+ * stdout tee, SDK client, update collection, permission fallback, and process
116
+ * shutdown so e2e and snapshot suites do not each reconstruct that boundary.
117
+ *
118
+ * @module @deepseek-ai/dsh-session-snapshot/launcher
119
+ */
120
+ const EXIT_MARKER_GRACE_MS = 250;
121
+ /**
122
+ * Boot an ACP agent subprocess and connect an SDK client to its stdio.
123
+ *
124
+ * @param options Agent paths, cwd, environment, and optional permission handler.
125
+ * @returns The running process, connected client, captures, and shutdown handle.
126
+ */
127
+ function launchAcpTestAgent(options) {
128
+ const { agent, cwd } = options;
129
+ const selectedConfig = options.configPath ?? agent.configPath;
130
+ const launch = resolveExampleLaunch({
131
+ srcBin: agent.binScript,
132
+ libBin: agent.libBinScript,
133
+ configArgs: agent.profile === void 0 ? ["--config", selectedConfig] : profileArgs(agent.profile, agent.configPath, selectedConfig, options.env?.DSH_SNAPSHOT, cwd),
134
+ tsconfigPath: agent.tsconfigPath,
135
+ ...agent.profile === void 0 ? {} : { sourceImport: "tsx/esm" },
136
+ env: {
137
+ ...options.env,
138
+ DSH_HOME: join(cwd, ".dsh"),
139
+ DSH_AGENTS_HOME: join(cwd, ".agents")
140
+ }
141
+ });
142
+ const child = spawn(launch.command, launch.args, {
143
+ cwd,
144
+ env: {
145
+ ...process.env,
146
+ ...launch.env
147
+ },
148
+ stdio: [
149
+ "pipe",
150
+ "pipe",
151
+ "pipe"
152
+ ]
153
+ });
154
+ const childFailure = new Promise((resolve) => child.on("error", resolve));
155
+ const spawned = Promise.race([new Promise((resolve) => child.once("spawn", resolve)), childFailure.then((error) => {
156
+ throw error;
157
+ })]);
158
+ spawned.catch(() => void 0);
159
+ const stderrChunks = [];
160
+ child.stderr.setEncoding("utf8");
161
+ child.stderr.on("data", (chunk) => stderrChunks.push(chunk));
162
+ const rawBuffers = [];
163
+ const passthrough = new Readable({ read() {} });
164
+ const updates = [];
165
+ const updateWaiters = [];
166
+ let updateStreamFailure;
167
+ const closeUpdateStream = () => {
168
+ if (updateStreamFailure !== void 0) return;
169
+ updateStreamFailure = /* @__PURE__ */ new Error("ACP test agent update stream closed before a matching session update arrived");
170
+ for (const waiter of updateWaiters.splice(0)) waiter.reject(updateStreamFailure);
171
+ };
172
+ child.stdout.on("data", (buffer) => {
173
+ rawBuffers.push(buffer);
174
+ passthrough.push(buffer);
175
+ });
176
+ child.stdout.on("end", () => {
177
+ passthrough.push(null);
178
+ });
179
+ const stream = ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(passthrough));
180
+ const inFlightClientCallbacks = /* @__PURE__ */ new Set();
181
+ const trackClientCallback = (callback) => {
182
+ const pending = Promise.resolve().then(callback);
183
+ inFlightClientCallbacks.add(pending);
184
+ const untrack = () => {
185
+ inFlightClientCallbacks.delete(pending);
186
+ };
187
+ pending.then(untrack, untrack);
188
+ return pending;
189
+ };
190
+ const requestPermission = options.requestPermission ?? (() => Promise.resolve({ outcome: { outcome: "cancelled" } }));
191
+ const connection = client({ name: "deepseek-harness-acp-test-client" }).onNotification(methods.client.session.update, ({ params }) => {
192
+ return trackClientCallback(() => {
193
+ updates.push(params.update);
194
+ for (let index = updateWaiters.length - 1; index >= 0; index--) {
195
+ const waiter = updateWaiters[index];
196
+ /* v8 ignore next 1 -- index is bounded by the array length */
197
+ if (waiter === void 0) continue;
198
+ let matches;
199
+ try {
200
+ matches = waiter.match(params.update);
201
+ } catch (error) {
202
+ updateWaiters.splice(index, 1);
203
+ waiter.reject(error);
204
+ continue;
205
+ }
206
+ if (!matches) continue;
207
+ updateWaiters.splice(index, 1);
208
+ waiter.resolve(params.update);
209
+ }
210
+ });
211
+ }).onRequest(methods.client.session.requestPermission, ({ params }) => trackClientCallback(() => requestPermission(params))).connect(stream);
212
+ const context = connection.agent;
213
+ const client$1 = {
214
+ closed: connection.closed,
215
+ initialize: (params) => context.request(methods.agent.initialize, params),
216
+ newSession: (params) => context.request(methods.agent.session.new, params),
217
+ /* v8 ignore next -- exercised by the real-process ACP control-surface conformance e2e. */
218
+ listSessions: (params) => context.request(methods.agent.session.list, params),
219
+ /* v8 ignore next -- exercised by the real-process ACP control-surface conformance e2e. */
220
+ resumeSession: (params) => context.request(methods.agent.session.resume, params),
221
+ /* v8 ignore next -- exercised by the real-process ACP control-surface conformance e2e. */
222
+ closeSession: (params) => context.request(methods.agent.session.close, params),
223
+ /* v8 ignore next -- exercised by the real-process ACP control-surface conformance e2e. */
224
+ setSessionConfigOption: (params) => context.request(methods.agent.session.setConfigOption, params),
225
+ prompt: (params) => context.request(methods.agent.session.prompt, params),
226
+ cancel: (params) => context.notify(methods.agent.session.cancel, params)
227
+ };
228
+ const stdioClosed = new Promise((resolve) => child.once("close", () => {
229
+ resolve();
230
+ }));
231
+ const drained = Promise.all([stdioClosed, connection.closed]).then(async () => {
232
+ while (inFlightClientCallbacks.size > 0) await Promise.allSettled([...inFlightClientCallbacks]);
233
+ });
234
+ connection.closed.then(closeUpdateStream);
235
+ return {
236
+ child,
237
+ spawned,
238
+ client: client$1,
239
+ updates,
240
+ rawStdout: () => Buffer.concat(rawBuffers).toString("utf8"),
241
+ stderr: () => stderrChunks.join(""),
242
+ waitForUpdate(match) {
243
+ if (updateStreamFailure !== void 0) return Promise.reject(updateStreamFailure);
244
+ return new Promise((resolve, reject) => updateWaiters.push({
245
+ match,
246
+ resolve,
247
+ reject
248
+ }));
249
+ },
250
+ async close(signal) {
251
+ try {
252
+ await spawned;
253
+ } catch (error) {
254
+ await drained;
255
+ closeUpdateStream();
256
+ throw error;
257
+ }
258
+ if (!isRunning(child)) {
259
+ await drained;
260
+ closeUpdateStream();
261
+ return;
262
+ }
263
+ const exited = waitForExit(child);
264
+ if (signal === void 0) child.stdin.end();
265
+ else child.kill(signal);
266
+ const failure = await Promise.race([exited.then(() => void 0), childFailure]);
267
+ if (failure === void 0) {
268
+ await drained;
269
+ closeUpdateStream();
270
+ return;
271
+ }
272
+ const propagateFailureAfterDrain = async () => {
273
+ await drained;
274
+ closeUpdateStream();
275
+ throw failure;
276
+ };
277
+ if (!isRunning(child) || await exitMarkerWithinGrace(exited)) return propagateFailureAfterDrain();
278
+ const fallbackError = Promise.withResolvers();
279
+ const observeFallbackError = (error) => {
280
+ fallbackError.resolve(error);
281
+ };
282
+ child.once("error", observeFallbackError);
283
+ if (!child.kill("SIGKILL")) {
284
+ child.off("error", observeFallbackError);
285
+ if (!isRunning(child) || await exitMarkerWithinGrace(exited)) return propagateFailureAfterDrain();
286
+ closeUpdateStream();
287
+ throw new AggregateError([failure, /* @__PURE__ */ new Error("Fallback SIGKILL was not accepted by the child process")], "ACP test agent failed and fallback termination was refused");
288
+ }
289
+ const fallbackFailure = await Promise.race([exited.then(() => void 0), fallbackError.promise]);
290
+ child.off("error", observeFallbackError);
291
+ if (fallbackFailure !== void 0) {
292
+ closeUpdateStream();
293
+ throw new AggregateError([failure, fallbackFailure], "ACP test agent failed and fallback termination was refused");
294
+ }
295
+ return propagateFailureAfterDrain();
296
+ }
297
+ };
298
+ }
299
+ /** Build one dsh profile invocation from the base and optional scenario patches. */
300
+ function profileArgs(profile, basePatch, selectedPatch, snapshotMode, cwd) {
301
+ const base = resolve(cwd, basePatch);
302
+ const selected = resolve(cwd, selectedPatch);
303
+ const patches = snapshotMode === "replay" ? [base, replayPatchPath(selected)] : [...new Set([base, selected])];
304
+ const materializedRoot = join(cwd, ".dsh-profile-patches");
305
+ mkdirSync(materializedRoot, { recursive: true });
306
+ const materializedDir = mkdtempSync(join(materializedRoot, "launch-"));
307
+ return [
308
+ "--profile",
309
+ profile,
310
+ ...patches.map((file, index) => materializeProfilePatch(file, cwd, materializedDir, index)).flatMap((file) => ["--patch", file])
311
+ ];
312
+ }
313
+ /** Derive the replay-only sibling patch selected by the former app-bin swap. */
314
+ function replayPatchPath(source) {
315
+ const replayName = basename(source).replace(/cordis\.ya?ml$/, "cordis.snapshot.yml");
316
+ return resolve(dirname(source), replayName);
317
+ }
318
+ /** Parse a bare package or subpath specifier into its package name. */
319
+ function barePackageName(specifier) {
320
+ if (specifier.startsWith(".") || specifier.startsWith("/") || specifier.includes(":")) return void 0;
321
+ const [first = "", second = ""] = specifier.split("/");
322
+ return first.startsWith("@") ? `${first}/${second}` : first;
323
+ }
324
+ /** Find a bare package's directory from the authored patch's module-resolution anchor. */
325
+ function packageDirFromPatch(source, packageName) {
326
+ for (const searchPath of createRequire(pathToFileURL(source)).resolve.paths(packageName) ?? []) {
327
+ const candidate = join(searchPath, packageName);
328
+ if (existsSync(join(candidate, "package.json"))) return realpathSync(candidate);
329
+ }
330
+ }
331
+ /**
332
+ * Install an authored patch's resolvable bare package into the temporary
333
+ * profile fallback. This mirrors `dsh plugin` while retaining the bare entry
334
+ * name and package provenance used by request metadata.
335
+ */
336
+ function linkProfilePackage(source, cwd, packageName) {
337
+ const packageDir = packageDirFromPatch(source, packageName);
338
+ if (packageDir === void 0) return;
339
+ const link = join(cwd, ".dsh", "profiles", "node_modules", packageName);
340
+ mkdirSync(dirname(link), { recursive: true });
341
+ if (existsSync(link)) {
342
+ if (realpathSync(link) !== packageDir) throw new Error(`snapshot profile package ${packageName} resolves to two directories`);
343
+ return;
344
+ }
345
+ /* v8 ignore next -- Windows uses directory junctions; the platform lane owns that branch. */
346
+ symlinkSync(packageDir, link, process.platform === "win32" ? "junction" : "dir");
347
+ }
348
+ /**
349
+ * Copy one authored patch into the launch cwd with relative plugin names made absolute.
350
+ * @param source - authored profile patch path.
351
+ * @param cwd - isolated process cwd whose profile fallback receives package links.
352
+ * @param targetDir - existing directory that owns the materialized patch.
353
+ * @param index - stable patch ordinal used in the output filename.
354
+ * @returns absolute materialized patch path.
355
+ */
356
+ function materializeProfilePatch(source, cwd, targetDir, index) {
357
+ const parsed = yaml.load(readFileSync(source, "utf8"), { schema: entryListSchema });
358
+ if (!Array.isArray(parsed)) throw new Error(`snapshot profile patch must be a top-level array: ${source}`);
359
+ const patches = parsed;
360
+ const baseDir = dirname(source);
361
+ const resolveName = (value) => {
362
+ const packageName = barePackageName(value);
363
+ if (packageName !== void 0) linkProfilePackage(source, cwd, packageName);
364
+ return value.startsWith("./") || value.startsWith("../") ? pathToFileURL(resolve(baseDir, value)).href : value;
365
+ };
366
+ const visitEntry = (entry) => {
367
+ entry.name = resolveName(entry.name);
368
+ if (entry.group === true && Array.isArray(entry.config)) for (const child of entry.config) visitEntry(child);
369
+ };
370
+ for (const patch of patches) {
371
+ if (typeof patch.name === "string") patch.name = resolveName(patch.name);
372
+ for (const entry of patch.insert ?? []) visitEntry(entry);
373
+ }
374
+ const target = join(targetDir, `${String(index)}-${basename(source)}`);
375
+ writeFileSync(target, yaml.dump(patches, {
376
+ schema: entryListSchema,
377
+ lineWidth: -1,
378
+ noRefs: true
379
+ }));
380
+ return target;
381
+ }
382
+ /** Resolve once a running child exits. */
383
+ function waitForExit(child) {
384
+ return new Promise((resolve) => child.once("exit", () => {
385
+ resolve();
386
+ }));
387
+ }
388
+ /** Give an accepted Windows termination request a bounded window to publish its exit marker. */
389
+ function exitMarkerWithinGrace(exited) {
390
+ return Promise.race([exited.then(() => true), new Promise((resolve) => {
391
+ setTimeout(() => {
392
+ resolve(false);
393
+ }, EXIT_MARKER_GRACE_MS).unref();
394
+ })]);
395
+ }
396
+ /** Whether the child still lacks either OS termination marker. */
397
+ function isRunning(child) {
398
+ return child.exitCode === null && child.signalCode === null;
399
+ }
400
+ //#endregion
401
+ //#region lib/types/workspace.js
402
+ /** Capture readable, path-stable workspace state for recorded-session tests. */
403
+ /** Marker that lets Git retain an expected empty directory without becoming expected workspace state. */
404
+ const EMPTY_WORKSPACE_MARKER = ".empty";
405
+ function textContent(bytes) {
406
+ if (bytes.includes(0)) return void 0;
407
+ const text = bytes.toString("utf8");
408
+ return Buffer.from(text, "utf8").equals(bytes) ? text : void 0;
409
+ }
410
+ /**
411
+ * Capture one workspace without resolving links or depending on host path separators.
412
+ * @param root - Absolute directory whose user-visible state is captured.
413
+ * @param options - Harness-owned immediate children to omit.
414
+ * @returns Stable entries sorted by relative path.
415
+ */
416
+ async function captureWorkspaceSnapshot(root, options = {}) {
417
+ const ignoredRootEntries = new Set(options.ignoredRootEntries ?? []);
418
+ const visit = async (directory, segments) => {
419
+ const entries = (await readdir(directory, { withFileTypes: true })).filter((entry) => segments.length > 0 || !ignoredRootEntries.has(entry.name)).sort((left, right) => Buffer.compare(Buffer.from(left.name), Buffer.from(right.name)));
420
+ const captured = [];
421
+ for (const entry of entries) {
422
+ const childSegments = [...segments, entry.name];
423
+ const path = childSegments.join("/");
424
+ const absolute = join(directory, entry.name);
425
+ if (entry.isDirectory()) {
426
+ const children = await visit(absolute, childSegments);
427
+ captured.push(...children.length === 0 ? [{
428
+ path,
429
+ kind: "empty-directory"
430
+ }] : children);
431
+ } else if (entry.isFile()) {
432
+ const bytes = await readFile(absolute);
433
+ const content = textContent(bytes);
434
+ captured.push(content === void 0 ? {
435
+ path,
436
+ kind: "binary",
437
+ base64: bytes.toString("base64")
438
+ } : {
439
+ path,
440
+ kind: "text",
441
+ content
442
+ });
443
+ } else captured.push({
444
+ path,
445
+ kind: "symlink",
446
+ target: await readlink(absolute)
447
+ });
448
+ }
449
+ return captured;
450
+ };
451
+ return visit(root, []);
452
+ }
453
+ /**
454
+ * Capture a committed `workspace.expected/` tree, excluding its Git-only empty marker.
455
+ * @param root - Absolute expected-workspace directory.
456
+ * @returns Stable expected entries.
457
+ */
458
+ function captureExpectedWorkspaceSnapshot(root) {
459
+ return captureWorkspaceSnapshot(root, { ignoredRootEntries: [EMPTY_WORKSPACE_MARKER] });
460
+ }
461
+ //#endregion
462
+ //#region lib/types/harness.js
463
+ /**
464
+ * Shared subprocess harness for ACP snapshot suites. A library module driven by
465
+ * the suite factory in ./suite.ts (and directly by harness-level specs); each
466
+ * profile adapter names its own agent-under-test paths.
467
+ *
468
+ * It boots the REAL agent bin subprocess via the cordis Loader (so the
469
+ * export-shape bug class stays guarded — see docs/postmortem/0001), drives it
470
+ * over real ACP JSON-RPC stdio with a deterministic input script, tees raw
471
+ * stdout (for the expected-output and purity checks) into an SDK client app,
472
+ * and — in record mode — harvests the persisted session JSONL after a graceful
473
+ * shutdown flush. The pure normalizers in ./normalize.ts turn the captured
474
+ * stdout frames and the session-log events into stable, snapshot-able text.
475
+ *
476
+ * See .agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md.
477
+ *
478
+ * @module @deepseek-ai/dsh-session-snapshot/harness
479
+ */
480
+ const DEFAULT_WAIT_TIMEOUT_MS = 1e4;
481
+ const WAIT_POLL_INTERVAL_MS = 10;
482
+ /**
483
+ * Derive one stable, fixed-length spill root owned by this scenario.
484
+ * Windows uses a two-character-shorter root because drive resolution adds its drive prefix.
485
+ * @param fixtureFile - The scenario fixture whose parent directory provides the stable identity.
486
+ * @param platform - the host platform, injectable for unit coverage.
487
+ * @returns the root-relative snapshot spill directory.
488
+ */
489
+ function snapshotSpillRoot(fixtureFile, platform = process.platform) {
490
+ const scenario = basename(dirname(fixtureFile));
491
+ const key = createHash("sha256").update(scenario).digest("hex").slice(0, 9);
492
+ return `${platform === "win32" ? "/t" : "/tmp"}/dsh-acp-snap-${key}`;
493
+ }
494
+ /**
495
+ * Run a scenario end-to-end against a freshly-spawned subprocess. Owns the
496
+ * child and its generated dirs; always tears them down. Returns the captured stdout
497
+ * and (record mode) the harvested session-log path.
498
+ *
499
+ * @param input The scenario's input script (steps + optional permission answers).
500
+ * @param opts The agent to boot, the mode, and the fixture wiring.
501
+ * @returns The captured stdout/stderr, session id, generated cwd, and harvested logs.
502
+ */
503
+ async function runScenario(input, opts) {
504
+ const cwd = await mkdtemp(join(opts.workspaceParent ?? tmpdir(), "acp-snap-cwd-"));
505
+ const cwdAliases = [...new Set([realpathSync(cwd), realpathSync.native(cwd)])];
506
+ const sessionsRoot = await mkdtemp(join(tmpdir(), "acp-snap-sessions-"));
507
+ const spillRoot = snapshotSpillRoot(opts.fixtureFile);
508
+ let launched;
509
+ let sessionId;
510
+ let sessionLogs = [];
511
+ const outcome = await (async () => {
512
+ if (opts.workspaceDir !== void 0 && existsSync(opts.workspaceDir)) await cp(opts.workspaceDir, cwd, { recursive: true });
513
+ await opts.prepareWorkspace?.(cwd);
514
+ const initialWorkspace = await captureWorkspaceSnapshot(cwd, { ignoredRootEntries: [
515
+ ".agents",
516
+ ".dsh",
517
+ ".dsh-profile-patches",
518
+ ".dsh-snapshot-stream-ready"
519
+ ] });
520
+ const env = {
521
+ ...opts.env,
522
+ DSH_SNAPSHOT: opts.mode,
523
+ DSH_SNAPSHOT_FILE: opts.fixtureFile,
524
+ DSH_SNAPSHOT_SESSIONS_ROOT: sessionsRoot,
525
+ DSH_SNAPSHOT_SPILL_ROOT: spillRoot,
526
+ DSH_HOME: join(cwd, ".dsh"),
527
+ DSH_AGENTS_HOME: join(cwd, ".agents"),
528
+ ...opts.overrideFile !== void 0 ? { DSH_SNAPSHOT_OVERRIDE: opts.overrideFile } : {},
529
+ ...opts.childFiles !== void 0 && opts.childFiles.length > 0 ? { DSH_SNAPSHOT_CHILD_FILES: opts.childFiles.join(delimiter) } : {}
530
+ };
531
+ const permissionQueue = [...input.permissionAnswers ?? []];
532
+ let scriptError;
533
+ launched = launchAcpTestAgent({
534
+ agent: opts.agent,
535
+ cwd,
536
+ ...opts.configPath !== void 0 ? { configPath: opts.configPath } : {},
537
+ env,
538
+ requestPermission(params) {
539
+ const answer = permissionQueue.shift();
540
+ if (answer === void 0) return Promise.resolve({ outcome: { outcome: "cancelled" } });
541
+ const option = params.options.find((o) => o.kind === answer.kind);
542
+ if (option === void 0) {
543
+ scriptError = /* @__PURE__ */ new Error(`snapshot-harness: scripted permission answer ${answer.kind} not among the offered options [${params.options.map((o) => o.kind).join(", ")}]`);
544
+ return Promise.resolve({ outcome: { outcome: "cancelled" } });
545
+ }
546
+ return Promise.resolve({ outcome: {
547
+ outcome: "selected",
548
+ optionId: option.optionId
549
+ } });
550
+ }
551
+ });
552
+ const active = launched;
553
+ await active.spawned;
554
+ const { client } = active;
555
+ for (const step of input.steps) {
556
+ await runStep(client, step, cwd, (match) => active.waitForUpdate(match), () => sessionId, (id) => {
557
+ sessionId = id;
558
+ }, (id, timeoutMs, minimumTurn) => waitForPersistedTurnStart(sessionsRoot, id, timeoutMs, minimumTurn), (id, timeoutMs) => waitForPersistedTurnEnd(sessionsRoot, id, timeoutMs), (child, timeoutMs, minimumTurn) => waitForPersistedChildTurnEnd(sessionsRoot, child, timeoutMs, minimumTurn), (id, phase, timeoutMs) => waitForPersistedGoalPhase(sessionsRoot, id, phase, timeoutMs), (id, text, timeoutMs) => waitForPersistedInboxMessage(sessionsRoot, id, text, timeoutMs), (id, timeoutMs) => waitForPersistedTitleAfterTurnEnd(sessionsRoot, id, timeoutMs), (id, type, timeoutMs) => waitForPersistedEventAfterTurnEnd(sessionsRoot, id, type, timeoutMs));
559
+ if (scriptError !== void 0) throw scriptError;
560
+ }
561
+ await active.close();
562
+ sessionLogs = await harvestSessionLogs(sessionsRoot);
563
+ const finalWorkspace = await captureWorkspaceSnapshot(cwd, { ignoredRootEntries: [
564
+ ".agents",
565
+ ".dsh",
566
+ ".dsh-profile-patches",
567
+ ".dsh-snapshot-stream-ready"
568
+ ] });
569
+ return {
570
+ rawStdout: launched.rawStdout(),
571
+ stderr: launched.stderr(),
572
+ cwd,
573
+ cwdAliases,
574
+ initialWorkspace,
575
+ finalWorkspace,
576
+ ...sessionId !== void 0 ? { sessionId } : {},
577
+ sessionLogs
578
+ };
579
+ })().then((value) => ({
580
+ status: "fulfilled",
581
+ value
582
+ }), (error) => {
583
+ const stderr = launched?.stderr() ?? "";
584
+ return {
585
+ status: "rejected",
586
+ error: stderr === "" ? error : new Error(`snapshot-harness: scenario failed: ${String(error)}\nagent stderr:\n${stderr}`, { cause: error })
587
+ };
588
+ });
589
+ const cleanupResults = [];
590
+ const cleanup = async (action) => {
591
+ cleanupResults.push(...await Promise.allSettled([action()]));
592
+ };
593
+ /* v8 ignore next 1 -- launch itself can only throw on a defensive synchronous spawn API failure */
594
+ await cleanup(() => launched?.close("SIGKILL") ?? Promise.resolve());
595
+ await cleanup(() => rm(cwd, {
596
+ recursive: true,
597
+ force: true
598
+ }));
599
+ await cleanup(() => rm(sessionsRoot, {
600
+ recursive: true,
601
+ force: true
602
+ }));
603
+ await cleanup(() => rm(spillRoot, {
604
+ recursive: true,
605
+ force: true
606
+ }));
607
+ const cleanupFailures = cleanupResults.filter((result) => result.status === "rejected").map((result) => result.reason);
608
+ if (cleanupFailures.length > 0) throw new AggregateError(outcome.status === "rejected" ? [outcome.error, ...cleanupFailures] : cleanupFailures, outcome.status === "rejected" ? "snapshot scenario and cleanup failed" : "snapshot cleanup failed");
609
+ if (outcome.status === "rejected") throw outcome.error;
610
+ return outcome.value;
611
+ }
612
+ /** Drive one input step over the client connection. */
613
+ async function runStep(client, step, cwd, waitForUpdate, getSessionId, setSessionId, waitForTurnStart, waitForTurnEnd, waitForChildTurnEnd, waitForGoalPhase, waitForInboxMessage, waitForTitleAfterTurnEnd, waitForEventAfterTurnEnd) {
614
+ switch (step.op) {
615
+ case "initialize":
616
+ await client.initialize({
617
+ protocolVersion: PROTOCOL_VERSION,
618
+ clientCapabilities: {}
619
+ });
620
+ return;
621
+ case "newSession": {
622
+ const { sessionId } = await client.newSession({
623
+ cwd,
624
+ mcpServers: []
625
+ });
626
+ setSessionId(sessionId);
627
+ return;
628
+ }
629
+ case "newSessionExpectError":
630
+ await client.newSession({
631
+ cwd,
632
+ mcpServers: [],
633
+ ...step.additionalDirectories !== void 0 ? { additionalDirectories: step.additionalDirectories } : {}
634
+ }).then(() => {
635
+ throw new Error("snapshot-harness: expected session/new to be rejected but it succeeded");
636
+ }, () => {});
637
+ return;
638
+ case "prompt": {
639
+ const sessionId = getSessionId();
640
+ if (sessionId === void 0) throw new Error("snapshot-harness: prompt before newSession");
641
+ await client.prompt({
642
+ sessionId,
643
+ prompt: [{
644
+ type: "text",
645
+ text: step.text
646
+ }]
647
+ });
648
+ return;
649
+ }
650
+ case "promptContent": {
651
+ const sessionId = getSessionId();
652
+ if (sessionId === void 0) throw new Error("snapshot-harness: promptContent before newSession");
653
+ await client.prompt({
654
+ sessionId,
655
+ prompt: step.content
656
+ });
657
+ return;
658
+ }
659
+ case "promptAndWaitForAgentMessage": {
660
+ const sessionId = getSessionId();
661
+ if (sessionId === void 0) throw new Error("snapshot-harness: promptAndWaitForAgentMessage before newSession");
662
+ const updateDone = waitForUpdate((update) => update.sessionUpdate === "agent_message_chunk" && update.content.type === "text" && update.content.text === step.waitForText);
663
+ await client.prompt({
664
+ sessionId,
665
+ prompt: [{
666
+ type: "text",
667
+ text: step.text
668
+ }]
669
+ });
670
+ await updateDone;
671
+ return;
672
+ }
673
+ case "promptExpectError": {
674
+ const sessionId = getSessionId();
675
+ if (sessionId === void 0) throw new Error("snapshot-harness: promptExpectError before newSession");
676
+ await client.prompt({
677
+ sessionId,
678
+ prompt: [{
679
+ type: "text",
680
+ text: step.text
681
+ }]
682
+ }).then(() => {
683
+ throw new Error("snapshot-harness: expected the prompt to fail but it succeeded");
684
+ }, () => {});
685
+ return;
686
+ }
687
+ case "promptAndCancel": {
688
+ const sessionId = getSessionId();
689
+ if (sessionId === void 0) throw new Error("snapshot-harness: promptAndCancel before newSession");
690
+ const promptDone = client.prompt({
691
+ sessionId,
692
+ prompt: [{
693
+ type: "text",
694
+ text: step.text
695
+ }]
696
+ });
697
+ if (step.waitForFile !== void 0) await waitForWorkspaceFile(cwd, step.waitForFile.path, step.waitForFile.timeoutMs);
698
+ else await waitForTurnStart(sessionId);
699
+ await client.cancel({ sessionId });
700
+ await promptDone;
701
+ return;
702
+ }
703
+ case "waitForFile":
704
+ await waitForWorkspaceFile(cwd, step.path, step.timeoutMs);
705
+ return;
706
+ case "waitForTurnEnd": {
707
+ const sessionId = getSessionId();
708
+ if (sessionId === void 0) throw new Error("snapshot-harness: waitForTurnEnd before newSession");
709
+ await waitForTurnEnd(sessionId, step.timeoutMs);
710
+ return;
711
+ }
712
+ case "waitForSubagentTurnEnd":
713
+ await waitForChildTurnEnd(step.child ?? 1, step.timeoutMs, step.minimumTurn);
714
+ return;
715
+ case "waitForGoalPhase": {
716
+ const sessionId = getSessionId();
717
+ if (sessionId === void 0) throw new Error("snapshot-harness: waitForGoalPhase before newSession");
718
+ await waitForGoalPhase(sessionId, step.phase, step.timeoutMs);
719
+ return;
720
+ }
721
+ case "waitForInboxMessage": {
722
+ const sessionId = getSessionId();
723
+ if (sessionId === void 0) throw new Error("snapshot-harness: waitForInboxMessage before newSession");
724
+ await waitForInboxMessage(sessionId, step.text, step.timeoutMs);
725
+ return;
726
+ }
727
+ case "waitForTitleAfterTurnEnd": {
728
+ const sessionId = getSessionId();
729
+ if (sessionId === void 0) throw new Error("snapshot-harness: waitForTitleAfterTurnEnd before newSession");
730
+ await waitForTitleAfterTurnEnd(sessionId, step.timeoutMs);
731
+ return;
732
+ }
733
+ case "waitForEventAfterTurnEnd": {
734
+ const sessionId = getSessionId();
735
+ if (sessionId === void 0) throw new Error("snapshot-harness: waitForEventAfterTurnEnd before newSession");
736
+ await waitForEventAfterTurnEnd(sessionId, step.type, step.timeoutMs);
737
+ return;
738
+ }
739
+ case "waitForTurnStart": {
740
+ const sessionId = getSessionId();
741
+ if (sessionId === void 0) throw new Error("snapshot-harness: waitForTurnStart before newSession");
742
+ await waitForTurnStart(sessionId, step.timeoutMs, step.minimumTurn);
743
+ return;
744
+ }
745
+ case "cancel": {
746
+ const sessionId = getSessionId();
747
+ if (sessionId === void 0) throw new Error("snapshot-harness: cancel before newSession");
748
+ if (step.waitForFile !== void 0) await waitForWorkspaceFile(cwd, step.waitForFile.path, step.waitForFile.timeoutMs);
749
+ await client.cancel({ sessionId });
750
+ return;
751
+ }
752
+ default: throw new Error(`snapshot-harness: unknown input op ${JSON.stringify(step)}`);
753
+ }
754
+ }
755
+ /** Wait until persistence exposes an open turn for the selected session. */
756
+ async function waitForPersistedTurnStart(root, sessionId, timeoutMs = DEFAULT_WAIT_TIMEOUT_MS, minimumTurn) {
757
+ let invalidRecord;
758
+ await vi.waitFor(async () => {
759
+ const log = (await harvestSessionLogs(root)).find((candidate) => candidate.id === sessionId);
760
+ let openTurn;
761
+ try {
762
+ openTurn = log === void 0 ? void 0 : latestOpenTurn(log.content);
763
+ } catch (error) {
764
+ invalidRecord = { error };
765
+ return;
766
+ }
767
+ if (openTurn === void 0 || minimumTurn !== void 0 && openTurn < minimumTurn) {
768
+ const detail = minimumTurn === void 0 ? "turn/start" : `turn/start at or beyond turn ${minimumTurn}`;
769
+ throw new Error(`snapshot-harness: session "${sessionId}" did not persist ${detail} within ${timeoutMs}ms`);
770
+ }
771
+ }, {
772
+ interval: WAIT_POLL_INTERVAL_MS,
773
+ timeout: timeoutMs
774
+ });
775
+ if (invalidRecord !== void 0) throw invalidRecord.error;
776
+ }
777
+ /**
778
+ * Wait until the raw JSONL backend exposes one complete closing turn boundary.
779
+ * The ACP cancel notification settles its prompt before the agent necessarily
780
+ * reaches quiescence, so cancellation snapshots use this external boundary to
781
+ * keep subprocess disposal from changing an `aborted` turn into `disposed`.
782
+ */
783
+ async function waitForPersistedTurnEnd(root, sessionId, timeoutMs = DEFAULT_WAIT_TIMEOUT_MS) {
784
+ await vi.waitFor(async () => {
785
+ const log = (await harvestSessionLogs(root)).find((candidate) => candidate.id === sessionId);
786
+ if (log === void 0 || !latestTurnIsClosed(log.content)) throw new Error(`snapshot-harness: session "${sessionId}" did not persist turn/end within ${timeoutMs}ms`);
787
+ }, {
788
+ interval: WAIT_POLL_INTERVAL_MS,
789
+ timeout: timeoutMs
790
+ });
791
+ }
792
+ /**
793
+ * Wait until the Nth harvested child Session closes a model work turn.
794
+ *
795
+ * Harvest order matches `session.1.jsonl`, `session.2.jsonl`, and so on. A
796
+ * continuable child appends its descriptor after any inherited history and
797
+ * before accepting its first prompt, so only a later request header proves its
798
+ * own model work reached a closed turn.
799
+ */
800
+ async function waitForPersistedChildTurnEnd(root, child, timeoutMs = DEFAULT_WAIT_TIMEOUT_MS, minimumTurn = 1) {
801
+ await vi.waitFor(async () => {
802
+ const log = (await harvestSessionLogs(root))[child];
803
+ if (log === void 0 || !latestTurnIsClosed(log.content) || !hasRequestHeaderAfterDescriptor(log.content) || !hasClosedTurn(log.content, minimumTurn)) throw new Error(`snapshot-harness: subagent child #${child} did not persist closed turn ${minimumTurn} within ${timeoutMs}ms`);
804
+ }, {
805
+ interval: WAIT_POLL_INTERVAL_MS,
806
+ timeout: timeoutMs
807
+ });
808
+ }
809
+ /** Whether a raw session log contains the requested closed turn. */
810
+ function hasClosedTurn(content, turn) {
811
+ return content.split("\n").filter(Boolean).some((line) => {
812
+ const event = JSON.parse(line);
813
+ return event.type === "turn/end" && event.data?.turn === turn;
814
+ });
815
+ }
816
+ /** Wait until the latest durable goal snapshot reaches one phase. */
817
+ async function waitForPersistedGoalPhase(root, sessionId, phase, timeoutMs = DEFAULT_WAIT_TIMEOUT_MS) {
818
+ await vi.waitFor(async () => {
819
+ if (!(((await harvestSessionLogs(root)).find((log) => log.id === sessionId)?.content)?.split("\n").filter(Boolean).some((line) => {
820
+ const event = JSON.parse(line);
821
+ return event.type === "goal/change" && event.data?.goal?.phase === phase;
822
+ }) ?? false)) throw new Error(`snapshot-harness: session "${sessionId}" did not persist goal phase "${phase}" within ${timeoutMs}ms`);
823
+ }, {
824
+ interval: WAIT_POLL_INTERVAL_MS,
825
+ timeout: timeoutMs
826
+ });
827
+ }
828
+ /** Wait until an inserted inbox message contains scenario-owned text. */
829
+ async function waitForPersistedInboxMessage(root, sessionId, text, timeoutMs = DEFAULT_WAIT_TIMEOUT_MS) {
830
+ await vi.waitFor(async () => {
831
+ if (!((await harvestSessionLogs(root)).find((candidate) => candidate.id === sessionId)?.content.split("\n").some((line) => {
832
+ if (line.length === 0) return false;
833
+ const record = JSON.parse(line);
834
+ return record.type === "agent/inbox/spliced" && record.data?.inserted?.some((message) => message.content?.some((block) => block.type === "text" && typeof block.text === "string" && block.text.includes(text))) === true;
835
+ }) ?? false)) throw new Error(`snapshot-harness: session "${sessionId}" did not persist expected inbox message within ${timeoutMs}ms`);
836
+ }, {
837
+ interval: WAIT_POLL_INTERVAL_MS,
838
+ timeout: timeoutMs
839
+ });
840
+ }
841
+ /** Whether a child log contains model work after its own descriptor event. */
842
+ function hasRequestHeaderAfterDescriptor(content) {
843
+ const events = content.slice(0, content.lastIndexOf("\n") + 1).split("\n").filter((line) => line.length > 0).map((line) => JSON.parse(line));
844
+ const descriptor = events.findLastIndex((event) => event.type === "subagent/descriptor");
845
+ return descriptor >= 0 && events.slice(descriptor + 1).some((event) => event.type === "request/header");
846
+ }
847
+ /** Wait until a complete provider or fallback title record follows the latest closed turn. */
848
+ async function waitForPersistedTitleAfterTurnEnd(root, sessionId, timeoutMs = DEFAULT_WAIT_TIMEOUT_MS) {
849
+ await vi.waitFor(async () => {
850
+ const log = (await harvestSessionLogs(root)).find((candidate) => candidate.id === sessionId);
851
+ if (log === void 0 || !latestTitleFollowsTurnEnd(log.content)) throw new Error(`snapshot-harness: session "${sessionId}" did not persist session/title after turn/end within ${timeoutMs}ms`);
852
+ }, {
853
+ interval: WAIT_POLL_INTERVAL_MS,
854
+ timeout: timeoutMs
855
+ });
856
+ }
857
+ /** Wait until a complete record of `type` follows the latest closed turn. */
858
+ async function waitForPersistedEventAfterTurnEnd(root, sessionId, type, timeoutMs = DEFAULT_WAIT_TIMEOUT_MS) {
859
+ await vi.waitFor(async () => {
860
+ const log = (await harvestSessionLogs(root)).find((candidate) => candidate.id === sessionId);
861
+ if (log === void 0 || !latestEventFollowsTurnEnd(log.content, type)) throw new Error(`snapshot-harness: session "${sessionId}" did not persist ${type} after turn/end within ${timeoutMs}ms`);
862
+ }, {
863
+ interval: WAIT_POLL_INTERVAL_MS,
864
+ timeout: timeoutMs
865
+ });
866
+ }
867
+ /** Wait for a cwd-relative marker proving an external action reached readiness. */
868
+ async function waitForWorkspaceFile(cwd, path, timeoutMs = DEFAULT_WAIT_TIMEOUT_MS) {
869
+ const target = join(cwd, path);
870
+ await vi.waitFor(() => {
871
+ if (!existsSync(target)) throw new Error(`snapshot-harness: workspace file "${path}" did not appear within ${timeoutMs}ms`);
872
+ }, {
873
+ interval: WAIT_POLL_INTERVAL_MS,
874
+ timeout: timeoutMs
875
+ });
876
+ }
877
+ /** Return whether the last complete raw-JSONL turn boundary closes its turn. */
878
+ function latestTurnIsClosed(content) {
879
+ const complete = content.slice(0, content.lastIndexOf("\n") + 1);
880
+ return complete.lastIndexOf("\n{\"type\":\"turn/end\",") > complete.lastIndexOf("\n{\"type\":\"turn/start\",");
881
+ }
882
+ /** Return whether the last complete title record occurs after the last complete turn end. */
883
+ function latestTitleFollowsTurnEnd(content) {
884
+ const complete = content.slice(0, content.lastIndexOf("\n") + 1);
885
+ const turnEnd = complete.lastIndexOf("\n{\"type\":\"turn/end\",");
886
+ return turnEnd >= 0 && complete.lastIndexOf("\n{\"type\":\"session/title\",") > turnEnd;
887
+ }
888
+ /** Return whether a complete record of `type` occurs after the last complete turn end. */
889
+ function latestEventFollowsTurnEnd(content, type) {
890
+ const complete = content.slice(0, content.lastIndexOf("\n") + 1);
891
+ const turnEnd = complete.lastIndexOf("\n{\"type\":\"turn/end\",");
892
+ return turnEnd >= 0 && complete.lastIndexOf(`\n{"type":"${type}",`) > turnEnd;
893
+ }
894
+ /** Return the latest open turn number, validating the persisted boundary record. */
895
+ function latestOpenTurn(content) {
896
+ const complete = content.slice(0, content.lastIndexOf("\n") + 1);
897
+ const start = complete.lastIndexOf("\n{\"type\":\"turn/start\",");
898
+ if (start <= complete.lastIndexOf("\n{\"type\":\"turn/end\",")) return void 0;
899
+ const end = complete.indexOf("\n", start + 1);
900
+ const turn = JSON.parse(complete.slice(start + 1, end)).data?.turn;
901
+ if (!Number.isSafeInteger(turn) || turn < 1) throw new Error("snapshot-harness: invalid persisted turn/start record");
902
+ return turn;
903
+ }
904
+ /**
905
+ * Harvest EVERY persisted `.jsonl` session log under a sessions root, parse each
906
+ * header line, and return them ordered primary-first: the top-level session (no
907
+ * `parentSession`) leads, then each subagent child by ascending `createdAt`.
908
+ *
909
+ * Snapshot configs select the JSONL backend's raw mode, which lays sessions
910
+ * out as `<root>/<project>/<session-id>/session.jsonl`. Recursive collection
911
+ * catches the primary and every child session. Returns `[]` if no log was
912
+ * produced (a no-session scenario).
913
+ */
914
+ async function harvestSessionLogs(root) {
915
+ let files;
916
+ try {
917
+ files = await readdir(root, { recursive: true });
918
+ } catch {
919
+ return [];
920
+ }
921
+ const logs = [];
922
+ for (const file of files) {
923
+ if (basename(file) !== "session.jsonl") continue;
924
+ const content = await readFile(join(root, file), "utf8");
925
+ const firstLine = content.split("\n").find((line) => line.trim().length > 0) ?? "{}";
926
+ const header = JSON.parse(firstLine);
927
+ logs.push({
928
+ id: typeof header.id === "string" ? header.id : "",
929
+ createdAt: typeof header.createdAt === "number" ? header.createdAt : 0,
930
+ ...typeof header.parentSession === "string" ? { parentSession: header.parentSession } : {},
931
+ content
932
+ });
933
+ }
934
+ logs.sort((a, b) => {
935
+ return Number(a.parentSession !== void 0) - Number(b.parentSession !== void 0) || a.createdAt - b.createdAt || a.id.localeCompare(b.id);
936
+ });
937
+ return logs;
938
+ }
939
+ //#endregion
940
+ //#region lib/types/normalize.js
941
+ /**
942
+ * Pure ACP transcript and session-log normalizers. They scrub session ids, run cwd, RPC ids,
943
+ * timestamps, goal lifecycle clocks, and hook duration while preserving semantic payload values.
944
+ * Request-header scrubbers stay composable so one scenario per header class can pin prompt and
945
+ * tool-schema sidecars.
946
+ * @module @deepseek-ai/dsh-session-snapshot/normalize
947
+ */
948
+ const SESSION_ID = "{{sessionId}}";
949
+ const MESSAGE_ID = "{{messageId}}";
950
+ const USED_TOKENS = "{{usedTokens}}";
951
+ const CWD = "{{cwd}}";
952
+ const SYSTEM = "{{system}}";
953
+ const TOOLS = "{{tools}}";
954
+ const EVENT_TIME = "{{eventTime}}";
955
+ const EVENT_OMITTED_BYTES = "{{eventOmittedBytes}}";
956
+ const PACKED_CHUNK_ROW_TYPES$1 = new Set([
957
+ "text-chunks",
958
+ "reasoning-chunks",
959
+ "tool-call-chunks"
960
+ ]);
961
+ function isPackedFixtureRow(record) {
962
+ return typeof record.type === "string" && PACKED_CHUNK_ROW_TYPES$1.has(record.type);
963
+ }
964
+ function omitFixtureEnvelope(record) {
965
+ delete record.seq;
966
+ delete record.time;
967
+ delete record.seq0;
968
+ delete record.time0;
969
+ }
970
+ /** A cwd-rooted path after volatile cwd replacement, through its last separator-delimited segment. */
971
+ const CWD_ROOTED_PATH_RE = /\{\{cwd\}\}(?:[\\/][^\s<>"'`]+)+/g;
972
+ const PATH_TAG_RE = /(<path>)([^<]*)(<\/path>)/g;
973
+ const ADDITIONAL_INSTRUCTIONS_PATH_RE = /(Additional instructions from: )([^\r\n]+)/g;
974
+ const EMBEDDED_EVENT_TIME_RE = /^( "time": )\d+(?=,\r?$)/gm;
975
+ const EVENT_READ_OMITTED_BYTES_RE = /(\r?\n\r?\n\(Omitted )\d+( bytes\.)/g;
976
+ const EVENT_READ_TARGET_REGION_RE = /^Session [^\r\n]+ — [^\r\n]+\r?\nTarget event seq \d+:\r?\n```json\r?\n\{\r?\n[\s\S]*?(?=\r?\n```(?:\r?\n|$)|\r?\n\r?\n\(Omitted )/;
977
+ const PATH_TEXT_BOUNDARY_RE = /[\s<>'"`()\[\]{},;:!?=]/;
978
+ const FILE_URI_PATH_PREFIX_RE = /(?:^|[^a-z0-9+.-])file:\/\/\/?$/i;
979
+ /** A UUID v4 string, the shape `randomUUID()` produces for session ids. */
980
+ const UUID_RE$1 = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi;
981
+ const LOCAL_SPILL_PATH_RE = new RegExp(String.raw`\{\{cwd\}\}[\\/]\.spill[\\/]session-[0-9a-f]{12}[\\/][0-9a-f]{12}-([A-Za-z0-9._~-]+?)` + String.raw`(?=\. Use read with offset/limit|[\s)]|$)`, "g");
982
+ const SNAPSHOT_SPILL_PATH_RE = new RegExp(String.raw`(?:[A-Za-z]:)?[\\/](?:tmp|t)[\\/](?:dsh-acp-snap-[0-9a-f]{9}|dsh-acp-snapshot-spill)[\\/]session-[0-9a-f]{12}[\\/][0-9a-f]{12}-([A-Za-z0-9._~-]+?)` + String.raw`(?=\. Use read with offset/limit|[\s)]|$)`, "g");
983
+ /**
984
+ * Extract every snapshot-mode spill path from a session log, keyed by spill
985
+ * filename. Used by refresh write-back to keep spill paths stable across runs.
986
+ * @param content - the raw session log text to scan.
987
+ * @returns spill filename → the full matched spill path, last match wins per name.
988
+ */
989
+ function extractSnapshotSpillPaths(content) {
990
+ const result = /* @__PURE__ */ new Map();
991
+ for (const match of content.matchAll(SNAPSHOT_SPILL_PATH_RE)) {
992
+ const name = match[1];
993
+ /* v8 ignore next -- the filename capture is required and non-empty whenever the spill regex matches */
994
+ if (name === void 0) continue;
995
+ result.set(name, match[0]);
996
+ }
997
+ return result;
998
+ }
999
+ /** Convert separators only inside generated path-bearing text markers. */
1000
+ function canonicalizeEmbeddedPaths(value) {
1001
+ return value.replace(PATH_TAG_RE, (_match, open, path, close) => `${open}${path.replaceAll("\\", "/")}${close}`).replace(ADDITIONAL_INSTRUCTIONS_PATH_RE, (_match, prefix, path) => `${prefix}${path.replaceAll("\\", "/")}`);
1002
+ }
1003
+ /** Return every known spelling of the generated cwd, most specific first. */
1004
+ function cwdSpellings(ctx) {
1005
+ const spellings = [...new Set([ctx.cwd, ...ctx.cwdAliases ?? []])].filter((spelling) => spelling.length > 0);
1006
+ const macAliases = spellings.filter((spelling) => spelling.startsWith("/") && !spelling.startsWith("/private/")).map((spelling) => `/private${spelling}`);
1007
+ return [...new Set([...spellings, ...macAliases])].sort((left, right) => right.length - left.length);
1008
+ }
1009
+ /** Whether an embedded cwd match starts and ends at a path/text boundary. */
1010
+ function isCwdMatch(value, start, length) {
1011
+ const before = value[start - 1];
1012
+ const after = value[start + length];
1013
+ const afterPunctuation = value[start + length + 1];
1014
+ const startsAtBoundary = before === void 0 || PATH_TEXT_BOUNDARY_RE.test(before) || FILE_URI_PATH_PREFIX_RE.test(value.slice(0, start));
1015
+ const endsAtBoundary = after === void 0 || after === "/" || after === "\\" || PATH_TEXT_BOUNDARY_RE.test(after) || after === "." && (afterPunctuation === void 0 || PATH_TEXT_BOUNDARY_RE.test(afterPunctuation));
1016
+ return startsAtBoundary && endsAtBoundary;
1017
+ }
1018
+ /** Replace one cwd spelling without matching a longer path segment that merely shares its prefix. */
1019
+ function replaceCwdSpelling(value, spelling, replacement) {
1020
+ let cursor = 0;
1021
+ let out = "";
1022
+ while (cursor < value.length) {
1023
+ const match = value.indexOf(spelling, cursor);
1024
+ if (match < 0) return out + value.slice(cursor);
1025
+ const end = match + spelling.length;
1026
+ if (isCwdMatch(value, match, spelling.length)) {
1027
+ out += value.slice(cursor, match) + replacement;
1028
+ cursor = end;
1029
+ } else {
1030
+ out += value.slice(cursor, end);
1031
+ cursor = end;
1032
+ }
1033
+ }
1034
+ return out;
1035
+ }
1036
+ /** Replace every known cwd spelling with one stable token. */
1037
+ function replaceCwd(value, ctx, replacement) {
1038
+ let out = value;
1039
+ for (const spelling of cwdSpellings(ctx)) out = replaceCwdSpelling(out, spelling, replacement);
1040
+ return out;
1041
+ }
1042
+ /** Replace cwd, session ids, and any stray UUID with stable tokens in a string. */
1043
+ function scrubString(value, ctx, cwdPathMode, identityMode) {
1044
+ let out = replaceCwd(value, ctx, CWD);
1045
+ out = out.split(`/private${CWD}`).join(CWD);
1046
+ if (cwdPathMode === "canonical") {
1047
+ out = out.replace(CWD_ROOTED_PATH_RE, (path) => path.replaceAll("\\", "/"));
1048
+ out = canonicalizeEmbeddedPaths(out);
1049
+ }
1050
+ out = out.replace(LOCAL_SPILL_PATH_RE, (_match, name) => `{{spillLocator:${name}}}`);
1051
+ out = out.replace(SNAPSHOT_SPILL_PATH_RE, (_match, name) => `{{spillLocator:${name}}}`);
1052
+ if (EVENT_READ_TARGET_REGION_RE.test(out)) {
1053
+ out = out.replace(EVENT_READ_TARGET_REGION_RE, (target) => target.replace(EMBEDDED_EVENT_TIME_RE, `$1${EVENT_TIME}`));
1054
+ out = out.replace(EVENT_READ_OMITTED_BYTES_RE, `$1${EVENT_OMITTED_BYTES}$2`);
1055
+ }
1056
+ if (identityMode === "legacy") {
1057
+ for (const id of ctx.sessionIds) out = out.split(id).join(SESSION_ID);
1058
+ out = out.replace(UUID_RE$1, SESSION_ID);
1059
+ }
1060
+ return out;
1061
+ }
1062
+ /** Recursively scrub a parsed JSON value (strings replaced; structure kept). */
1063
+ function scrubValue(value, ctx, cwdPathMode, identityMode, key) {
1064
+ if (typeof value === "string") {
1065
+ if (identityMode === "legacy" && key === "messageId") return MESSAGE_ID;
1066
+ const scrubbed = scrubString(value, ctx, cwdPathMode, identityMode);
1067
+ return cwdPathMode === "canonical" && key === "path" ? scrubbed.replaceAll("\\", "/") : scrubbed;
1068
+ }
1069
+ if (Array.isArray(value)) return value.map((v) => scrubValue(v, ctx, cwdPathMode, identityMode));
1070
+ if (value !== null && typeof value === "object") {
1071
+ const out = {};
1072
+ for (const [k, v] of Object.entries(value)) out[k] = scrubValue(v, ctx, cwdPathMode, identityMode, k);
1073
+ if (value.sessionUpdate === "usage_update" && typeof value.used === "number") out.used = USED_TOKENS;
1074
+ return out;
1075
+ }
1076
+ return value;
1077
+ }
1078
+ /** Escape one literal path segment for use in a regular expression. */
1079
+ function escapeRegExp(value) {
1080
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1081
+ }
1082
+ /** Replace any absolute spelling whose final segment is the generated cwd basename. */
1083
+ function tokenizeFixtureString(value, ctx, basename) {
1084
+ const exact = replaceCwd(value, ctx, CWD);
1085
+ const absoluteCwd = new RegExp(String.raw`(?:[A-Za-z]:)?[\\/](?:[^\\/\s<>"]+[\\/])*${escapeRegExp(basename)}` + String.raw`(?=$|[\\/\s<>'"()\[\]{},;:!?=])`, "g");
1086
+ return exact.replace(absoluteCwd, CWD).split(`/private${CWD}`).join(CWD);
1087
+ }
1088
+ /** Recursively replace generated-cwd spellings while preserving every other JSON value. */
1089
+ function tokenizeFixtureValue(value, ctx, basename) {
1090
+ if (typeof value === "string") return tokenizeFixtureString(value, ctx, basename);
1091
+ if (Array.isArray(value)) return value.map((item) => tokenizeFixtureValue(item, ctx, basename));
1092
+ if (value !== null && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, tokenizeFixtureValue(item, ctx, basename)]));
1093
+ return value;
1094
+ }
1095
+ /**
1096
+ * Store one generated workspace as `{{cwd}}` while retaining every other
1097
+ * session value. The caller opts in only for workspaces created under a
1098
+ * platform temporary root; explicitly relocated workspaces keep their real
1099
+ * path.
1100
+ *
1101
+ * @param rawLog The raw or refresh-stabilized session JSONL fixture.
1102
+ * @returns Compact JSONL whose known cwd spellings become `{{cwd}}`.
1103
+ * @throws If a non-empty line is invalid JSON or the session cwd has no basename.
1104
+ */
1105
+ function tokenizeSessionFixtureCwd(rawLog) {
1106
+ const lines = rawLog.split("\n");
1107
+ const firstLine = lines.find((line) => line.trim().length > 0);
1108
+ const header = firstLine === void 0 ? void 0 : JSON.parse(firstLine);
1109
+ const cwd = typeof header?.cwd === "string" ? header.cwd : "";
1110
+ const basename = cwd.split(/[\\/]/).at(-1);
1111
+ if (basename === void 0 || basename.length === 0) throw new Error("acp-snapshot: cannot tokenize a cwd without a basename");
1112
+ const ctx = {
1113
+ sessionIds: [],
1114
+ cwd
1115
+ };
1116
+ return lines.map((line) => {
1117
+ if (line.trim().length === 0) return line;
1118
+ return JSON.stringify(tokenizeFixtureValue(JSON.parse(line), ctx, basename));
1119
+ }).join("\n");
1120
+ }
1121
+ /**
1122
+ * Normalize a raw stdout transcript (newline-delimited JSON-RPC frames) into a stable expected output
1123
+ * in the same shape as the wire: one compact JSON frame per line (NDJSON), with the JSON-RPC
1124
+ * `id` rewritten to a per-transcript sequence (1, 2, 3, …) and all volatile strings scrubbed.
1125
+ * Invalid JSON throws, doubling as a protocol-stdout purity check.
1126
+ *
1127
+ * @param rawStdout The captured stdout bytes, decoded utf8.
1128
+ * @param ctx The run's volatile values to scrub.
1129
+ * @param options Separator output controls; shared canonical paths are the default.
1130
+ * @returns The normalized NDJSON transcript, one frame per line.
1131
+ */
1132
+ function normalizeStdout(rawStdout, ctx, options = {}) {
1133
+ const cwdPathMode = options.cwdPathMode ?? "canonical";
1134
+ const identityMode = options.identityMode ?? "legacy";
1135
+ const lines = rawStdout.split("\n").filter((line) => line.trim().length > 0);
1136
+ const idSeq = /* @__PURE__ */ new Map();
1137
+ const stableId = (id) => {
1138
+ const key = JSON.stringify(id);
1139
+ let n = idSeq.get(key);
1140
+ if (n === void 0) {
1141
+ n = idSeq.size + 1;
1142
+ idSeq.set(key, n);
1143
+ }
1144
+ return n;
1145
+ };
1146
+ return lines.map((line) => {
1147
+ const frame = JSON.parse(line);
1148
+ if ("id" in frame && frame.id !== void 0 && frame.id !== null) frame.id = stableId(frame.id);
1149
+ return scrubValue(frame, ctx, cwdPathMode, identityMode);
1150
+ }).map((f) => JSON.stringify(f)).join("\n") + "\n";
1151
+ }
1152
+ /**
1153
+ * Normalize a session JSONL log into a stable expected output: the header line's
1154
+ * volatile fields (`createdAt`, `id`, `cwd`) are zeroed/scrubbed, ordinary
1155
+ * event `time`, packed-row `time0`, and goal-change lifecycle clock values are
1156
+ * zeroed, and all volatile strings are scrubbed. Projected inputs remain
1157
+ * projected. Packed `data.dt` gaps are normalized even when the projected row
1158
+ * omits its `time0` anchor.
1159
+ * Output is JSONL in the same shape as the input — one compact record per
1160
+ * line.
1161
+ *
1162
+ * @param rawLog The raw session `.jsonl` content.
1163
+ * @param ctx The run's volatile values to scrub.
1164
+ * @param options Separator output controls; shared canonical paths are the default.
1165
+ * @returns The normalized JSONL log, one record per line.
1166
+ */
1167
+ function normalizeSessionLog(rawLog, ctx, options = {}) {
1168
+ const cwdPathMode = options.cwdPathMode ?? "canonical";
1169
+ const identityMode = options.identityMode ?? "legacy";
1170
+ return rawLog.split("\n").filter((line) => line.trim().length > 0).map((line) => {
1171
+ const record = JSON.parse(line);
1172
+ if (record.type === "session") {
1173
+ if ("createdAt" in record) record.createdAt = 0;
1174
+ } else if (isPackedFixtureRow(record)) {
1175
+ if ("time0" in record) record.time0 = 0;
1176
+ const data = record.data;
1177
+ if (data !== null && typeof data === "object" && Array.isArray(data.dt)) data.dt = data.dt.map(() => 0);
1178
+ } else if ("time" in record) record.time = 0;
1179
+ if (record.type === "hook/result" && record.data !== null && typeof record.data === "object") {
1180
+ const data = record.data;
1181
+ if ("durationMs" in data) data.durationMs = 0;
1182
+ }
1183
+ if (record.type === "goal/change" && record.data !== null && typeof record.data === "object") {
1184
+ const data = record.data;
1185
+ if ("createdAt" in data) data.createdAt = 0;
1186
+ if ("updatedAt" in data) data.updatedAt = 0;
1187
+ }
1188
+ if (Object.hasOwn(record, "sourceEventSeqs")) record.sourceEventSeqs = decodeSeqRanges(record.sourceEventSeqs);
1189
+ return scrubValue(record, ctx, cwdPathMode, identityMode);
1190
+ }).map((r) => JSON.stringify(r)).join("\n") + "\n";
1191
+ }
1192
+ /**
1193
+ * Repack projected body records so persistence flush boundaries do not affect
1194
+ * committed snapshots. Synthetic envelopes exist only while the storage codec
1195
+ * reconstructs and packs the logical event stream; returned rows stay projected.
1196
+ */
1197
+ function repackSessionSnapshot(rawLog) {
1198
+ const lines = rawLog.split("\n").filter((line) => line.trim().length > 0);
1199
+ const header = lines.shift();
1200
+ let nextSeq = 0;
1201
+ return [
1202
+ header,
1203
+ ...packChunkRuns(lines.flatMap((line) => {
1204
+ const record = JSON.parse(line);
1205
+ if (isPackedFixtureRow(record)) {
1206
+ const decoded = decodeStorageRecord({
1207
+ ...record,
1208
+ seq0: nextSeq,
1209
+ time0: 0
1210
+ });
1211
+ nextSeq += decoded.length;
1212
+ return decoded;
1213
+ }
1214
+ const event = {
1215
+ ...record,
1216
+ seq: nextSeq,
1217
+ time: 0
1218
+ };
1219
+ nextSeq += 1;
1220
+ return [event];
1221
+ })).map((stored) => {
1222
+ const projected = { ...stored };
1223
+ omitFixtureEnvelope(projected);
1224
+ return JSON.stringify(projected);
1225
+ }),
1226
+ ""
1227
+ ].join("\n");
1228
+ }
1229
+ /**
1230
+ * Normalize and project persisted session JSONL for a committed fixture.
1231
+ * This composes ordinary log normalization with request-header scrubbing and
1232
+ * persistence-envelope projection, then packs the logical event stream into a
1233
+ * canonical layout independent of persistence flush boundaries.
1234
+ *
1235
+ * @param rawLog - persisted or already-projected session JSONL.
1236
+ * @param ctx - the run's volatile values to scrub.
1237
+ * @param options - separator output controls.
1238
+ * @returns normalized committed session snapshot JSONL.
1239
+ */
1240
+ function normalizeSessionSnapshot(rawLog, ctx, options = {}) {
1241
+ return repackSessionSnapshot(scrubSessionSnapshot(normalizeSessionLog(rawLog, ctx, options)));
1242
+ }
1243
+ /**
1244
+ * Normalize one scenario's primary and child logs with shared typed identity redaction.
1245
+ * @param rawLogs - primary-first persisted or projected session JSONL.
1246
+ * @param ctx - generated cwd spellings and other volatile run facts.
1247
+ * @param options - separator controls; relationship-preserving identity mode is mandatory.
1248
+ * @returns normalized session fixtures in input order.
1249
+ */
1250
+ function normalizeSessionSnapshots(rawLogs, ctx, options = {}) {
1251
+ return redactSessionSnapshotIds(rawLogs).map((log) => repackSessionSnapshot(scrubSessionSnapshot(normalizeSessionLog(log, {
1252
+ ...ctx,
1253
+ sessionIds: []
1254
+ }, {
1255
+ ...options,
1256
+ identityMode: "preserve"
1257
+ }))));
1258
+ }
1259
+ /**
1260
+ * Replace system-prompt content in request headers with `{{system}}` tokens
1261
+ * while retaining field presence.
1262
+ * Other header content stays verbatim, so a header-pinning fixture can keep
1263
+ * its complete tool schemas while every JSONL fixture omits the prompt text.
1264
+ * Lines without a system payload pass through byte-for-byte; the transform is
1265
+ * idempotent.
1266
+ *
1267
+ * @param rawLog The raw session `.jsonl` content.
1268
+ * @returns The JSONL with system-prompt content tokenized.
1269
+ */
1270
+ function scrubSystemPrompts(rawLog) {
1271
+ return scrubHeaderContent(rawLog, { system: true });
1272
+ }
1273
+ /**
1274
+ * Replace tool schemas in full request-header snapshots with `{{tools}}`
1275
+ * tokens while retaining field presence. System prompts and session-prefix
1276
+ * messages stay verbatim so pinning fixtures can move only schema bulk into
1277
+ * their dedicated JSON sidecar. Lines without a tool payload pass through
1278
+ * byte-for-byte; the transform is idempotent.
1279
+ *
1280
+ * @param rawLog The raw session `.jsonl` content.
1281
+ * @returns The JSONL with tool-schema content tokenized.
1282
+ */
1283
+ function scrubToolSchemas(rawLog) {
1284
+ return scrubHeaderContent(rawLog, { tools: true });
1285
+ }
1286
+ /**
1287
+ * Replace all bulky request-header content in a session JSONL with stable
1288
+ * tokens. This includes the system-prompt fields handled by
1289
+ * {@link scrubSystemPrompts}, tool schemas, and session-prefix messages. It
1290
+ * keeps prefix message counts, field presence, config, and reason. Lines
1291
+ * without content to scrub pass through byte-for-byte, and the transform is
1292
+ * idempotent.
1293
+ *
1294
+ * @param rawLog The raw session `.jsonl` content.
1295
+ * @returns The JSONL with all header bulk tokenized, other lines byte-identical.
1296
+ */
1297
+ function scrubRequestHeaders(rawLog) {
1298
+ return scrubHeaderContent(rawLog, {
1299
+ system: true,
1300
+ tools: true
1301
+ });
1302
+ }
1303
+ /**
1304
+ * Project a persisted session log while tokenizing all request-header bulk.
1305
+ * Each non-empty line is parsed at most once; the session header stays
1306
+ * byte-identical. Body records omit their persistence-only envelopes, and
1307
+ * request-header payloads are tokenized.
1308
+ *
1309
+ * @param rawLog - persisted or already-projected session JSONL.
1310
+ * @returns committed snapshot JSONL with request headers tokenized.
1311
+ */
1312
+ function scrubSessionSnapshot(rawLog) {
1313
+ const scrubbed = scrubRequestHeaders(rawLog);
1314
+ let recordIndex = 0;
1315
+ return scrubbed.split("\n").map((line) => {
1316
+ if (line.trim().length === 0) return line;
1317
+ const record = JSON.parse(line);
1318
+ if (recordIndex++ === 0) {
1319
+ if (record.type !== "session") throw new Error("session snapshot must start with a session header");
1320
+ return line;
1321
+ }
1322
+ omitFixtureEnvelope(record);
1323
+ return JSON.stringify(record);
1324
+ }).join("\n");
1325
+ }
1326
+ /** Transform the selected request-header payloads. */
1327
+ function scrubHeaderContent(rawLog, options) {
1328
+ return rawLog.split("\n").map((line) => {
1329
+ if (line.trim().length === 0) return line;
1330
+ const record = JSON.parse(line);
1331
+ const data = record.data;
1332
+ if (data === null || typeof data !== "object") return line;
1333
+ if (record.type === "request/header") {
1334
+ const header = data.header;
1335
+ if (header === null || typeof header !== "object") return line;
1336
+ let touched = false;
1337
+ if (options.system === true && "system" in header) {
1338
+ header.system = SYSTEM;
1339
+ touched = true;
1340
+ }
1341
+ if (options.tools === true && "tools" in header) {
1342
+ header.tools = TOOLS;
1343
+ touched = true;
1344
+ }
1345
+ return touched ? JSON.stringify(record) : line;
1346
+ }
1347
+ return line;
1348
+ }).join("\n");
1349
+ }
1350
+ //#endregion
1351
+ //#region lib/types/manifest.js
1352
+ /** Parse and validate one recorded-session snapshot manifest. */
1353
+ const PROFILES = new Set([
1354
+ "headless",
1355
+ "sdk",
1356
+ "acp",
1357
+ "web"
1358
+ ]);
1359
+ const RECORDINGS = new Set(["live", "authored"]);
1360
+ const PLATFORMS = new Set(["posix", "pwsh"]);
1361
+ const PERMISSIONS = new Set([
1362
+ "read-only",
1363
+ "workspace-write",
1364
+ "danger-full-access"
1365
+ ]);
1366
+ const NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
1367
+ function record(value, label) {
1368
+ if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be a mapping`);
1369
+ return value;
1370
+ }
1371
+ function exactKeys(value, allowed, label) {
1372
+ const unknown = Object.keys(value).filter((key) => !allowed.includes(key)).sort();
1373
+ if (unknown.length > 0) throw new Error(`${label} has unknown field(s): ${unknown.join(", ")}`);
1374
+ }
1375
+ function name(value, label) {
1376
+ if (typeof value !== "string" || !NAME_RE.test(value)) throw new Error(`${label} must be a lower-kebab-case name`);
1377
+ return value;
1378
+ }
1379
+ function scenarioSource(value, label) {
1380
+ if (typeof value !== "string" || !value.split("/").every((segment) => NAME_RE.test(segment))) throw new Error(`${label} must be a lower-kebab-case name or corpus-relative path`);
1381
+ return value;
1382
+ }
1383
+ function positiveIndexes(value, label) {
1384
+ if (!Array.isArray(value) || value.some((item) => !Number.isInteger(item) || Number(item) < 1) || new Set(value).size !== value.length) throw new Error(`${label} must be an array of unique positive integers`);
1385
+ return [...value];
1386
+ }
1387
+ /**
1388
+ * Parse one `snapshot.yml` without admitting JavaScript YAML tags or unknown fields.
1389
+ * @param source - complete manifest text.
1390
+ * @param path - diagnostic path.
1391
+ * @returns validated manifest metadata.
1392
+ */
1393
+ function parseSnapshotManifest(source, path = "snapshot.yml") {
1394
+ let parsed;
1395
+ try {
1396
+ parsed = yaml.load(source, { schema: yaml.JSON_SCHEMA });
1397
+ } catch (error) {
1398
+ throw new Error(`session-snapshot: ${path}: invalid YAML: ${String(error)}`);
1399
+ }
1400
+ try {
1401
+ const root = record(parsed, "manifest");
1402
+ exactKeys(root, [
1403
+ "version",
1404
+ "scenario",
1405
+ "profile",
1406
+ "composition",
1407
+ "recording",
1408
+ "header",
1409
+ "replay",
1410
+ "platform",
1411
+ "permission",
1412
+ "environment",
1413
+ "workspace",
1414
+ "input",
1415
+ "session"
1416
+ ], "manifest");
1417
+ if (root.version !== 1) throw new Error("manifest.version must equal 1");
1418
+ const scenario = root.scenario === void 0 ? void 0 : name(root.scenario, "manifest.scenario");
1419
+ if (typeof root.profile !== "string" || !PROFILES.has(root.profile)) throw new Error("manifest.profile must be headless, sdk, acp, or web");
1420
+ const composition = root.composition === void 0 ? void 0 : name(root.composition, "manifest.composition");
1421
+ let recording;
1422
+ if (root.recording !== void 0) {
1423
+ if (typeof root.recording !== "string" || !RECORDINGS.has(root.recording)) throw new Error("manifest.recording must be live or authored");
1424
+ recording = root.recording;
1425
+ }
1426
+ let header;
1427
+ if (root.header !== void 0) {
1428
+ const value = record(root.header, "manifest.header");
1429
+ exactKeys(value, [
1430
+ "class",
1431
+ "pin",
1432
+ "systemPromptSource",
1433
+ "toolSchemasSource",
1434
+ "childSystemPrompts",
1435
+ "childToolSchemas",
1436
+ "changes"
1437
+ ], "manifest.header");
1438
+ if (value.pin !== void 0 && value.pin !== true) throw new Error("manifest.header.pin must equal true when present");
1439
+ if (value.changes !== void 0 && (!Number.isInteger(value.changes) || Number(value.changes) < 0)) throw new Error("manifest.header.changes must be a non-negative integer");
1440
+ header = {
1441
+ class: name(value.class, "manifest.header.class"),
1442
+ ...value.pin === true ? { pin: true } : {},
1443
+ ...value.systemPromptSource === void 0 ? {} : { systemPromptSource: scenarioSource(value.systemPromptSource, "manifest.header.systemPromptSource") },
1444
+ ...value.toolSchemasSource === void 0 ? {} : { toolSchemasSource: scenarioSource(value.toolSchemasSource, "manifest.header.toolSchemasSource") },
1445
+ ...value.childSystemPrompts === void 0 ? {} : { childSystemPrompts: positiveIndexes(value.childSystemPrompts, "manifest.header.childSystemPrompts") },
1446
+ ...value.childToolSchemas === void 0 ? {} : { childToolSchemas: positiveIndexes(value.childToolSchemas, "manifest.header.childToolSchemas") },
1447
+ ...value.changes === void 0 ? {} : { changes: Number(value.changes) }
1448
+ };
1449
+ }
1450
+ let replay;
1451
+ if (root.replay !== void 0) {
1452
+ const value = record(root.replay, "manifest.replay");
1453
+ exactKeys(value, ["override"], "manifest.replay");
1454
+ if (value.override !== true) throw new Error("manifest.replay.override must equal true");
1455
+ replay = { override: true };
1456
+ }
1457
+ let platform;
1458
+ if (root.platform !== void 0) {
1459
+ if (typeof root.platform !== "string" || !PLATFORMS.has(root.platform)) throw new Error("manifest.platform must be posix or pwsh");
1460
+ platform = root.platform;
1461
+ }
1462
+ let permission;
1463
+ if (root.permission !== void 0) {
1464
+ if (typeof root.permission !== "string" || !PERMISSIONS.has(root.permission)) throw new Error("manifest.permission must be read-only, workspace-write, or danger-full-access");
1465
+ permission = root.permission;
1466
+ }
1467
+ let environment;
1468
+ if (root.environment !== void 0) {
1469
+ const value = record(root.environment, "manifest.environment");
1470
+ if (Object.entries(value).some(([key, item]) => !/^[A-Z][A-Z0-9_]*$/.test(key) || typeof item !== "string")) throw new Error("manifest.environment must map uppercase environment names to strings");
1471
+ environment = value;
1472
+ }
1473
+ let workspace;
1474
+ if (root.workspace !== void 0) {
1475
+ const value = record(root.workspace, "manifest.workspace");
1476
+ exactKeys(value, [
1477
+ "setup",
1478
+ "final",
1479
+ "parent"
1480
+ ], "manifest.workspace");
1481
+ if (value.final !== void 0 && value.final !== true) throw new Error("manifest.workspace.final must equal true when present");
1482
+ if (value.parent !== void 0 && value.parent !== "home") throw new Error("manifest.workspace.parent must equal home");
1483
+ workspace = {
1484
+ ...value.setup === void 0 ? {} : { setup: name(value.setup, "manifest.workspace.setup") },
1485
+ ...value.final === true ? { final: true } : {},
1486
+ ...value.parent === "home" ? { parent: "home" } : {}
1487
+ };
1488
+ if (Object.keys(workspace).length === 0) throw new Error("manifest.workspace must not be empty");
1489
+ }
1490
+ let input;
1491
+ if (root.input !== void 0) {
1492
+ const value = record(root.input, "manifest.input");
1493
+ exactKeys(value, ["task", "attachments"], "manifest.input");
1494
+ if (value.task !== void 0 && (typeof value.task !== "string" || value.task.trim() === "")) throw new Error("manifest.input.task must be a non-empty string when present");
1495
+ let attachments;
1496
+ if (value.attachments !== void 0) {
1497
+ if (!Array.isArray(value.attachments) || value.attachments.length === 0) throw new Error("manifest.input.attachments must be a non-empty array");
1498
+ attachments = value.attachments.map((item, index) => {
1499
+ const attachment = record(item, `manifest.input.attachments[${index}]`);
1500
+ exactKeys(attachment, [
1501
+ "id",
1502
+ "mediaType",
1503
+ "data"
1504
+ ], `manifest.input.attachments[${index}]`);
1505
+ if (typeof attachment.id !== "string" || !attachment.id.startsWith("sha256:")) throw new Error(`manifest.input.attachments[${index}].id must start with sha256:`);
1506
+ if (typeof attachment.mediaType !== "string" || !attachment.mediaType.includes("/")) throw new Error(`manifest.input.attachments[${index}].mediaType must be a MIME type`);
1507
+ if (typeof attachment.data !== "string" || attachment.data.length === 0) throw new Error(`manifest.input.attachments[${index}].data must be non-empty base64`);
1508
+ return {
1509
+ id: attachment.id,
1510
+ mediaType: attachment.mediaType,
1511
+ data: attachment.data
1512
+ };
1513
+ });
1514
+ if (new Set(attachments.map((attachment) => attachment.id)).size !== attachments.length) throw new Error("manifest.input.attachments must have unique ids");
1515
+ }
1516
+ if (value.task === void 0 && attachments === void 0) throw new Error("manifest.input must declare task or attachments");
1517
+ input = {
1518
+ ...value.task === void 0 ? {} : { task: value.task },
1519
+ ...attachments === void 0 ? {} : { attachments }
1520
+ };
1521
+ }
1522
+ let session;
1523
+ if (root.session !== void 0) {
1524
+ const value = record(root.session, "manifest.session");
1525
+ exactKeys(value, ["source"], "manifest.session");
1526
+ if (typeof value.source !== "string" || value.source.trim() === "") throw new Error("manifest.session.source must be a non-empty string");
1527
+ if (isAbsolute(value.source) || value.source.includes("\\") || value.source.includes("\0")) throw new Error("manifest.session.source must be a relative POSIX path");
1528
+ session = { source: value.source };
1529
+ }
1530
+ return {
1531
+ version: 1,
1532
+ ...scenario === void 0 ? {} : { scenario },
1533
+ profile: root.profile,
1534
+ ...composition === void 0 ? {} : { composition },
1535
+ ...recording === void 0 ? {} : { recording },
1536
+ ...header === void 0 ? {} : { header },
1537
+ ...replay === void 0 ? {} : { replay },
1538
+ ...platform === void 0 ? {} : { platform },
1539
+ ...permission === void 0 ? {} : { permission },
1540
+ ...environment === void 0 ? {} : { environment },
1541
+ ...workspace === void 0 ? {} : { workspace },
1542
+ ...input === void 0 ? {} : { input },
1543
+ ...session === void 0 ? {} : { session }
1544
+ };
1545
+ } catch (error) {
1546
+ /* v8 ignore next -- every parser and validator above throws Error instances. */
1547
+ throw new Error(`session-snapshot: ${path}: ${error instanceof Error ? error.message : String(error)}`);
1548
+ }
1549
+ }
1550
+ //#endregion
1551
+ //#region lib/types/suite.js
1552
+ /**
1553
+ * Keyless-by-default ACP snapshot suite factory. Each scenario drives the real
1554
+ * subprocess and compares normalized stdout; comparable session fixtures are
1555
+ * both replay input and expected output. Record mode refreshes reproducible
1556
+ * model scenarios from the live API, while refresh mode replays committed
1557
+ * scripts and rewrites derived artifacts without a key.
1558
+ * Replay scenarios run concurrently because each subprocess owns unique temp
1559
+ * cwd and persistence roots and reads only committed fixtures. Record and
1560
+ * refresh stay serial while writing.
1561
+ *
1562
+ * Exactly one scenario per header-composition class pins the tokenized header
1563
+ * sequence. Its prompt and tool-schema sequences live in independent
1564
+ * sidecars, each of which may be shared with another class pin when the bytes
1565
+ * are identical. Every live header is checked against the composed pin, so
1566
+ * session-dependent composition must declare a separate class instead of
1567
+ * escaping coverage.
1568
+ * @module @deepseek-ai/dsh-session-snapshot/suite
1569
+ */
1570
+ /** The readable system-prompt snapshot beside its owning header pin. */
1571
+ const SYSTEM_PROMPT_SNAPSHOT = "system-prompt.expected.md";
1572
+ /** The structured tool-schema snapshot beside its owning header pin. */
1573
+ const TOOL_SCHEMAS_SNAPSHOT = "tool-schemas.expected.json";
1574
+ /** Return the dedicated tool-schema sidecar for one child fixture index. */
1575
+ function childToolSchemasSnapshot(index) {
1576
+ return `tool-schemas.${index}.expected.json`;
1577
+ }
1578
+ /** Return the dedicated system-prompt sidecar for one child fixture index. */
1579
+ function childSystemPromptSnapshot(index) {
1580
+ return `system-prompt.${index}.expected.md`;
1581
+ }
1582
+ /** The optional full Windows-native stdout transcript. */
1583
+ const WINDOWS_STDOUT_SNAPSHOT = "stdout.expected.windows.jsonl";
1584
+ /** Stable session-log token standing in for the sidecar's initial schemas. */
1585
+ const TOOLS_TOKEN = "{{tools}}";
1586
+ const PACKED_CHUNK_ROW_TYPES = new Set([
1587
+ "text-chunks",
1588
+ "reasoning-chunks",
1589
+ "tool-call-chunks"
1590
+ ]);
1591
+ /** Canonical UUID spelling minted for ordinary message identities. */
1592
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1593
+ /**
1594
+ * Whether a scenario's run test is skipped for this mode and host: record mode
1595
+ * skips authored (non-`recorded`) scenarios, {@link Scenario.posixOnly}
1596
+ * scenarios skip on Windows, and {@link Scenario.pwshOnly} scenarios skip
1597
+ * when the caller's `hasPwsh` probe is false.
1598
+ *
1599
+ * @param scenario The scenario whose run test is being registered.
1600
+ * @param recording Whether the suite runs in record mode.
1601
+ * @param platform The running Node platform, injectable for unit coverage.
1602
+ * @param hasPwsh The caller's pwsh-availability probe; `pwshOnly` scenarios
1603
+ * skip unless it is true.
1604
+ * @returns True when the scenario's run test must not execute.
1605
+ */
1606
+ function scenarioSkipped(scenario, recording, platform = process.platform, hasPwsh) {
1607
+ if (recording && !scenario.recorded) return true;
1608
+ if (scenario.posixOnly === true && platform === "win32") return true;
1609
+ return scenario.pwshOnly === true && hasPwsh !== true;
1610
+ }
1611
+ /**
1612
+ * Select the shared stdout expected output plus any platform-native assertion declared by a scenario.
1613
+ *
1614
+ * @param scenario The scenario whose stdout contract is being selected.
1615
+ * @param platform The running Node platform, injectable for unit coverage.
1616
+ * @returns The ordered expected-output variants: shared canonical first, then optional Windows native.
1617
+ */
1618
+ function stdoutExpectedVariants(scenario, platform = process.platform) {
1619
+ const canonical = {
1620
+ file: "stdout.expected.jsonl",
1621
+ cwdPathMode: "canonical"
1622
+ };
1623
+ if (platform !== "win32" || scenario.pinsNativeWindowsStdout !== true) return [canonical];
1624
+ return [canonical, {
1625
+ file: WINDOWS_STDOUT_SNAPSHOT,
1626
+ cwdPathMode: "native"
1627
+ }];
1628
+ }
1629
+ /**
1630
+ * Record one scenario's generated content for a shared snapshot source.
1631
+ * A later claimant must generate identical bytes; otherwise record/refresh
1632
+ * would make the final file depend on scenario order.
1633
+ *
1634
+ * @param claims Claims already made in this suite run, keyed by source path.
1635
+ * @param source The shared snapshot path being claimed.
1636
+ * @param scenario The scenario generating the content.
1637
+ * @param content The complete content the scenario generated.
1638
+ * @returns Nothing.
1639
+ */
1640
+ function claimSharedSnapshot(claims, source, scenario, content) {
1641
+ const previous = claims.get(source);
1642
+ if (previous !== void 0 && previous.content !== content) throw new Error(`acp-snapshot: shared snapshot ${source} diverged between ${previous.scenario} and ${scenario}`);
1643
+ if (previous === void 0) claims.set(source, {
1644
+ scenario,
1645
+ content
1646
+ });
1647
+ }
1648
+ /**
1649
+ * Reject byte-identical committed snapshots stored under different paths.
1650
+ *
1651
+ * @param kind Human-readable snapshot kind for the diagnostic.
1652
+ * @param snapshots The committed files to compare.
1653
+ * @returns Nothing.
1654
+ */
1655
+ function assertUniqueSnapshotContents(kind, snapshots) {
1656
+ const firstPathByContent = /* @__PURE__ */ new Map();
1657
+ for (const snapshot of snapshots) {
1658
+ const firstPath = firstPathByContent.get(snapshot.content);
1659
+ if (firstPath !== void 0) throw new Error(`acp-snapshot: identical ${kind} snapshots appear in ${firstPath} and ${snapshot.path}; reuse one source`);
1660
+ firstPathByContent.set(snapshot.content, snapshot.path);
1661
+ }
1662
+ }
1663
+ /**
1664
+ * Validate and order a scenario directory's session-fixture filenames.
1665
+ *
1666
+ * The primary fixture is always `session.jsonl`; child sessions are discovered
1667
+ * from contiguous `session.1.jsonl` … filenames. The directory is the source of
1668
+ * truth, so scenario tables do not duplicate a child count that can drift from
1669
+ * the files. A session-like JSONL with any other suffix fails loud.
1670
+ *
1671
+ * @param names File names in one scenario directory.
1672
+ * @returns The primary and child fixture names in replay/harvest order.
1673
+ */
1674
+ function sessionFixtureNames(names) {
1675
+ if (!names.includes("session.jsonl")) throw new Error("missing session.jsonl");
1676
+ const children = [];
1677
+ for (const name of names) {
1678
+ if (name === "session.jsonl") continue;
1679
+ if (!name.startsWith("session.") || !name.endsWith(".jsonl")) continue;
1680
+ const match = /^session\.([1-9]\d*)\.jsonl$/.exec(name);
1681
+ if (match === null) throw new Error(`invalid child session fixture name: ${name}`);
1682
+ children.push({
1683
+ name,
1684
+ index: Number(match[1])
1685
+ });
1686
+ }
1687
+ children.sort((a, b) => a.index - b.index);
1688
+ for (const [offset, child] of children.entries()) {
1689
+ const expected = offset + 1;
1690
+ if (child.index !== expected) throw new Error(`child session fixtures must be contiguous: expected session.${expected}.jsonl, found ${child.name}`);
1691
+ }
1692
+ return ["session.jsonl", ...children.map((child) => child.name)];
1693
+ }
1694
+ /** Read one scenario directory's validated session-fixture inventory. */
1695
+ async function sessionFixtures(dir) {
1696
+ return sessionFixtureNames((await readdir(dir, { withFileTypes: true })).filter((entry) => entry.isFile()).map((entry) => entry.name));
1697
+ }
1698
+ /**
1699
+ * Derive normalization values from a fixture's own session header. Recorded ids and cwd differ
1700
+ * from the live replay run; the non-empty sentinel for missing cwd avoids accidental empty-
1701
+ * string replacement.
1702
+ *
1703
+ * @param fixture The committed `session.jsonl` content.
1704
+ * @returns The fixture's own volatile values, ready for {@link normalizeSessionLog}.
1705
+ */
1706
+ function fixtureContext(fixture) {
1707
+ const firstLine = fixture.split("\n").find((line) => line.trim().length > 0) ?? "{}";
1708
+ const header = JSON.parse(firstLine);
1709
+ return {
1710
+ sessionIds: typeof header.id === "string" ? [header.id] : [],
1711
+ cwd: typeof header.cwd === "string" ? header.cwd : "\0no-cwd\0"
1712
+ };
1713
+ }
1714
+ /** Normalize request-header payloads while retaining the reason that selects a pin revision. */
1715
+ function normalizedHeaderEvents(rawLog, ctx) {
1716
+ return normalizeSessionLog(rawLog, ctx).split("\n").filter((line) => line.trim().length > 0).map((line) => JSON.parse(line)).filter((record) => record.type === "request/header").map((record) => ({
1717
+ header: record.data?.header,
1718
+ reason: record.data?.reason
1719
+ }));
1720
+ }
1721
+ /**
1722
+ * Header revisions that own sidecar content. `series` reuses the current revision, while
1723
+ * `resume` owns sidecars because its full snapshot may drift across the process boundary.
1724
+ * Pinning fixtures therefore cover one loop instance; a mid-log `resume` fails their
1725
+ * pin-count invariant.
1726
+ */
1727
+ function pinningHeaderPayloads(rawLog, ctx) {
1728
+ return normalizedHeaderEvents(rawLog, ctx).filter((event) => event.reason !== "series").map((event) => event.header);
1729
+ }
1730
+ /** Extract every string system prompt from a normalized header sequence. */
1731
+ function systemPromptsFrom(headers) {
1732
+ return headers.flatMap((header) => {
1733
+ if (header === null || typeof header !== "object") return [];
1734
+ const system = header.system;
1735
+ return typeof system === "string" ? [system] : [];
1736
+ });
1737
+ }
1738
+ /** Extract every array-valued tool catalog from a normalized header sequence. */
1739
+ function toolSchemasFrom(headers) {
1740
+ return headers.flatMap((header) => {
1741
+ if (header === null || typeof header !== "object") return [];
1742
+ const tools = header.tools;
1743
+ return Array.isArray(tools) ? [tools] : [];
1744
+ });
1745
+ }
1746
+ /**
1747
+ * The `data.header` payload of every `request/header` event in a session
1748
+ * JSONL, in log order, with the log's volatile values scrubbed first
1749
+ * ({@link normalizeSessionLog}) so headers harvested from different runs —
1750
+ * each embedding its own generated cwd in the composed prompt — compare on equal
1751
+ * footing.
1752
+ *
1753
+ * @param rawLog The session `.jsonl` content to extract headers from.
1754
+ * @param ctx The volatile values of the run that produced it.
1755
+ * @returns The normalized `data.header` payloads, in log order.
1756
+ */
1757
+ function normalizedHeaders(rawLog, ctx) {
1758
+ return normalizedHeaderEvents(rawLog, ctx).map((event) => event.header);
1759
+ }
1760
+ /**
1761
+ * The normalized string-valued system prompts carried by request headers in a
1762
+ * session JSONL, in log order. Headers without a string prompt are omitted so
1763
+ * callers can assert one prompt per header explicitly.
1764
+ *
1765
+ * @param rawLog The session `.jsonl` content to inspect.
1766
+ * @param ctx The volatile values of the run that produced it.
1767
+ * @returns The normalized system prompts, in header order.
1768
+ */
1769
+ function normalizedSystemPrompts(rawLog, ctx) {
1770
+ return systemPromptsFrom(normalizedHeaders(rawLog, ctx));
1771
+ }
1772
+ /**
1773
+ * The normalized tool-schema arrays carried by request headers in a session
1774
+ * JSONL, in log order. Headers without an array-valued tools field are omitted
1775
+ * so callers can assert one schema set per header explicitly.
1776
+ *
1777
+ * @param rawLog The session `.jsonl` content to inspect.
1778
+ * @param ctx The volatile values of the run that produced it.
1779
+ * @returns The normalized initial tool-schema arrays, in header order.
1780
+ */
1781
+ function normalizedToolSchemas(rawLog, ctx) {
1782
+ return toolSchemasFrom(normalizedHeaders(rawLog, ctx));
1783
+ }
1784
+ /**
1785
+ * Render the full tool-schema sequence as canonical, readable JSON.
1786
+ *
1787
+ * @param initial The pinned request header's complete tool schemas.
1788
+ * @param changes Complete tool schemas from later changed headers.
1789
+ * @returns A pretty-printed JSON snapshot ending in one newline.
1790
+ */
1791
+ function formatToolSchemasSnapshot(initial, changes = []) {
1792
+ return `${JSON.stringify({
1793
+ initial,
1794
+ changes
1795
+ }, null, 2)}\n`;
1796
+ }
1797
+ /**
1798
+ * Parse and validate the stable top-level fields of a tool-schema sidecar.
1799
+ *
1800
+ * @param snapshot The JSON sidecar text.
1801
+ * @returns Its initial and changed-header schema sets.
1802
+ */
1803
+ function parseToolSchemasSnapshot(snapshot) {
1804
+ const parsed = JSON.parse(snapshot);
1805
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("acp-snapshot: tool-schema snapshot must be an object");
1806
+ const { initial, changes } = parsed;
1807
+ if (!Array.isArray(initial) || !Array.isArray(changes) || !changes.every(Array.isArray)) throw new Error("acp-snapshot: tool-schema snapshot must carry array-valued initial and changes fields");
1808
+ return {
1809
+ initial,
1810
+ changes
1811
+ };
1812
+ }
1813
+ /**
1814
+ * Restore one sidecar schema set into a tokenized pinned header.
1815
+ *
1816
+ * @param header The parsed request header carrying `tools: "{{tools}}"`.
1817
+ * @param schemas The complete schemas for this full header snapshot.
1818
+ * @returns A copy of the header with its complete schemas restored.
1819
+ */
1820
+ function restorePinnedToolSchemas(header, schemas) {
1821
+ if (header === null || typeof header !== "object" || Array.isArray(header)) throw new Error("acp-snapshot: pinned request header must be an object");
1822
+ if (header.tools !== TOOLS_TOKEN) throw new Error(`acp-snapshot: pinned request header tools must equal ${TOOLS_TOKEN}`);
1823
+ return {
1824
+ ...header,
1825
+ tools: schemas
1826
+ };
1827
+ }
1828
+ /**
1829
+ * Render a normalized prompt as a repository-friendly Markdown snapshot.
1830
+ * Prompt text is unchanged except that a missing terminal newline is added so
1831
+ * the committed file follows the repository newline contract.
1832
+ *
1833
+ * @param prompt The normalized system prompt.
1834
+ * @param changes Full normalized prompts from later changed-header snapshots.
1835
+ * @returns Markdown snapshot text ending in a newline.
1836
+ */
1837
+ function formatSystemPromptSnapshot(prompt, changes = []) {
1838
+ let snapshot = prompt.endsWith("\n") ? prompt : `${prompt}\n`;
1839
+ for (const [index, change] of changes.entries()) {
1840
+ snapshot += `\n<!-- request/header change ${index + 1} -->\n\n`;
1841
+ snapshot += change.endsWith("\n") ? change : `${change}\n`;
1842
+ }
1843
+ return snapshot;
1844
+ }
1845
+ /**
1846
+ * Reject a child prompt sidecar that cannot own distinct, canonical prompt text.
1847
+ * @param sidecar - committed child prompt snapshot.
1848
+ * @param classPin - initial prompt snapshot owned by the scenario's header class.
1849
+ * @param label - repository-relative fixture label for diagnostics.
1850
+ */
1851
+ function assertChildSystemPromptSnapshot(sidecar, classPin, label) {
1852
+ if (sidecar.trim().length === 0) throw new Error(`${label} must pin a non-empty prompt`);
1853
+ if (!sidecar.endsWith("\n")) throw new Error(`${label} must end in a newline`);
1854
+ if (sidecar === classPin) throw new Error(`${label} must differ from its class pin`);
1855
+ }
1856
+ /** Return the initial-prompt portion of a possibly multi-header snapshot. */
1857
+ function initialSystemPromptSnapshot(snapshot) {
1858
+ const marker = snapshot.indexOf("\n<!-- request/header change ");
1859
+ return marker < 0 ? snapshot : snapshot.slice(0, marker);
1860
+ }
1861
+ /**
1862
+ * Count changed `request/header` snapshots in a session JSONL.
1863
+ *
1864
+ * @param rawLog The session `.jsonl` content.
1865
+ * @returns How many headers carry reason `change`.
1866
+ */
1867
+ function headerChangeCount(rawLog) {
1868
+ return rawLog.split("\n").filter((line) => line.trim().length > 0).filter((line) => {
1869
+ const record = JSON.parse(line);
1870
+ return record.type === "request/header" && record.data?.reason === "change";
1871
+ }).length;
1872
+ }
1873
+ function parseJsonlRecords(text) {
1874
+ return text.split("\n").filter((line) => line.trim().length > 0).map((line) => JSON.parse(line));
1875
+ }
1876
+ /** Narrow one parsed value to the complete identified-message shape retained by fixtures. */
1877
+ function completeMessage(value) {
1878
+ if (!isRecord(value) || typeof value.id !== "string" || !UUID_RE.test(value.id) || typeof value.role !== "string" || !Array.isArray(value.content) || !isRecord(value.source)) return void 0;
1879
+ return value;
1880
+ }
1881
+ /** Return the complete identified message carried by one surface event. */
1882
+ function surfaceEventMessage(record) {
1883
+ const type = record.type;
1884
+ if (typeof type !== "string" || !isSurfaceEligibleType(type)) return void 0;
1885
+ const data = record.data;
1886
+ if (!isRecord(data)) return void 0;
1887
+ let message;
1888
+ switch (type) {
1889
+ case "user/message":
1890
+ message = data;
1891
+ break;
1892
+ case "assistant/message":
1893
+ case "tool/result":
1894
+ message = data.message;
1895
+ break;
1896
+ /* v8 ignore next -- the authoritative predicate must fail loud when a new surface shape lands. */
1897
+ default: throw new Error(`acp-snapshot: unsupported surface event type "${type}"`);
1898
+ }
1899
+ return completeMessage(message);
1900
+ }
1901
+ /** Return complete message identities structurally owned by one durable record. */
1902
+ function recordMessages(record) {
1903
+ const surfaceMessage = surfaceEventMessage(record);
1904
+ if (surfaceMessage !== void 0) return [surfaceMessage];
1905
+ if (record.type !== "agent/inbox/spliced" || !isRecord(record.data) || !Array.isArray(record.data.inserted)) return [];
1906
+ return record.data.inserted.flatMap((value) => {
1907
+ const message = completeMessage(value);
1908
+ return message === void 0 ? [] : [message];
1909
+ });
1910
+ }
1911
+ /** Serialize parsed JSON by value rather than insertion order. */
1912
+ function canonicalJson(value) {
1913
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
1914
+ if (isRecord(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
1915
+ return JSON.stringify(value);
1916
+ }
1917
+ /** Index identity-free message values whose ID and fingerprint are mutually unique. */
1918
+ function uniqueMessageIds(logs) {
1919
+ const fingerprintsById = /* @__PURE__ */ new Map();
1920
+ const idsByFingerprint = /* @__PURE__ */ new Map();
1921
+ for (const log of logs) for (const record of parseJsonlRecords(log)) for (const message of recordMessages(record)) {
1922
+ const { id, ...withoutId } = message;
1923
+ const messageId = id;
1924
+ const fingerprint = canonicalJson(withoutId);
1925
+ const fingerprints = fingerprintsById.get(messageId);
1926
+ if (fingerprints === void 0) fingerprintsById.set(messageId, new Set([fingerprint]));
1927
+ else fingerprints.add(fingerprint);
1928
+ const ids = idsByFingerprint.get(fingerprint);
1929
+ if (ids === void 0) idsByFingerprint.set(fingerprint, new Set([messageId]));
1930
+ else ids.add(messageId);
1931
+ }
1932
+ const unique = /* @__PURE__ */ new Map();
1933
+ for (const [id, fingerprints] of fingerprintsById) {
1934
+ if (fingerprints.size !== 1) continue;
1935
+ const fingerprint = fingerprints.values().next().value;
1936
+ if (idsByFingerprint.get(fingerprint)?.size !== 1) continue;
1937
+ unique.set(fingerprint, id);
1938
+ }
1939
+ return unique;
1940
+ }
1941
+ /**
1942
+ * Match unchanged complete messages across a scenario's fresh and existing logs.
1943
+ * New, changed, duplicate-content, or otherwise ambiguous messages keep their fresh ids.
1944
+ */
1945
+ function fixtureMessageIdReplacements(logs, fixtures) {
1946
+ const freshIds = uniqueMessageIds(logs);
1947
+ const existingIds = uniqueMessageIds(fixtures);
1948
+ const replacements = /* @__PURE__ */ new Map();
1949
+ for (const [fingerprint, fresh] of freshIds) {
1950
+ const existing = existingIds.get(fingerprint);
1951
+ if (existing === void 0 || fresh === existing) continue;
1952
+ replacements.set(fresh, existing);
1953
+ }
1954
+ return replacements;
1955
+ }
1956
+ /** Apply literal fixture replacements without changing any other fresh value. */
1957
+ function applyFixtureReplacements(content, replacements) {
1958
+ let stable = content;
1959
+ for (const { from, to } of replacements) stable = stable.split(from).join(to);
1960
+ return stable;
1961
+ }
1962
+ /** Rewrite only validated durable-message ID fields, leaving every other occurrence untouched. */
1963
+ function applyFixtureMessageIds(content, replacements) {
1964
+ return content.split("\n").map((line) => {
1965
+ if (line.trim().length === 0) return line;
1966
+ const record = JSON.parse(line);
1967
+ let changed = false;
1968
+ for (const message of recordMessages(record)) {
1969
+ const replacement = replacements.get(message.id);
1970
+ if (replacement === void 0) continue;
1971
+ message.id = replacement;
1972
+ changed = true;
1973
+ }
1974
+ return changed ? JSON.stringify(record) : line;
1975
+ }).join("\n");
1976
+ }
1977
+ /**
1978
+ * Carry committed UUIDs into unchanged, unambiguous messages in fresh session fixtures.
1979
+ *
1980
+ * @param logs Fresh fixture-ready session JSONL contents for one scenario.
1981
+ * @param fixtures Existing fixture contents in matching order; missing fixtures may be empty strings.
1982
+ * @returns The fresh contents with only reusable message UUIDs replaced.
1983
+ */
1984
+ function stabilizeFixtureMessageIds(logs, fixtures) {
1985
+ const replacements = fixtureMessageIdReplacements(logs, fixtures);
1986
+ return logs.map((log) => applyFixtureMessageIds(log, replacements));
1987
+ }
1988
+ /** One packed row's member times, or `undefined` for an ordinary record. */
1989
+ function packedTimes(record) {
1990
+ if (!PACKED_CHUNK_ROW_TYPES.has(record.type)) return void 0;
1991
+ const row = record;
1992
+ const times = [row.time0 ?? 0];
1993
+ for (const gap of row.data.dt) times.push(times[times.length - 1] + gap);
1994
+ return times;
1995
+ }
1996
+ /** Expand packed timing envelopes so refresh alignment follows logical events, not physical lines. */
1997
+ function logicalRecords(records) {
1998
+ return records.flatMap((record) => {
1999
+ const times = packedTimes(record);
2000
+ return times === void 0 ? [record] : times.map((time) => ({
2001
+ type: "assistant/chunk",
2002
+ time
2003
+ }));
2004
+ });
2005
+ }
2006
+ /**
2007
+ * Find tool calls whose structured result reports `UNKNOWN_TOOL`.
2008
+ *
2009
+ * Snapshot refresh must not turn a missing registration into accepted behavior;
2010
+ * intentional unknown-tool behavior belongs in a focused unit or e2e test.
2011
+ *
2012
+ * @param rawLog The session JSONL to inspect.
2013
+ * @returns The failing call ids in log order, using a diagnostic placeholder when absent.
2014
+ */
2015
+ function unknownToolCallIds(rawLog) {
2016
+ return parseJsonlRecords(rawLog).flatMap((record) => {
2017
+ if (record.type !== "tool/result") return [];
2018
+ const data = record.data;
2019
+ if (data === null || typeof data !== "object") return [];
2020
+ const { message, error } = data;
2021
+ if (error === null || typeof error !== "object") return [];
2022
+ if (error.code !== "UNKNOWN_TOOL") return [];
2023
+ const source = typeof message === "object" && message !== null ? message.source : void 0;
2024
+ const callId = typeof source === "object" && source !== null ? source.callId : void 0;
2025
+ return [typeof callId === "string" ? callId : "<missing callId>"];
2026
+ });
2027
+ }
2028
+ /**
2029
+ * Build refresh write-back replacements for per-log session ids, cwd values,
2030
+ * and spill paths. Durable message ids have a later structural owner.
2031
+ *
2032
+ * @param logs The freshly harvested logs, in fixture order.
2033
+ * @param fixtures The existing fixture contents, in matching order.
2034
+ * @returns Literal replacements from fresh values to the fixture's existing values.
2035
+ */
2036
+ function refreshFixtureReplacements(logs, fixtures) {
2037
+ const replacements = [];
2038
+ for (let i = 0; i < logs.length; i++) {
2039
+ const fresh = parseJsonlRecords(logs[i].content)[0];
2040
+ const existing = parseJsonlRecords(fixtures[i] ?? "")[0];
2041
+ for (const field of ["id", "cwd"]) {
2042
+ const from = fresh?.[field];
2043
+ const to = existing?.[field];
2044
+ if (typeof from === "string" && typeof to === "string" && from.length > 0 && from !== to) replacements.push({
2045
+ from,
2046
+ to
2047
+ });
2048
+ }
2049
+ const freshSpills = extractSnapshotSpillPaths(logs[i].content);
2050
+ const existingSpills = extractSnapshotSpillPaths(fixtures[i] ?? "");
2051
+ for (const [name, existingPath] of existingSpills) {
2052
+ const freshPath = freshSpills.get(name);
2053
+ if (freshPath !== void 0 && freshPath !== existingPath) replacements.push({
2054
+ from: freshPath,
2055
+ to: existingPath
2056
+ });
2057
+ }
2058
+ }
2059
+ return replacements;
2060
+ }
2061
+ function preserveFixtureVolatiles(record, existing) {
2062
+ if (existing === void 0 || existing.type !== record.type) return;
2063
+ if (record.type === "session") {
2064
+ for (const field of [
2065
+ "id",
2066
+ "createdAt",
2067
+ "cwd",
2068
+ "parentSession"
2069
+ ]) if (field in record && field in existing) record[field] = existing[field];
2070
+ return;
2071
+ }
2072
+ if ("time" in record && "time" in existing) record.time = existing.time;
2073
+ if (record.type !== "hook/result") return;
2074
+ const data = record.data;
2075
+ const existingData = existing.data;
2076
+ if (data !== null && typeof data === "object" && existingData !== null && typeof existingData === "object" && "durationMs" in data && "durationMs" in existingData) data.durationMs = existingData.durationMs;
2077
+ }
2078
+ /** Carry logical member times into a fresh packed row while leaving its fragment arrays untouched. */
2079
+ function preservePackedMemberTimes(record, existingMembers) {
2080
+ if (!PACKED_CHUNK_ROW_TYPES.has(record.type)) return;
2081
+ const row = record;
2082
+ const firstTime = existingMembers[0]?.time;
2083
+ if (!Number.isSafeInteger(firstTime)) return;
2084
+ row.time0 = firstTime;
2085
+ if (existingMembers.length !== row.data.dt.length + 1) return;
2086
+ const times = existingMembers.map((member) => Number.isSafeInteger(member.time) ? member.time : void 0);
2087
+ if (times.some((time) => time === void 0)) return;
2088
+ const memberTimes = times;
2089
+ const gaps = memberTimes.slice(1).map((time, index) => time - memberTimes[index]);
2090
+ if (gaps.some((gap) => !Number.isSafeInteger(gap))) return;
2091
+ row.data.dt = gaps;
2092
+ }
2093
+ /** Whether a parsed JSON value is a non-array object. */
2094
+ function isRecord(value) {
2095
+ return value !== null && typeof value === "object" && !Array.isArray(value);
2096
+ }
2097
+ /**
2098
+ * Reuse existing leaves whose normalized values equal the fresh values.
2099
+ * Objects merge by key; arrays merge only when their positions still align.
2100
+ */
2101
+ function preserveNormalizedVolatiles(fresh, existing, normalizedFresh, normalizedExisting, stringMappings) {
2102
+ if (Array.isArray(fresh) && Array.isArray(existing) && Array.isArray(normalizedFresh) && Array.isArray(normalizedExisting)) {
2103
+ if (fresh.length !== existing.length || fresh.length !== normalizedFresh.length || fresh.length !== normalizedExisting.length) return fresh;
2104
+ return fresh.map((value, index) => preserveNormalizedVolatiles(value, existing[index], normalizedFresh[index], normalizedExisting[index], stringMappings));
2105
+ }
2106
+ if (isRecord(fresh) && isRecord(existing) && isRecord(normalizedFresh) && isRecord(normalizedExisting)) return Object.fromEntries(Object.entries(fresh).map(([key, value]) => [key, Object.hasOwn(existing, key) && Object.hasOwn(normalizedFresh, key) && Object.hasOwn(normalizedExisting, key) ? preserveNormalizedVolatiles(value, existing[key], normalizedFresh[key], normalizedExisting[key], stringMappings) : value]));
2107
+ if (typeof fresh === "string" && typeof existing === "string" && typeof normalizedFresh === "string" && normalizedFresh === normalizedExisting) return stringMappings.get(JSON.stringify([normalizedFresh, fresh])) === existing ? existing : fresh;
2108
+ return Object.is(normalizedFresh, normalizedExisting) ? existing : fresh;
2109
+ }
2110
+ /** Normalize one aligned record with the same contract used by fixture comparison. */
2111
+ function normalizedRefreshRecord(record, context) {
2112
+ return JSON.parse(normalizeSessionLog(`${JSON.stringify(record)}\n`, context));
2113
+ }
2114
+ /**
2115
+ * Add normalized-equivalent string replacements to a bijection.
2116
+ * Structural differences are fresh-owned and therefore contribute no mapping.
2117
+ */
2118
+ function collectNormalizedStringMappings(fresh, existing, normalizedFresh, normalizedExisting, excludedStrings, forward, reverse) {
2119
+ if (Array.isArray(fresh) && Array.isArray(existing) && Array.isArray(normalizedFresh) && Array.isArray(normalizedExisting)) {
2120
+ if (fresh.length !== existing.length || fresh.length !== normalizedFresh.length || fresh.length !== normalizedExisting.length) return true;
2121
+ return fresh.every((value, index) => collectNormalizedStringMappings(value, existing[index], normalizedFresh[index], normalizedExisting[index], excludedStrings, forward, reverse));
2122
+ }
2123
+ if (isRecord(fresh) && isRecord(existing) && isRecord(normalizedFresh) && isRecord(normalizedExisting)) return Object.entries(fresh).every(([key, value]) => !Object.hasOwn(existing, key) || !Object.hasOwn(normalizedFresh, key) || !Object.hasOwn(normalizedExisting, key) || collectNormalizedStringMappings(value, existing[key], normalizedFresh[key], normalizedExisting[key], excludedStrings, forward, reverse));
2124
+ if (typeof fresh !== "string" || typeof existing !== "string" || typeof normalizedFresh !== "string" || normalizedFresh !== normalizedExisting || fresh === existing || excludedStrings.has(fresh) || excludedStrings.has(existing)) return true;
2125
+ const freshKey = JSON.stringify([normalizedFresh, fresh]);
2126
+ const existingKey = JSON.stringify([normalizedFresh, existing]);
2127
+ const mappedExisting = forward.get(freshKey);
2128
+ const mappedFresh = reverse.get(existingKey);
2129
+ if (mappedExisting !== void 0 && mappedExisting !== existing || mappedFresh !== void 0 && mappedFresh !== fresh) return false;
2130
+ forward.set(freshKey, existing);
2131
+ reverse.set(existingKey, fresh);
2132
+ return true;
2133
+ }
2134
+ /**
2135
+ * Build a log-wide bijection for normalized-equivalent strings.
2136
+ * Any unexplained record mismatch or conflicting replacement disables reuse.
2137
+ */
2138
+ function normalizedStringMappings(records, freshRecords, existingRecords, freshContext, existingContext) {
2139
+ const excludedStrings = /* @__PURE__ */ new Set();
2140
+ for (const record of [...freshRecords, ...existingRecords]) for (const message of recordMessages(record)) excludedStrings.add(message.id);
2141
+ const forward = /* @__PURE__ */ new Map();
2142
+ const reverse = /* @__PURE__ */ new Map();
2143
+ let existingIndex = 0;
2144
+ for (let recordIndex = 0; recordIndex < records.length; recordIndex++) {
2145
+ const record = records[recordIndex];
2146
+ const existingRecord = existingRecords[existingIndex];
2147
+ const memberCount = packedTimes(record)?.length ?? 1;
2148
+ if (record.type === "session/title" && existingRecord?.type !== "session/title") continue;
2149
+ if (memberCount > 1) {
2150
+ const existingMembers = existingRecords.slice(existingIndex, existingIndex + memberCount);
2151
+ if (existingMembers.length !== memberCount || existingMembers.some((member) => member.type !== "assistant/chunk")) return void 0;
2152
+ } else {
2153
+ if (existingRecord === void 0 || existingRecord.type !== record.type) return void 0;
2154
+ if (!collectNormalizedStringMappings(record, existingRecord, normalizedRefreshRecord(freshRecords[recordIndex], freshContext), normalizedRefreshRecord(existingRecord, existingContext), excludedStrings, forward, reverse)) return void 0;
2155
+ }
2156
+ existingIndex += memberCount;
2157
+ }
2158
+ return existingIndex === existingRecords.length ? forward : void 0;
2159
+ }
2160
+ /**
2161
+ * Rewrite a fresh replay-produced log so repeated refreshes do not churn
2162
+ * volatile fixture fields. Meaningful event payloads come from `fresh`; the
2163
+ * existing fixture lends normalized-equivalent values, including non-message ids, paths,
2164
+ * creation/event times, spill locators, and hook durations, only when the
2165
+ * complete record layout aligns and volatile strings form a consistent
2166
+ * bijection. Complete durable-message ids are excluded because the later
2167
+ * fixture-ready structural pass owns them. Ambiguous layouts or mappings
2168
+ * keep fresh strings. Packed timing gaps expand from zero when a projected
2169
+ * fixture omits `time0`, so packing does not shift later records;
2170
+ * fresh semantic values and fragment arrays remain authoritative.
2171
+ *
2172
+ * @param fresh The newly harvested session JSONL.
2173
+ * @param existing The committed fixture JSONL being refreshed.
2174
+ * @param replacements Cross-log literal replacements from {@link refreshFixtureReplacements}.
2175
+ * @param freshContext The harvested run's ids, cwd, and every cwd alias.
2176
+ * @returns The stabilized JSONL content to write back.
2177
+ */
2178
+ function stabilizeRefreshLog(fresh, existing, replacements, freshContext) {
2179
+ const freshRecords = parseJsonlRecords(fresh);
2180
+ const stable = applyFixtureReplacements(fresh, replacements);
2181
+ const existingRecords = logicalRecords(parseJsonlRecords(existing));
2182
+ const records = parseJsonlRecords(stable);
2183
+ const existingContext = fixtureContext(existing);
2184
+ const stringMappings = normalizedStringMappings(records, freshRecords, existingRecords, freshContext, existingContext);
2185
+ let existingIndex = 0;
2186
+ let previousEventTime;
2187
+ for (let i = 0; i < records.length; i++) {
2188
+ let record = records[i];
2189
+ const existingRecord = existingRecords[existingIndex];
2190
+ const memberCount = packedTimes(record)?.length ?? 1;
2191
+ if (record.type === "session/title" && existingRecord?.type !== "session/title") {
2192
+ /* v8 ignore next -- a title is turn-enclosed, so a preceding event time exists in every valid fixture. */
2193
+ if (typeof previousEventTime !== "number") throw new Error("acp-snapshot: inserted title has no preceding event time");
2194
+ record.time = previousEventTime;
2195
+ } else {
2196
+ if (stringMappings !== void 0 && memberCount === 1 && existingRecord !== void 0 && existingRecord.type === record.type) {
2197
+ record = preserveNormalizedVolatiles(record, existingRecord, normalizedRefreshRecord(freshRecords[i], freshContext), normalizedRefreshRecord(existingRecord, existingContext), stringMappings);
2198
+ records[i] = record;
2199
+ }
2200
+ preservePackedMemberTimes(record, existingRecords.slice(existingIndex, existingIndex + memberCount));
2201
+ preserveFixtureVolatiles(record, existingRecord);
2202
+ existingIndex += memberCount;
2203
+ }
2204
+ if (typeof record.time === "number") previousEventTime = record.time;
2205
+ }
2206
+ return records.map((record) => JSON.stringify(record)).join("\n") + "\n";
2207
+ }
2208
+ /**
2209
+ * Register the suite: one test per scenario (the expected-output and log comparisons and
2210
+ * the header-uniformity guard) plus the fixture guard block (no orphan
2211
+ * scenario dirs, required files present, exactly one pin per header class,
2212
+ * shared sidecars unique and well-formed, every JSONL prompt-scrubbed,
2213
+ * non-pinning fixtures fully header-scrubbed). Must
2214
+ * run at vitest collection time — it calls `describe`/`it`. Throws
2215
+ * immediately if any header class lacks a pinning scenario or carries two
2216
+ * (the uniformity guard needs exactly one comparison anchor per class).
2217
+ *
2218
+ * @param options The agent, snapshots directory, scenario table, and mode.
2219
+ */
2220
+ function defineAcpSnapshotSuite(options) {
2221
+ const { agent, snapshotsDir, scenarios, mode } = options;
2222
+ const RECORDING = mode === "record";
2223
+ const REFRESHING = mode === "refresh";
2224
+ const childMode = RECORDING ? "record" : "replay";
2225
+ const scenarioSuite = mode === "replay" ? describe.concurrent : describe;
2226
+ /** The class a scenario's header composition belongs to (see {@link Scenario.headerClass}). */
2227
+ const classOf = (scenario) => scenario.headerClass ?? "default";
2228
+ const scenariosByName = /* @__PURE__ */ new Map();
2229
+ for (const scenario of scenarios) {
2230
+ if (scenariosByName.has(scenario.name)) throw new Error(`acp-snapshot: duplicate scenario name "${scenario.name}"`);
2231
+ scenariosByName.set(scenario.name, scenario);
2232
+ for (const field of ["systemPromptSource", "toolSchemasSource"]) if (scenario[field] !== void 0 && scenario.pinsHeader !== true) throw new Error(`acp-snapshot: ${scenario.name}.${field} is only valid on a header-pinning scenario`);
2233
+ }
2234
+ /** Each header class's single pinning scenario. Guarded here (and by meta-tests) so a pin cannot silently vanish or split. */
2235
+ const pinningByClass = /* @__PURE__ */ new Map();
2236
+ for (const scenario of scenarios) {
2237
+ if (scenario.pinsHeader !== true) continue;
2238
+ const cls = classOf(scenario);
2239
+ const existing = pinningByClass.get(cls);
2240
+ if (existing) throw new Error(`acp-snapshot: header class "${cls}" pinned by both ${existing.name} and ${scenario.name}`);
2241
+ pinningByClass.set(cls, scenario);
2242
+ }
2243
+ for (const scenario of scenarios) if (!pinningByClass.has(classOf(scenario))) throw new Error(`acp-snapshot: no scenario pins the request-header content of class "${classOf(scenario)}" (needed by ${scenario.name})`);
2244
+ const sourceFor = (pinningScenario, field, label) => {
2245
+ const sourceName = pinningScenario[field] ?? pinningScenario.name;
2246
+ const source = scenariosByName.get(sourceName);
2247
+ if (source === void 0) throw new Error(`acp-snapshot: ${pinningScenario.name} names unknown ${label} source "${sourceName}"`);
2248
+ if (source.pinsHeader !== true) throw new Error(`acp-snapshot: ${pinningScenario.name} names non-pinning ${label} source "${sourceName}"`);
2249
+ if (source[field] !== void 0 && source[field] !== source.name) throw new Error(`acp-snapshot: ${pinningScenario.name} names ${label} source "${sourceName}", which does not own its sidecar`);
2250
+ const expectedChanges = pinningScenario.expectedHeaderChanges ?? 0;
2251
+ if ((source.expectedHeaderChanges ?? 0) !== expectedChanges) throw new Error(`acp-snapshot: ${pinningScenario.name} and ${sourceName} declare different header-change counts for shared ${label}`);
2252
+ return source;
2253
+ };
2254
+ const promptSourceByClass = /* @__PURE__ */ new Map();
2255
+ const schemaSourceByClass = /* @__PURE__ */ new Map();
2256
+ for (const [cls, pinningScenario] of pinningByClass) {
2257
+ promptSourceByClass.set(cls, sourceFor(pinningScenario, "systemPromptSource", "system-prompt snapshot"));
2258
+ schemaSourceByClass.set(cls, sourceFor(pinningScenario, "toolSchemasSource", "tool-schema snapshot"));
2259
+ }
2260
+ const promptOwners = new Set([...promptSourceByClass.values()].map((source) => source.name));
2261
+ const schemaOwners = new Set([...schemaSourceByClass.values()].map((source) => source.name));
2262
+ const promptClaims = /* @__PURE__ */ new Map();
2263
+ const schemaClaims = /* @__PURE__ */ new Map();
2264
+ scenarioSuite("snapshot scenarios", () => {
2265
+ for (const scenario of scenarios) it.skipIf(scenarioSkipped(scenario, RECORDING, process.platform, options.hasPwsh))(`snapshot: ${scenario.name} matches the expected outputs`, async ({ expect }) => {
2266
+ const dir = join(snapshotsDir, scenario.name);
2267
+ const manifestPath = join(dir, "snapshot.yml");
2268
+ const manifest = parseSnapshotManifest(await readFile(manifestPath, "utf8"), manifestPath);
2269
+ const input = JSON.parse(await readFile(join(dir, "input.json"), "utf8"));
2270
+ const overrideFile = join(dir, "replay.override.json");
2271
+ const workspaceDir = join(dir, "workspace");
2272
+ let fixtureFiles = RECORDING ? [] : await sessionFixtures(dir);
2273
+ const childFixtureFiles = fixtureFiles.slice(1);
2274
+ const comparesLog = scenario.comparesLog ?? scenario.hasModelTurn;
2275
+ const result = await runScenario(input, {
2276
+ agent,
2277
+ mode: childMode,
2278
+ fixtureFile: join(dir, "session.jsonl"),
2279
+ ...scenario.env !== void 0 ? { env: scenario.env } : {},
2280
+ ...existsSync(overrideFile) ? { overrideFile } : {},
2281
+ ...!RECORDING && childFixtureFiles.length > 0 ? { childFiles: childFixtureFiles.map((file) => join(dir, file)) } : {},
2282
+ ...existsSync(workspaceDir) ? { workspaceDir } : {},
2283
+ ...scenario.prepareWorkspace !== void 0 ? { prepareWorkspace: scenario.prepareWorkspace } : {},
2284
+ ...scenario.workspaceParent !== void 0 ? { workspaceParent: scenario.workspaceParent } : {},
2285
+ ...scenario.configPath !== void 0 ? { configPath: scenario.configPath } : {}
2286
+ });
2287
+ for (const log of result.sessionLogs) expect(unknownToolCallIds(log.content), `session ${log.id}: snapshot scenarios must not accept UNKNOWN_TOOL`).toEqual([]);
2288
+ const ctx = {
2289
+ sessionIds: [...result.sessionId !== void 0 ? [result.sessionId] : [], ...result.sessionLogs.map((l) => l.id)],
2290
+ cwd: result.cwd,
2291
+ cwdAliases: result.cwdAliases
2292
+ };
2293
+ const childSchemaPins = new Set(scenario.pinsChildToolSchemas ?? []);
2294
+ const childPromptPins = new Set(scenario.pinsChildSystemPrompts ?? []);
2295
+ const portableFixture = scenario.workspaceParent === void 0 ? tokenizeSessionFixtureCwd : (log) => log;
2296
+ if (RECORDING && scenario.recorded && scenario.hasModelTurn || REFRESHING && comparesLog) {
2297
+ expect(result.sessionLogs.length, `${mode} produced no session log to harvest`).toBeGreaterThan(0);
2298
+ if (REFRESHING) expect(result.sessionLogs.length, `expected ${fixtureFiles.length} session logs (parent + children)`).toBe(fixtureFiles.length);
2299
+ const outputFixtureFiles = ["session.jsonl", ...Array.from({ length: result.sessionLogs.length - 1 }, (_, i) => `session.${i + 1}.jsonl`)];
2300
+ const existingFixtures = await Promise.all(outputFixtureFiles.map(async (file) => {
2301
+ const path = join(dir, file);
2302
+ return existsSync(path) ? readFile(path, "utf8") : "";
2303
+ }));
2304
+ const refreshReplacements = REFRESHING ? refreshFixtureReplacements(result.sessionLogs, existingFixtures) : [];
2305
+ const outputFixtures = redactSessionSnapshotIds(stabilizeFixtureMessageIds(REFRESHING ? result.sessionLogs.map((log, index) => scrubSessionSnapshot(portableFixture(stabilizeRefreshLog(log.content, existingFixtures[index], refreshReplacements, ctx)))) : result.sessionLogs.map((log) => scrubSessionSnapshot(portableFixture(log.content))), existingFixtures));
2306
+ await Promise.all(outputFixtures.map((fixture, index) => writeFile(join(dir, outputFixtureFiles[index]), fixture)));
2307
+ if (RECORDING) {
2308
+ const outputNames = new Set(outputFixtureFiles);
2309
+ const entries = await readdir(dir, { withFileTypes: true });
2310
+ await Promise.all(entries.filter((entry) => entry.isFile() && /^session\.[1-9]\d*\.jsonl$/.test(entry.name) && !outputNames.has(entry.name)).map((entry) => rm(join(dir, entry.name))));
2311
+ fixtureFiles = outputFixtureFiles;
2312
+ }
2313
+ if (scenario.pinsHeader === true) {
2314
+ const primary = result.sessionLogs[0];
2315
+ const pinningHeaders = pinningHeaderPayloads(primary.content, ctx);
2316
+ const prompts = systemPromptsFrom(pinningHeaders);
2317
+ expect(prompts.length, `${mode} produced no system prompt to snapshot`).toBeGreaterThan(0);
2318
+ const promptSnapshot = formatSystemPromptSnapshot(prompts[0], prompts.slice(1));
2319
+ const promptPath = join(snapshotsDir, (promptSourceByClass.get(classOf(scenario)) ?? scenario).name, SYSTEM_PROMPT_SNAPSHOT);
2320
+ claimSharedSnapshot(promptClaims, promptPath, scenario.name, promptSnapshot);
2321
+ await writeFile(promptPath, promptSnapshot);
2322
+ const schemaSets = toolSchemasFrom(pinningHeaders);
2323
+ expect(schemaSets.length, `${mode} produced no tool schemas to snapshot`).toBeGreaterThan(0);
2324
+ expect(schemaSets.length, `${mode} produced a tool-schema sequence that differs from its prompt sequence`).toBe(prompts.length);
2325
+ const toolSchemasSnapshot = formatToolSchemasSnapshot(schemaSets[0], schemaSets.slice(1));
2326
+ const schemaPath = join(snapshotsDir, (schemaSourceByClass.get(classOf(scenario)) ?? scenario).name, TOOL_SCHEMAS_SNAPSHOT);
2327
+ claimSharedSnapshot(schemaClaims, schemaPath, scenario.name, toolSchemasSnapshot);
2328
+ await writeFile(schemaPath, toolSchemasSnapshot);
2329
+ }
2330
+ for (const index of childSchemaPins) {
2331
+ const log = result.sessionLogs[index];
2332
+ expect(log, `${mode}: no child session log at index ${index} to snapshot schemas from`).toBeDefined();
2333
+ const schemaSets = toolSchemasFrom(pinningHeaderPayloads(log.content, ctx));
2334
+ expect(schemaSets.length, `${mode}: child ${index} produced no tool schemas to snapshot`).toBeGreaterThan(0);
2335
+ await writeFile(join(dir, childToolSchemasSnapshot(index)), formatToolSchemasSnapshot(schemaSets[0], schemaSets.slice(1)));
2336
+ }
2337
+ for (const index of childPromptPins) {
2338
+ const log = result.sessionLogs[index];
2339
+ expect(log, `${mode}: no child session log at index ${index} to snapshot a prompt from`).toBeDefined();
2340
+ const prompts = systemPromptsFrom(pinningHeaderPayloads(log.content, ctx));
2341
+ expect(prompts.length, `${mode}: child ${index} produced no system prompt to snapshot`).toBeGreaterThan(0);
2342
+ await writeFile(join(dir, childSystemPromptSnapshot(index)), formatSystemPromptSnapshot(prompts[0]));
2343
+ }
2344
+ }
2345
+ for (const expected of stdoutExpectedVariants(scenario)) {
2346
+ const stdout = normalizeStdout(result.rawStdout, ctx, { cwdPathMode: expected.cwdPathMode });
2347
+ if (REFRESHING) await writeFile(join(dir, expected.file), stdout);
2348
+ await expect(stdout, `${expected.file} mismatch`).toMatchFileSnapshot(join(dir, expected.file));
2349
+ }
2350
+ if (comparesLog) {
2351
+ expect(result.sessionLogs.length, "this scenario must persist one log per session fixture").toBe(fixtureFiles.length);
2352
+ const harvested = result.sessionLogs.map((log) => log.content);
2353
+ const fixtures = await Promise.all(fixtureFiles.map((file) => readFile(join(dir, file), "utf8")));
2354
+ const fixtureContexts = fixtures.map(fixtureContext);
2355
+ const fixtureCtx = {
2356
+ sessionIds: fixtureContexts.flatMap((context) => context.sessionIds),
2357
+ cwd: fixtureContexts[0].cwd
2358
+ };
2359
+ const actualSnapshots = normalizeSessionSnapshots(harvested, ctx);
2360
+ const expectedSnapshots = normalizeSessionSnapshots(fixtures, fixtureCtx);
2361
+ for (const [index, actual] of actualSnapshots.entries()) expect(actual, `${fixtureFiles[index]} mismatch`).toEqual(expectedSnapshots[index]);
2362
+ }
2363
+ /* v8 ignore next -- construction guarantees the pin exists; a miss would fail the one-header assertion loudly. */
2364
+ const pinningScenario = pinningByClass.get(classOf(scenario)) ?? scenario;
2365
+ /* v8 ignore next -- registration guarantees every scenario class has resolved sources. */
2366
+ const promptSource = promptSourceByClass.get(classOf(scenario)) ?? pinningScenario;
2367
+ /* v8 ignore next -- registration guarantees every scenario class has resolved sources. */
2368
+ const schemaSource = schemaSourceByClass.get(classOf(scenario)) ?? pinningScenario;
2369
+ const pinnedFixture = await readFile(join(join(snapshotsDir, pinningScenario.name), "session.jsonl"), "utf8");
2370
+ const pinned = pinningHeaderPayloads(pinnedFixture, fixtureContext(pinnedFixture));
2371
+ const promptSnapshot = await readFile(join(snapshotsDir, promptSource.name, SYSTEM_PROMPT_SNAPSHOT), "utf8");
2372
+ const initialPromptSnapshot = initialSystemPromptSnapshot(promptSnapshot);
2373
+ expect(pinned.length, `the pinning fixture (${pinningScenario.name}) has an unexpected request/header count`).toBe(1 + (pinningScenario.expectedHeaderChanges ?? 0));
2374
+ const toolSchemasSnapshot = await readFile(join(snapshotsDir, schemaSource.name, TOOL_SCHEMAS_SNAPSHOT), "utf8");
2375
+ const toolSchemas = parseToolSchemasSnapshot(toolSchemasSnapshot);
2376
+ const pinnedSchemaSets = [toolSchemas.initial, ...toolSchemas.changes];
2377
+ expect(pinnedSchemaSets.length, `the schema source (${schemaSource.name}) has an unexpected tool-schema count`).toBe(pinned.length);
2378
+ const pinnedHeaders = pinned.map((header, index) => restorePinnedToolSchemas(header, pinnedSchemaSets[index]));
2379
+ const childPinnedSchemas = /* @__PURE__ */ new Map();
2380
+ for (const index of childSchemaPins) {
2381
+ const parsed = parseToolSchemasSnapshot(await readFile(join(dir, childToolSchemasSnapshot(index)), "utf8"));
2382
+ childPinnedSchemas.set(index, [parsed.initial, ...parsed.changes]);
2383
+ }
2384
+ const childPinnedPrompts = /* @__PURE__ */ new Map();
2385
+ for (const index of childPromptPins) childPinnedPrompts.set(index, await readFile(join(dir, childSystemPromptSnapshot(index)), "utf8"));
2386
+ for (const [logIndex, log] of result.sessionLogs.entries()) {
2387
+ const childSchemas = childPinnedSchemas.get(logIndex);
2388
+ const expectedChanges = scenario.pinsHeader === true && logIndex === 0 ? scenario.expectedHeaderChanges ?? 0 : 0;
2389
+ expect(headerChangeCount(log.content), `session ${log.id}: changed request/header count`).toBe(expectedChanges);
2390
+ const headerEvents = normalizedHeaderEvents(scrubSystemPrompts(log.content), ctx);
2391
+ const headers = headerEvents.map((event) => event.header);
2392
+ const prompts = normalizedSystemPrompts(log.content, ctx);
2393
+ const schemaSets = normalizedToolSchemas(log.content, ctx);
2394
+ expect(prompts.length, `session ${log.id}: every request/header must carry a string system prompt`).toBe(headers.length);
2395
+ expect(schemaSets.length, `session ${log.id}: every request/header must carry an array-valued tools field`).toBe(headers.length);
2396
+ if (childSchemas !== void 0) expect(childSchemas.length, `session ${log.id}: ${childToolSchemasSnapshot(logIndex)} has an unexpected tool-schema count`).toBe(1 + headerChangeCount(log.content));
2397
+ let revision = 0;
2398
+ for (const [k, header] of headers.entries()) {
2399
+ if (headerEvents[k]?.reason === "change") revision++;
2400
+ const classPin = expectedChanges > 0 ? pinnedHeaders[revision] : pinnedHeaders[0];
2401
+ const expected = childSchemas === void 0 ? classPin : {
2402
+ ...classPin,
2403
+ tools: childSchemas[revision]
2404
+ };
2405
+ expect(header, `session ${log.id}: request/header #${k + 1} diverged from the pinned (${pinningScenario.name}) header`).toEqual(expected);
2406
+ if (expectedChanges === 0) {
2407
+ const childPrompt = childPinnedPrompts.get(logIndex);
2408
+ const promptOrigin = childPrompt === void 0 ? `${promptSource.name}/${SYSTEM_PROMPT_SNAPSHOT}` : childSystemPromptSnapshot(logIndex);
2409
+ expect(formatSystemPromptSnapshot(prompts[k]), `session ${log.id}: initial system prompt #${k + 1} diverged from ${promptOrigin}`).toEqual(childPrompt ?? initialPromptSnapshot);
2410
+ }
2411
+ }
2412
+ if (scenario.pinsHeader === true && logIndex === 0) {
2413
+ const pinningHeaders = pinningHeaderPayloads(log.content, ctx);
2414
+ const pinningPrompts = systemPromptsFrom(pinningHeaders);
2415
+ const pinningSchemas = toolSchemasFrom(pinningHeaders);
2416
+ expect(formatSystemPromptSnapshot(pinningPrompts[0], pinningPrompts.slice(1)), `session ${log.id}: changed system prompts diverged from ${promptSource.name}/${SYSTEM_PROMPT_SNAPSHOT}`).toEqual(promptSnapshot);
2417
+ expect(formatToolSchemasSnapshot(pinningSchemas[0], pinningSchemas.slice(1)), `session ${log.id}: changed tool schemas diverged from ${schemaSource.name}/${TOOL_SCHEMAS_SNAPSHOT}`).toEqual(toolSchemasSnapshot);
2418
+ }
2419
+ }
2420
+ if (manifest.workspace?.final === true) {
2421
+ const expectedWorkspace = await captureExpectedWorkspaceSnapshot(join(dir, "workspace.expected"));
2422
+ expect(result.finalWorkspace, `${scenario.name}: complete final workspace`).toEqual(expectedWorkspace);
2423
+ } else expect(result.finalWorkspace, `${scenario.name}: a changed workspace requires workspace.final`).toEqual(result.initialWorkspace);
2424
+ });
2425
+ });
2426
+ describe("snapshot fixtures", () => {
2427
+ it("every scenario directory is registered (no orphans)", async () => {
2428
+ const onDisk = (await readdir(snapshotsDir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name).sort();
2429
+ const registered = scenarios.map((s) => s.name).sort();
2430
+ expect(onDisk).toEqual(registered);
2431
+ });
2432
+ it("every registered scenario has its required fixture files", async () => {
2433
+ for (const { name, overridden, pinsNativeWindowsStdout, pinsChildToolSchemas, pinsChildSystemPrompts } of scenarios) {
2434
+ const dir = join(snapshotsDir, name);
2435
+ const files = (await readdir(dir, { withFileTypes: true })).filter((entry) => entry.isFile()).map((entry) => entry.name);
2436
+ const manifestPath = join(dir, "snapshot.yml");
2437
+ expect(existsSync(manifestPath), `${name}/snapshot.yml`).toBe(true);
2438
+ const manifest = parseSnapshotManifest(await readFile(manifestPath, "utf8"), manifestPath);
2439
+ expect(manifest.profile, `${name}: manifest profile`).toBe(agent.profile ?? "acp");
2440
+ expect(manifest.session, `${name}: ACP scenarios own their session`).toBeUndefined();
2441
+ const childIndices = (pattern) => new Set(files.map((file) => pattern.exec(file)).filter((match) => match !== null).map((match) => Number(match[1])));
2442
+ expect(childIndices(/^tool-schemas\.([1-9]\d*)\.expected\.json$/), `${name}: child tool-schema sidecars must match \`pinsChildToolSchemas\``).toEqual(new Set(pinsChildToolSchemas ?? []));
2443
+ expect(childIndices(/^system-prompt\.([1-9]\d*)\.expected\.md$/), `${name}: child system-prompt sidecars must match \`pinsChildSystemPrompts\``).toEqual(new Set(pinsChildSystemPrompts ?? []));
2444
+ expect(existsSync(join(dir, "input.json")), `${name}/input.json`).toBe(true);
2445
+ expect(existsSync(join(dir, "stdout.expected.jsonl")), `${name}/stdout.expected.jsonl`).toBe(true);
2446
+ expect(existsSync(join(dir, WINDOWS_STDOUT_SNAPSHOT)), `${name}/${WINDOWS_STDOUT_SNAPSHOT} presence must match \`pinsNativeWindowsStdout\``).toBe(pinsNativeWindowsStdout === true);
2447
+ expect(existsSync(join(dir, "session.jsonl")), `${name}/session.jsonl`).toBe(true);
2448
+ expect(existsSync(join(dir, "replay.override.json")), `${name}/replay.override.json presence must match \`overridden\``).toBe(overridden === true);
2449
+ expect(existsSync(join(dir, SYSTEM_PROMPT_SNAPSHOT)), `${name}/${SYSTEM_PROMPT_SNAPSHOT} presence must match snapshot-source ownership`).toBe(promptOwners.has(name));
2450
+ expect(existsSync(join(dir, TOOL_SCHEMAS_SNAPSHOT)), `${name}/${TOOL_SCHEMAS_SNAPSHOT} presence must match snapshot-source ownership`).toBe(schemaOwners.has(name));
2451
+ await expect(sessionFixtures(dir), `${name}: session fixture inventory`).resolves.toBeDefined();
2452
+ }
2453
+ });
2454
+ it("exactly one scenario pins the request-header content of each header class", () => {
2455
+ const pins = /* @__PURE__ */ new Map();
2456
+ for (const scenario of scenarios.filter((s) => s.pinsHeader === true)) {
2457
+ const cls = classOf(scenario);
2458
+ pins.set(cls, [...pins.get(cls) ?? [], scenario.name]);
2459
+ }
2460
+ expect(Object.fromEntries([...pins].map(([cls, names]) => [cls, names.length]))).toEqual(Object.fromEntries([...pinningByClass.keys()].map((cls) => [cls, 1])));
2461
+ for (const scenario of scenarios) expect(pinningByClass.has(classOf(scenario)), `class "${classOf(scenario)}" (scenario ${scenario.name}) has a pin`).toBe(true);
2462
+ });
2463
+ it("every pinning fixture composes one tokenized header sequence with its referenced sidecars", async () => {
2464
+ for (const scenario of pinningByClass.values()) {
2465
+ /* v8 ignore next -- registration guarantees every pin has resolved sources. */
2466
+ const promptSource = promptSourceByClass.get(classOf(scenario)) ?? scenario;
2467
+ /* v8 ignore next -- registration guarantees every pin has resolved sources. */
2468
+ const schemaSource = schemaSourceByClass.get(classOf(scenario)) ?? scenario;
2469
+ const fixture = await readFile(join(snapshotsDir, scenario.name, "session.jsonl"), "utf8");
2470
+ const headers = pinningHeaderPayloads(fixture, fixtureContext(fixture));
2471
+ const promptSnapshot = await readFile(join(snapshotsDir, promptSource.name, SYSTEM_PROMPT_SNAPSHOT), "utf8");
2472
+ expect(headers.length, `${scenario.name}: unexpected request/header count`).toBe(1 + (scenario.expectedHeaderChanges ?? 0));
2473
+ const toolSchemasSnapshot = await readFile(join(snapshotsDir, schemaSource.name, TOOL_SCHEMAS_SNAPSHOT), "utf8");
2474
+ const toolSchemas = parseToolSchemasSnapshot(toolSchemasSnapshot);
2475
+ const schemaSets = [toolSchemas.initial, ...toolSchemas.changes];
2476
+ expect(schemaSets.length, `${schemaSource.name}: tool-schema sequence must match ${scenario.name}'s header sequence`).toBe(headers.length);
2477
+ for (const [index, header] of headers.entries()) expect(() => restorePinnedToolSchemas(header, schemaSets[index]), `${scenario.name}: tools must use the sidecar token`).not.toThrow();
2478
+ expect(promptSnapshot.length, `${promptSource.name}/${SYSTEM_PROMPT_SNAPSHOT} must not be empty`).toBeGreaterThan(0);
2479
+ expect(promptSnapshot.endsWith("\n"), `${promptSource.name}/${SYSTEM_PROMPT_SNAPSHOT} must end in a newline`).toBe(true);
2480
+ expect(toolSchemasSnapshot, `${schemaSource.name}/${TOOL_SCHEMAS_SNAPSHOT} must use canonical JSON formatting`).toBe(formatToolSchemasSnapshot(toolSchemas.initial, toolSchemas.changes));
2481
+ expect(headerChangeCount(fixture), `${scenario.name}: a pinning fixture must carry exactly its declared changed headers`).toBe(scenario.expectedHeaderChanges ?? 0);
2482
+ }
2483
+ });
2484
+ it("stores each distinct prompt and tool-schema snapshot once", async () => {
2485
+ const prompts = await Promise.all([...promptOwners].map(async (owner) => ({
2486
+ path: `${owner}/${SYSTEM_PROMPT_SNAPSHOT}`,
2487
+ content: await readFile(join(snapshotsDir, owner, SYSTEM_PROMPT_SNAPSHOT), "utf8")
2488
+ })));
2489
+ const schemas = await Promise.all([...schemaOwners].map(async (owner) => ({
2490
+ path: `${owner}/${TOOL_SCHEMAS_SNAPSHOT}`,
2491
+ content: await readFile(join(snapshotsDir, owner, TOOL_SCHEMAS_SNAPSHOT), "utf8")
2492
+ })));
2493
+ assertUniqueSnapshotContents("system-prompt", prompts);
2494
+ assertUniqueSnapshotContents("tool-schema", schemas);
2495
+ });
2496
+ it("every declared child sidecar is canonical and names a real child", async () => {
2497
+ for (const scenario of scenarios) {
2498
+ const dir = join(snapshotsDir, scenario.name);
2499
+ const files = await sessionFixtures(dir);
2500
+ for (const index of scenario.pinsChildToolSchemas ?? []) {
2501
+ expect(files[index], `${scenario.name}: child schema pin ${index} must name an existing session.<n>.jsonl fixture`).toBeDefined();
2502
+ const file = childToolSchemasSnapshot(index);
2503
+ const sidecar = await readFile(join(dir, file), "utf8");
2504
+ const parsed = parseToolSchemasSnapshot(sidecar);
2505
+ expect(sidecar, `${scenario.name}/${file} must use canonical JSON formatting`).toBe(formatToolSchemasSnapshot(parsed.initial, parsed.changes));
2506
+ expect(parsed.initial.length, `${scenario.name}/${file} must pin at least one schema`).toBeGreaterThan(0);
2507
+ }
2508
+ for (const index of scenario.pinsChildSystemPrompts ?? []) {
2509
+ expect(files[index], `${scenario.name}: child prompt pin ${index} must name an existing session.<n>.jsonl fixture`).toBeDefined();
2510
+ const file = childSystemPromptSnapshot(index);
2511
+ assertChildSystemPromptSnapshot(await readFile(join(dir, file), "utf8"), initialSystemPromptSnapshot(await readFile(join(snapshotsDir, (promptSourceByClass.get(classOf(scenario)) ?? scenario).name, SYSTEM_PROMPT_SNAPSHOT), "utf8")), `${scenario.name}/${file}`);
2512
+ }
2513
+ }
2514
+ });
2515
+ it("every committed JSONL has valid tool results and canonical fixture storage", async () => {
2516
+ for (const scenario of scenarios) {
2517
+ const dir = join(snapshotsDir, scenario.name);
2518
+ const files = await sessionFixtures(dir);
2519
+ for (const file of files) {
2520
+ const fixture = await readFile(join(dir, file), "utf8");
2521
+ expect(unknownToolCallIds(fixture), `${scenario.name}/${file} contains UNKNOWN_TOOL`).toEqual([]);
2522
+ expect(fixture, `${scenario.name}/${file} carries a non-canonical macOS cwd token`).not.toContain("/private{{cwd}}");
2523
+ expect(scrubSystemPrompts(fixture), `${scenario.name}/${file} carries an unscrubbed system prompt`).toEqual(fixture);
2524
+ expect(scrubToolSchemas(fixture), `${scenario.name}/${file} carries unscrubbed tool schemas`).toEqual(fixture);
2525
+ if (scenario.pinsHeader !== true) expect(scrubRequestHeaders(fixture), `${scenario.name}/${file} carries unscrubbed header content`).toEqual(fixture);
2526
+ }
2527
+ const fixtures = await Promise.all(files.map((file) => readFile(join(dir, file), "utf8")));
2528
+ expect(redactSessionSnapshotIds(fixtures), `${scenario.name}: identity redaction fixed point`).toEqual(fixtures);
2529
+ }
2530
+ });
2531
+ });
2532
+ }
2533
+ //#endregion
2534
+ export { EMPTY_WORKSPACE_MARKER, captureExpectedWorkspaceSnapshot, captureWorkspaceSnapshot, defineAcpSnapshotSuite, extractSnapshotSpillPaths, fixtureContext, formatSystemPromptSnapshot, formatToolSchemasSnapshot, headerChangeCount, launchAcpTestAgent, materializeProfilePatch, normalizeSessionLog, normalizeSessionSnapshot, normalizeSessionSnapshots, normalizeStdout, normalizedHeaders, normalizedSystemPrompts, normalizedToolSchemas, parseSnapshotManifest, parseToolSchemasSnapshot, redactSessionSnapshotIds, refreshFixtureReplacements, restorePinnedToolSchemas, runScenario, scrubRequestHeaders, scrubSessionSnapshot, scrubSystemPrompts, scrubToolSchemas, sessionFixtureNames, snapshotSpillRoot, stabilizeFixtureMessageIds, stabilizeRefreshLog, tokenizeSessionFixtureCwd };