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