@alexkroman1/aai-cli 6.10.1 → 6.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/dist/scaffold/CLAUDE.md +58 -0
  2. package/dist/scaffold/package.json +3 -3
  3. package/dist/scaffold/server.mjs +12 -3
  4. package/dist/scaffold/vite.config.ts +1 -1
  5. package/dist/templates/call-audit/agent.test.ts +965 -0
  6. package/dist/templates/call-audit/agent.ts +158 -0
  7. package/dist/templates/call-audit/client.tsx +235 -0
  8. package/dist/templates/call-audit/workflows/audit.ts +305 -0
  9. package/dist/templates/call-audit/workflows/ingest.ts +259 -0
  10. package/dist/templates/call-audit/workflows/media.ts +647 -0
  11. package/dist/templates/call-audit/workflows/summarize.ts +206 -0
  12. package/dist/templates/call-audit/workflows/sync-api.ts +44 -0
  13. package/dist/templates/call-audit/workflows/temp-media.ts +138 -0
  14. package/dist/templates/recap-workflow/agent.test.ts +11 -3
  15. package/dist/templates/recap-workflow/workflows/recap.ts +19 -8
  16. package/dist/templates/spoken-summary/agent.test.ts +343 -0
  17. package/dist/templates/spoken-summary/agent.ts +142 -0
  18. package/dist/templates/spoken-summary/client.tsx +225 -0
  19. package/dist/templates/spoken-summary/workflows/summarize.ts +242 -0
  20. package/dist/templates/spoken-summary/workflows/transcribe.ts +145 -0
  21. package/dist/templates/transcription-workflow/agent.test.ts +241 -18
  22. package/dist/templates/transcription-workflow/agent.ts +20 -6
  23. package/dist/templates/transcription-workflow/workflows/batch.ts +75 -173
  24. package/dist/templates/transcription-workflow/workflows/normalize.ts +343 -0
  25. package/dist/templates/transcription-workflow/workflows/stream.ts +6 -4
  26. package/dist/templates/transcription-workflow/workflows/sync-api.ts +26 -94
  27. package/dist/templates/transcription-workflow/workflows/transcribe.ts +23 -14
  28. package/dist/templates/transcription-workflow/workflows/wav.ts +31 -0
  29. package/package.json +3 -3
@@ -0,0 +1,965 @@
1
+ // Copyright 2026 the AAI authors. MIT license.
2
+ /**
3
+ * Specs for the audit desk's declaration, its ffmpeg argv, the two analyses it
4
+ * reads back, and where it decides to cut.
5
+ *
6
+ * **The pipeline is not driven end to end here, and that is the tier rather than
7
+ * a gap.** A unit test may not spawn a subprocess or write a file, so the three
8
+ * steps that run ffmpeg cannot be. What CAN be — and what this file therefore
9
+ * spends most of its lines on — is everything those steps decide, because
10
+ * `media.ts` exists precisely to hold it: the argv is a pure function, so it is a
11
+ * value to assert on rather than a string buried in a step; and both analyses are
12
+ * parsed by pure functions, so ffmpeg's real output can be a fixture.
13
+ *
14
+ * The fixtures below are **captured from ffmpeg 6.1.1**, verbatim, by running the
15
+ * argv these functions build. That matters more than it usually would: every field
16
+ * here is a string ffmpeg chose (`"input_i" : "-16.19"`, tabs and spaces included),
17
+ * so a fixture somebody typed from the documentation would be a spec that passes
18
+ * against a parser no real recording can satisfy.
19
+ *
20
+ * What the ffmpeg steps get instead is the two things a spec can still reach: the
21
+ * argv they will run, and the classification of a failure — which is where a
22
+ * mistake is silent, since a `timeout` called fatal is a run that gives up on work
23
+ * that would have finished.
24
+ */
25
+
26
+ import { readdir, readFile, stat, writeFile } from "node:fs/promises";
27
+ import { tmpdir } from "node:os";
28
+ import { join } from "node:path";
29
+ import { FfmpegError } from "@alexkroman1/aai/ffmpeg";
30
+ import { stubReporter, stubSpeech, stubStepFetch, stubUploads } from "@alexkroman1/aai/testing";
31
+ import { installStubGateway } from "@alexkroman1/aai/testing/vitest";
32
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
33
+ import { FatalError, RetryableError } from "workflow";
34
+ import agentDef, { audit } from "./agent.ts";
35
+ import {
36
+ countWords,
37
+ joinSegments,
38
+ now,
39
+ SEGMENT_CONCURRENCY,
40
+ transcribeSegment,
41
+ } from "./workflows/audit.ts";
42
+ import { analyse, classifyFfmpeg, ingestRecording } from "./workflows/ingest.ts";
43
+ import {
44
+ ANALYSIS_FORMAT,
45
+ BYTES_PER_SECOND,
46
+ clock,
47
+ durationSeconds,
48
+ type Loudness,
49
+ MAX_SEGMENT_SECONDS,
50
+ MediaAnalysisError,
51
+ MIN_SILENCE_SECONDS,
52
+ masterArgs,
53
+ measureLoudnessArgs,
54
+ normalizeArgs,
55
+ parseLoudness,
56
+ parseSilences,
57
+ planSegments,
58
+ type Silence,
59
+ speechFraction,
60
+ } from "./workflows/media.ts";
61
+ import { narrate, summarize } from "./workflows/summarize.ts";
62
+ import { fileChunks, materializeUpload, withTempDir } from "./workflows/temp-media.ts";
63
+
64
+ /** The id every spec below uploads under. */
65
+ const UPLOAD_ID = "upl_test";
66
+
67
+ /**
68
+ * `loudnorm`'s first pass, exactly as ffmpeg 6.1.1 printed it to stderr.
69
+ *
70
+ * Kept verbatim — the leading log line, the blank line, the tab indentation, and
71
+ * every value as a STRING. Two of those are load-bearing: the parser has to FIND
72
+ * the block rather than assume the text starts with it, and it has to coerce
73
+ * strings rather than read numbers.
74
+ */
75
+ const LOUDNORM_STDERR = `[Parsed_loudnorm_0 @ 0x558882c84140]
76
+ {
77
+ \t"input_i" : "-16.19",
78
+ \t"input_tp" : "-7.42",
79
+ \t"input_lra" : "4.80",
80
+ \t"input_thresh" : "-26.34",
81
+ \t"output_i" : "-16.13",
82
+ \t"output_tp" : "-7.48",
83
+ \t"output_lra" : "4.80",
84
+ \t"output_thresh" : "-26.26",
85
+ \t"normalization_type" : "dynamic",
86
+ \t"target_offset" : "0.13"
87
+ }
88
+ `;
89
+
90
+ /**
91
+ * What `ametadata=mode=print:file=…` wrote for a 20-second recording, verbatim.
92
+ *
93
+ * Two things about it are the whole reason `parseSilences` is not a two-line
94
+ * regex, and both were verified rather than assumed:
95
+ *
96
+ * - The event times are NOT the frame's `pts_time`. `silence_start=3` sits on a
97
+ * frame at 3.599, because the filter needed 0.6s of silence to be sure.
98
+ * - The LAST event has no `silence_end`. The recording ended during a pause, so
99
+ * the filter never saw the sound come back.
100
+ */
101
+ const SILENCE_LOG = `frame:155 pts:158720 pts_time:3.59909
102
+ lavfi.silence_start=3
103
+ frame:215 pts:220160 pts_time:4.99229
104
+ lavfi.silence_end=5.00005
105
+ lavfi.silence_duration=2.00005
106
+ frame:370 pts:378880 pts_time:8.59138
107
+ lavfi.silence_start=7.99998
108
+ frame:430 pts:440320 pts_time:9.98458
109
+ lavfi.silence_end=10
110
+ lavfi.silence_duration=2.00007
111
+ frame:585 pts:599040 pts_time:13.5837
112
+ lavfi.silence_start=13
113
+ frame:645 pts:660480 pts_time:14.9769
114
+ lavfi.silence_end=15
115
+ lavfi.silence_duration=2.00007
116
+ frame:801 pts:820224 pts_time:18.5992
117
+ lavfi.silence_start=18
118
+ `;
119
+
120
+ /**
121
+ * Bytes the real normalize pass wrote for the recording {@link SILENCE_LOG} came
122
+ * from, and the exact seconds that is.
123
+ *
124
+ * Not 20 seconds: AAC carries encoder priming samples, so decoding a nominally
125
+ * 20-second file yields 20.015625. The difference is small and it is the whole
126
+ * reason `planSegments` takes a byte count — see `durationSeconds`.
127
+ */
128
+ const REAL_PCM_BYTES = 640_500;
129
+ const EXACT_SECONDS = durationSeconds(REAL_PCM_BYTES);
130
+
131
+ /** The measurement above, parsed — the input the second pass's argv is built from. */
132
+ const MEASURED: Loudness = {
133
+ inputLufs: -16.19,
134
+ inputTruePeak: -7.42,
135
+ inputRange: 4.8,
136
+ inputThreshold: -26.34,
137
+ targetOffset: 0.13,
138
+ };
139
+
140
+ /**
141
+ * The bytes one stubbed request carried, ASSERTED rather than cast.
142
+ *
143
+ * A `throw` returns `never`, so this narrows without an `as Uint8Array` — and the
144
+ * cast is what a first draft reaches for, which Biome then rejects as unsafe
145
+ * optional chaining, because a missing call would throw on `.byteLength` rather
146
+ * than fail with a message. A typed seam is the repo's remedy for a concentration
147
+ * of identical casts, and two is where it starts paying.
148
+ *
149
+ * A bare `throw` rather than `expect.fail`, which is the shape the repo prefers in
150
+ * a test BODY and which Biome's `noMisplacedAssertion` forbids in a helper — an
151
+ * assertion outside a `test()` is a real hazard, and here the throw is doing type
152
+ * narrowing rather than making a claim.
153
+ */
154
+ function sentBytes(body: unknown): Uint8Array {
155
+ if (!(body instanceof Uint8Array)) {
156
+ throw new TypeError("the stub records a Uint8Array request body");
157
+ }
158
+ return body;
159
+ }
160
+
161
+ /** A pause at each of `starts`, each exactly long enough to be a candidate. */
162
+ function pauses(...starts: number[]): Silence[] {
163
+ return starts.map((startSec) => ({ startSec, endSec: startSec + MIN_SILENCE_SECONDS }));
164
+ }
165
+
166
+ /** Slots left published reach the next file, so every one is released here. */
167
+ const restores: (() => void)[] = [];
168
+ afterEach(() => {
169
+ while (restores.length > 0) restores.pop()?.();
170
+ });
171
+
172
+ beforeEach(() => {
173
+ // The step env, which is where `requireStepEnv`, `stepSpeak` and the gateway
174
+ // read the key. `vi.stubEnv` rather than an assignment: `unstubEnvs` undoes it
175
+ // before every test, so nothing here has to remember to put it back.
176
+ vi.stubEnv("ASSEMBLYAI_API_KEY", "test-key");
177
+ });
178
+
179
+ describe("the declaration", () => {
180
+ test("is a workflow app with the one workflow the page starts by name", () => {
181
+ // The page calls `api.start("audit", …)`, so a rename here is a runtime 400
182
+ // rather than a compile error. This is what pins it.
183
+ expect(Object.keys(agentDef.workflows ?? {})).toEqual(["audit"]);
184
+ expect(agentDef.page).toBe("static");
185
+ });
186
+
187
+ test("declares no providers and exactly the one credential its steps read", () => {
188
+ // A workflow app has no session, so nothing else in its config could name one
189
+ // — and one AssemblyAI key covers transcription, the model and the voice.
190
+ // Note what is NOT here: ffmpeg. `requiredEnv` checks the environment, and a
191
+ // binary on `PATH` is not an environment variable.
192
+ expect(agentDef.requiredEnv).toEqual(["ASSEMBLYAI_API_KEY"]);
193
+ });
194
+
195
+ test("takes the recording as an UPLOAD, which is what makes the form a file picker", () => {
196
+ expect(audit.uploads).toEqual(["recording"]);
197
+ });
198
+
199
+ test("offers real voice ids, so the synthesis cannot fail silently in band", async () => {
200
+ // A wrong voice is accepted by the socket and refused in band, so the schema
201
+ // is the only thing that can catch one. The list is read from the SDK catalog.
202
+ expect(
203
+ (await audit.input?.["~standard"].validate({ recording: UPLOAD_ID, voice: "not-a-voice" }))
204
+ ?.issues,
205
+ ).toBeTruthy();
206
+ expect(
207
+ (await audit.input?.["~standard"].validate({ recording: UPLOAD_ID, voice: "jane" }))?.issues,
208
+ ).toBeUndefined();
209
+ });
210
+
211
+ test("accepts a recording with no voice chosen, so the SDK default applies", async () => {
212
+ expect(
213
+ (await audit.input?.["~standard"].validate({ recording: UPLOAD_ID }))?.issues,
214
+ ).toBeUndefined();
215
+ });
216
+ });
217
+
218
+ describe("the ffmpeg argv", () => {
219
+ test("every invocation is quiet, non-interactive, and overwrites", () => {
220
+ // `-nostdin` is the one that matters in a guest: an ffmpeg that decides to
221
+ // read stdin is a process that never exits.
222
+ for (const argv of [
223
+ measureLoudnessArgs("in.m4a"),
224
+ normalizeArgs("in.m4a", MEASURED, "out.pcm", "silence.txt"),
225
+ masterArgs("spoken.wav", "out.mp3"),
226
+ ]) {
227
+ expect(argv).toEqual(expect.arrayContaining(["-hide_banner", "-nostats", "-nostdin", "-y"]));
228
+ }
229
+ });
230
+
231
+ test("the measure pass writes no audio and asks for JSON", () => {
232
+ const argv = measureLoudnessArgs("in.m4a");
233
+ // `-f null -` is what makes this cost a decode and produce five numbers.
234
+ expect(argv.slice(-3)).toEqual(["-f", "null", "-"]);
235
+ expect(argv.join(" ")).toContain("print_format=json");
236
+ });
237
+
238
+ test("the measure pass runs at `info`, because that is where the JSON is printed", () => {
239
+ // The failure this pins is silent: at `-loglevel error` the pass still runs
240
+ // and still succeeds, and prints nothing at all — which reads as a parser bug.
241
+ const argv = measureLoudnessArgs("in.m4a");
242
+ expect(argv[argv.indexOf("-loglevel") + 1]).toBe("info");
243
+ });
244
+
245
+ test("the normalize pass stays quiet, because its analysis goes to a FILE", () => {
246
+ const argv = normalizeArgs("in.m4a", MEASURED, "out.pcm", "silence.txt");
247
+ expect(argv[argv.indexOf("-loglevel") + 1]).toBe("error");
248
+ // `ametadata` writes the path directly rather than through the log, which is
249
+ // the property that lets this pass be both silent and complete.
250
+ expect(argv.join(" ")).toContain("ametadata=mode=print:file=silence.txt");
251
+ });
252
+
253
+ test("the normalize pass feeds back every measured value", () => {
254
+ // A missing `measured_*` makes loudnorm silently run a ONE-pass normalization
255
+ // instead — no error, just a different result — so all five are pinned.
256
+ const filter = normalizeArgs("in.m4a", MEASURED, "out.pcm", "silence.txt").join(" ");
257
+ expect(filter).toContain("measured_I=-16.19");
258
+ expect(filter).toContain("measured_TP=-7.42");
259
+ expect(filter).toContain("measured_LRA=4.8");
260
+ expect(filter).toContain("measured_thresh=-26.34");
261
+ expect(filter).toContain("offset=0.13");
262
+ // One constant gain rather than a moving one, so speech does not pump.
263
+ expect(filter).toContain("linear=true");
264
+ });
265
+
266
+ test("the normalize pass writes HEADERLESS PCM in the analysis format", () => {
267
+ // The decision the whole template rests on: `-f s16le`, not `-f wav`, so byte
268
+ // zero is second zero and nothing has to parse a RIFF chunk list.
269
+ const argv = normalizeArgs("in.m4a", MEASURED, "out.pcm", "silence.txt");
270
+ expect(argv.slice(-3)).toEqual(["-f", "s16le", "out.pcm"]);
271
+ expect(argv[argv.indexOf("-ar") + 1]).toBe(String(ANALYSIS_FORMAT.sampleRate));
272
+ expect(argv[argv.indexOf("-ac") + 1]).toBe(String(ANALYSIS_FORMAT.channels));
273
+ expect(argv[argv.indexOf("-c:a") + 1]).toBe("pcm_s16le");
274
+ });
275
+
276
+ test("the filter chain is ONE argv element, so its commas never meet a shell", () => {
277
+ // `runFfmpeg` spawns without a shell, which is what makes an unquoted filter
278
+ // graph safe — and what would break if this were ever assembled into a string.
279
+ const argv = normalizeArgs("in.m4a", MEASURED, "out.pcm", "silence.txt");
280
+ const filter = argv[argv.indexOf("-af") + 1] ?? "";
281
+ expect(filter).toContain(",silencedetect=");
282
+ expect(filter.startsWith("loudnorm=")).toBe(true);
283
+ });
284
+
285
+ test("the mastering pass encodes MP3, which is the point of running it", () => {
286
+ const argv = masterArgs("spoken.wav", "out.mp3");
287
+ expect(argv[argv.indexOf("-c:a") + 1]).toBe("libmp3lame");
288
+ expect(argv.at(-1)).toBe("out.mp3");
289
+ });
290
+ });
291
+
292
+ describe("reading the loudness measurement", () => {
293
+ test("parses ffmpeg's own block, strings and all", () => {
294
+ expect(parseLoudness(LOUDNORM_STDERR)).toEqual(MEASURED);
295
+ });
296
+
297
+ test("finds the block after whatever info-level chatter preceded it", () => {
298
+ // The pass runs at `-loglevel info`, so the captured stderr tail holds stream
299
+ // descriptions and a muxing-overhead line before the JSON.
300
+ const noisy = ` Stream #0:0(und): Audio: pcm_s16le, 192000 Hz, mono\nsize=N/A time=00:00:17.20\n${LOUDNORM_STDERR}`;
301
+ expect(parseLoudness(noisy)).toEqual(MEASURED);
302
+ });
303
+
304
+ test("takes the LAST block, so a retried pass reads its own numbers", () => {
305
+ const twice = LOUDNORM_STDERR + LOUDNORM_STDERR.replace('"-16.19"', '"-20.50"');
306
+ expect(parseLoudness(twice).inputLufs).toBe(-20.5);
307
+ });
308
+
309
+ test("names the missing `-loglevel info` when nothing was printed", () => {
310
+ // The most likely way this breaks, so the message says how to fix it rather
311
+ // than reporting a parse failure.
312
+ expect(() => parseLoudness("size=N/A time=00:00:17.20\n")).toThrow(MediaAnalysisError);
313
+ expect(() => parseLoudness("")).toThrow(/-loglevel info/);
314
+ });
315
+
316
+ test("names the KEY when a value is missing or unparseable", () => {
317
+ // A silent `NaN` would flow into the second pass's argv as the literal text
318
+ // `NaN` and come back as an ffmpeg option-parsing error about a filter.
319
+ const missing = LOUDNORM_STDERR.replace('"input_tp" : "-7.42",', "");
320
+ expect(() => parseLoudness(missing)).toThrow(/input_tp/);
321
+ });
322
+
323
+ test("refuses a block that is not JSON at all", () => {
324
+ expect(() => parseLoudness("[loudnorm] {not json}")).toThrow(MediaAnalysisError);
325
+ });
326
+ });
327
+
328
+ describe("reading the pauses", () => {
329
+ test("parses every event ffmpeg logged, and closes the unterminated one", () => {
330
+ // The trailing `silence_start=18` has no `silence_end` — the recording ended
331
+ // during the pause. It is closed at the duration, which is why this function
332
+ // takes one.
333
+ expect(parseSilences(SILENCE_LOG, EXACT_SECONDS)).toEqual([
334
+ { startSec: 3, endSec: 5.000_05 },
335
+ { startSec: 7.999_98, endSec: 10 },
336
+ { startSec: 13, endSec: 15 },
337
+ { startSec: 18, endSec: EXACT_SECONDS },
338
+ ]);
339
+ });
340
+
341
+ test("reads the EVENT time, not the frame's", () => {
342
+ // `silence_start=3` sits on a frame at `pts_time:3.59909`. Reading the frame
343
+ // would put every cut 0.6s late and lose the word before it.
344
+ const [first] = parseSilences(SILENCE_LOG, EXACT_SECONDS);
345
+ expect(first?.startSec).toBe(3);
346
+ });
347
+
348
+ test("an empty log is no pauses, not a failure", () => {
349
+ // Verified against ffmpeg 6.1: `ametadata` creates the file at filter-init, so
350
+ // a recording with no pause in it leaves an empty log rather than none.
351
+ expect(parseSilences("", 30)).toEqual([]);
352
+ });
353
+
354
+ test("a pause that never ended before a recording that already did is dropped", () => {
355
+ // `durationSec` is measured from the PCM byte count and the log from the same
356
+ // pass, so they agree — but a zero-length pause would produce a cut candidate
357
+ // at the very end, which `planSegments` would then have to reject.
358
+ expect(parseSilences("lavfi.silence_start=30\n", 30)).toEqual([]);
359
+ });
360
+
361
+ test("an end without a start is ignored rather than inventing a pause at zero", () => {
362
+ expect(parseSilences("lavfi.silence_end=4\n", 30)).toEqual([]);
363
+ });
364
+
365
+ test("a truncated numeric value is skipped rather than read as NaN", () => {
366
+ // A `NaN` boundary would make every downstream comparison false, so the cut
367
+ // planner would silently fall back to blind cuts for the whole recording.
368
+ expect(parseSilences("lavfi.silence_start=\nlavfi.silence_end=4\n", 30)).toEqual([]);
369
+ });
370
+ });
371
+
372
+ describe("planning where to cut", () => {
373
+ test("a recording inside the cap is one segment, whatever its pauses", () => {
374
+ // Nothing to decide, and the pauses must not tempt the planner into cutting: a
375
+ // request per pause would be dozens of requests for a two-minute call.
376
+ const segments = planSegments(pauses(10, 20, 30), 60 * BYTES_PER_SECOND);
377
+ expect(segments).toHaveLength(1);
378
+ expect(segments[0]).toMatchObject({ index: 0, startByte: 0, startMs: 0, endMs: 60_000 });
379
+ expect(segments[0]?.endByte).toBe(60 * BYTES_PER_SECOND);
380
+ });
381
+
382
+ test("cuts at the LAST pause that still fits, not the first", () => {
383
+ // Greedy from the front, so segments are as long as the cap allows — the
384
+ // alternative is twice as many requests for the same audio.
385
+ const segments = planSegments(pauses(30, 60, 100), 150 * BYTES_PER_SECOND);
386
+ // The pause at 100 spans 100–100.6, so its midpoint is 100.3.
387
+ expect(segments.map((s) => s.endMs)).toEqual([100_300, 150_000]);
388
+ });
389
+
390
+ test("the cut is the pause's MIDPOINT, so neither side is clipped", () => {
391
+ // Cutting at the start clips the decay of the word before; cutting at the end
392
+ // clips the attack of the word after.
393
+ const segments = planSegments([{ startSec: 100, endSec: 101 }], 150 * BYTES_PER_SECOND);
394
+ expect(segments[0]?.endMs).toBe(100_500);
395
+ });
396
+
397
+ test("segments are contiguous and non-overlapping, which is what deletes the stitcher", () => {
398
+ const segments = planSegments(pauses(50, 100, 160, 210), 260 * BYTES_PER_SECOND);
399
+ expect(segments.length).toBeGreaterThan(1);
400
+ for (const [i, segment] of segments.entries()) {
401
+ if (i === 0) continue;
402
+ expect(segment.startByte).toBe(segments[i - 1]?.endByte);
403
+ expect(segment.startMs).toBe(segments[i - 1]?.endMs);
404
+ }
405
+ expect(segments[0]?.startByte).toBe(0);
406
+ expect(segments.at(-1)?.endByte).toBe(260 * BYTES_PER_SECOND);
407
+ });
408
+
409
+ test("no segment exceeds the endpoint's cap", () => {
410
+ const segments = planSegments(pauses(20, 40, 61, 130, 200), 400 * BYTES_PER_SECOND);
411
+ for (const segment of segments) {
412
+ expect(segment.endMs - segment.startMs).toBeLessThanOrEqual(MAX_SEGMENT_SECONDS * 1000);
413
+ }
414
+ });
415
+
416
+ test("an unbroken monologue falls back to a blind cut, and SAYS so", () => {
417
+ // The case the pretty invariant cannot serve. Refusing it would be the worse
418
+ // trade, so it degrades to exactly what `transcription-workflow` does — and
419
+ // reports it, because a mangled word at a seam is otherwise a mystery.
420
+ const segments = planSegments([], 300 * BYTES_PER_SECOND);
421
+ expect(segments).toHaveLength(3);
422
+ expect(segments.map((s) => s.endMs)).toEqual([110_000, 220_000, 300_000]);
423
+ expect(segments.map((s) => s.cutInSpeech)).toEqual([true, true, false]);
424
+ });
425
+
426
+ test("a cut that landed in a pause is NOT reported as a blind cut", () => {
427
+ const segments = planSegments(pauses(100), 150 * BYTES_PER_SECOND);
428
+ expect(segments.map((s) => s.cutInSpeech)).toEqual([false, false]);
429
+ });
430
+
431
+ test("a tail too short to be worth a request joins its predecessor", () => {
432
+ // The endpoint refuses audio under 80ms, and a 0.4-second tail holds at most one
433
+ // word — but the word is a word, so it is merged rather than dropped. A pause at
434
+ // 150 leaves the predecessor well short of the cap, which is what makes the merge
435
+ // legal; see the cap test below for when it is not.
436
+ const segments = planSegments(pauses(150), 200.5 * BYTES_PER_SECOND);
437
+ expect(segments).toHaveLength(2);
438
+ expect(segments.at(-1)?.endMs).toBe(200_500);
439
+ });
440
+
441
+ test("a short tail is NOT absorbed into a segment already at the cap", () => {
442
+ // The greedy loop leaves a final segment of at most `MAX_SEGMENT_SECONDS`, so
443
+ // merging a sub-second tail into a predecessor already at the cap would make one
444
+ // 110.5 seconds long — inside the endpoint's own 120-second limit, and outside
445
+ // the bound this module promises. A short final request is the cheaper mistake,
446
+ // and it is still an order of magnitude above the 80ms the endpoint refuses.
447
+ const segments = planSegments([], 110.5 * BYTES_PER_SECOND);
448
+ expect(segments).toHaveLength(2);
449
+ for (const segment of segments) {
450
+ expect(segment.endMs - segment.startMs).toBeLessThanOrEqual(MAX_SEGMENT_SECONDS * 1000);
451
+ }
452
+ // Still contiguous, still covering the whole recording.
453
+ expect(segments[0]?.endByte).toBe(segments[1]?.startByte);
454
+ expect(segments.at(-1)?.endMs).toBe(110_500);
455
+ });
456
+
457
+ test("every byte offset lands on a sample-frame boundary", () => {
458
+ // A byte offset mid-sample shifts every sample after it by one byte, which is
459
+ // not a click — it is white noise the decoder transcribes into confident
460
+ // nonsense.
461
+ const frame = (ANALYSIS_FORMAT.channels * ANALYSIS_FORMAT.bitsPerSample) / 8;
462
+ for (const segment of planSegments(pauses(37.333, 88.777, 150.5), 200 * BYTES_PER_SECOND)) {
463
+ expect(segment.startByte % frame).toBe(0);
464
+ expect(segment.endByte % frame).toBe(0);
465
+ }
466
+ });
467
+
468
+ test("an empty recording plans nothing rather than one empty request", () => {
469
+ expect(planSegments([], 0)).toEqual([]);
470
+ });
471
+
472
+ test("the real 20-second capture is one segment covering all of it", () => {
473
+ // End to end over the captured fixture, which is the case a reader can check
474
+ // against the numbers in `SILENCE_LOG`.
475
+ const silences = parseSilences(SILENCE_LOG, EXACT_SECONDS);
476
+ const segments = planSegments(silences, REAL_PCM_BYTES);
477
+ expect(segments).toHaveLength(1);
478
+ expect(segments[0]?.cutInSpeech).toBe(false);
479
+ });
480
+
481
+ test("the last segment never addresses a byte the file does not have", () => {
482
+ // The twelve-byte bug this API shape exists to prevent: planning from a
483
+ // duration rounded to whole milliseconds put `endByte` at 640,512 for a
484
+ // 640,500-byte file. `readUpload` clamps a window to the stored size, so
485
+ // nothing threw — the plan was simply describing audio that does not exist.
486
+ // Found by running the real argv against a real ffmpeg, which is the only
487
+ // place a twelve-byte error was ever going to surface.
488
+ for (const bytes of [REAL_PCM_BYTES, 32_001, 999_999, 2]) {
489
+ const segments = planSegments(parseSilences(SILENCE_LOG, durationSeconds(bytes)), bytes);
490
+ expect(segments.at(-1)?.endByte ?? 0).toBeLessThanOrEqual(bytes);
491
+ }
492
+ });
493
+ });
494
+
495
+ describe("what the page is told about the recording", () => {
496
+ test("speech is the complement of the pauses", () => {
497
+ // 20 seconds with 8 of pause in it.
498
+ expect(
499
+ Math.round(
500
+ speechFraction(
501
+ [
502
+ { startSec: 3, endSec: 5 },
503
+ { startSec: 8, endSec: 10 },
504
+ { startSec: 13, endSec: 15 },
505
+ { startSec: 18, endSec: 20 },
506
+ ],
507
+ 20,
508
+ ) * 100,
509
+ ),
510
+ ).toBe(60);
511
+ });
512
+
513
+ test("a recording of pure silence is 0% speech, and one with no pause is 100%", () => {
514
+ expect(speechFraction([{ startSec: 0, endSec: 30 }], 30)).toBe(0);
515
+ expect(speechFraction([], 30)).toBe(1);
516
+ });
517
+
518
+ test("a zero-length recording answers 0 rather than dividing by it", () => {
519
+ expect(speechFraction([], 0)).toBe(0);
520
+ });
521
+
522
+ test("the clock grows an hours field only when there is one", () => {
523
+ expect(clock(0)).toBe("0:00");
524
+ expect(clock(9000)).toBe("0:09");
525
+ expect(clock(249_000)).toBe("4:09");
526
+ expect(clock(3_849_000)).toBe("1:04:09");
527
+ });
528
+ });
529
+
530
+ describe("joining the segments", () => {
531
+ /** Two segments whose shared boundary is `cutInSpeech` on the first. */
532
+ function pair(cutInSpeech: boolean) {
533
+ return [
534
+ { index: 0, startByte: 0, endByte: 10, startMs: 0, endMs: 10, cutInSpeech },
535
+ { index: 1, startByte: 10, endByte: 20, startMs: 10, endMs: 20, cutInSpeech: false },
536
+ ];
537
+ }
538
+
539
+ test("a cut made in a pause becomes a paragraph break", () => {
540
+ // The plan knows where the turn boundaries are, so the transcript can show
541
+ // them. This is the one place `cutInSpeech` changes an output, not a report.
542
+ expect(
543
+ joinSegments(pair(false), [
544
+ { index: 0, text: "Good morning." },
545
+ { index: 1, text: "Let us begin." },
546
+ ]),
547
+ ).toBe("Good morning.\n\nLet us begin.");
548
+ });
549
+
550
+ test("a blind cut is joined with a space, because it landed mid-sentence", () => {
551
+ expect(
552
+ joinSegments(pair(true), [
553
+ { index: 0, text: "the number was" },
554
+ { index: 1, text: "roughly four" },
555
+ ]),
556
+ ).toBe("the number was roughly four");
557
+ });
558
+
559
+ test("puts the parts in index order however they arrived", () => {
560
+ // `mapConcurrent` resolves in item order, so this is belt and braces — a merge
561
+ // is where an ordering mistake would be invisible rather than loud.
562
+ expect(
563
+ joinSegments(pair(true), [
564
+ { index: 1, text: "second" },
565
+ { index: 0, text: "first" },
566
+ ]),
567
+ ).toBe("first second");
568
+ });
569
+
570
+ test("a segment that transcribed to nothing leaves no stray separator", () => {
571
+ // A stretch that is all room tone comes back empty, and a naive join would
572
+ // leave a leading or doubled break in the transcript.
573
+ expect(
574
+ joinSegments(pair(false), [
575
+ { index: 0, text: "" },
576
+ { index: 1, text: "words" },
577
+ ]),
578
+ ).toBe("words");
579
+ });
580
+
581
+ test("counts words, or none for an empty transcript", () => {
582
+ expect(countWords(" two words ")).toBe(2);
583
+ expect(countWords(" ")).toBe(0);
584
+ });
585
+ });
586
+
587
+ describe("transcribing one segment", () => {
588
+ /**
589
+ * One second of stored PCM, and a sync endpoint that answers.
590
+ *
591
+ * `stubStepFetch`, not `vi.stubGlobal("fetch", …)`: the step calls `stepFetch`,
592
+ * which reaches a published slot rather than the global. Stubbing the global
593
+ * still passes, because an unpublished slot falls back to it, and would be
594
+ * asserting against a path production does not take.
595
+ */
596
+ function stubProvider(answer: { status?: number; body?: unknown } = {}) {
597
+ restores.push(
598
+ stubUploads({
599
+ [UPLOAD_ID]: {
600
+ bytes: new Uint8Array(BYTES_PER_SECOND),
601
+ name: "call.pcm",
602
+ type: "application/octet-stream",
603
+ },
604
+ }),
605
+ stubReporter().restore,
606
+ );
607
+ const stub = stubStepFetch(() => ({
608
+ status: answer.status ?? 200,
609
+ body: answer.body ?? { text: "hello there" },
610
+ }));
611
+ restores.push(stub.restore);
612
+ return stub.calls;
613
+ }
614
+
615
+ const SEGMENT = {
616
+ index: 0,
617
+ startByte: 0,
618
+ endByte: BYTES_PER_SECOND,
619
+ startMs: 0,
620
+ endMs: 1000,
621
+ cutInSpeech: false,
622
+ };
623
+
624
+ test("puts a WAV header back on the headerless span before sending it", async () => {
625
+ // The endpoint decodes each request independently, so a slice of raw PCM is
626
+ // meaningless bytes until a header says what they are. `encodeWav` is the SDK's
627
+ // — this template deliberately carries no copy of it.
628
+ const calls = stubProvider();
629
+ await expect(transcribeSegment(UPLOAD_ID, SEGMENT)).resolves.toEqual({
630
+ index: 0,
631
+ text: "hello there",
632
+ });
633
+
634
+ // Inside a multipart body, so the header is not at byte zero — what matters is
635
+ // that it is there at all, and immediately followed by `WAVE`.
636
+ const sent = new TextDecoder("latin1").decode(sentBytes(calls[0]?.body));
637
+ expect(sent).toContain("RIFF");
638
+ expect(sent.indexOf("WAVE")).toBe(sent.indexOf("RIFF") + 8);
639
+ });
640
+
641
+ test("sends the whole span, header included, and nothing else", async () => {
642
+ const calls = stubProvider();
643
+ await transcribeSegment(UPLOAD_ID, SEGMENT);
644
+ // 44 bytes of canonical header plus one second of audio, inside a multipart
645
+ // body — so the request is strictly larger than the span and close to it.
646
+ const size = sentBytes(calls[0]?.body).byteLength;
647
+ expect(size).toBeGreaterThan(BYTES_PER_SECOND);
648
+ expect(size).toBeLessThan(BYTES_PER_SECOND + 2000);
649
+ });
650
+
651
+ test("a rate limit is RETRYABLE, so one busy minute does not fail the run", async () => {
652
+ // The expected failure of a 32-wide fan-out, and the reason this step's
653
+ // `maxRetries` is above the default.
654
+ stubProvider({ status: 429, body: { error: "slow down" } });
655
+ await expect(transcribeSegment(UPLOAD_ID, SEGMENT)).rejects.toBeInstanceOf(RetryableError);
656
+ expect(transcribeSegment.maxRetries).toBe(5);
657
+ });
658
+
659
+ test("a rejected request is FATAL, so it is not asked five more times", async () => {
660
+ stubProvider({ status: 400, body: { error: "that is not audio" } });
661
+ await expect(transcribeSegment(UPLOAD_ID, SEGMENT)).rejects.toBeInstanceOf(FatalError);
662
+ });
663
+
664
+ test("the fan-out width is a constant, because the format is", () => {
665
+ // The payoff of normalizing: a segment is at most 3.5 MB whatever was
666
+ // uploaded, so there is no byte budget to divide — see the constant's doc.
667
+ expect(SEGMENT_CONCURRENCY).toBe(32);
668
+ expect(MAX_SEGMENT_SECONDS * BYTES_PER_SECOND * SEGMENT_CONCURRENCY).toBeLessThan(
669
+ 640 * 1024 * 1024,
670
+ );
671
+ });
672
+ });
673
+
674
+ describe("auditing the transcript", () => {
675
+ test("asks for a script as well as lists, and keeps both", async () => {
676
+ // The two-summaries decision: a voice reading a bullet list says "one. two.
677
+ // three." with no connective tissue, so the schema demands sentences too.
678
+ restores.push(stubReporter().restore);
679
+ // The stub answers with TEXT, because that is what a gateway returns — and
680
+ // `stepGenerateJson` parsing it is part of what this exercises.
681
+ installStubGateway(
682
+ JSON.stringify({
683
+ headline: "Renewal call with Northwind",
684
+ risks: ["Nobody owns the migration date"],
685
+ actions: ["Ana to send revised pricing"],
686
+ spoken: "The renewal is close, but the migration date has no owner yet.",
687
+ }),
688
+ );
689
+
690
+ const summary = await summarize("… transcript …", "call.m4a", 600_000);
691
+ expect(summary.headline).toBe("Renewal call with Northwind");
692
+ expect(summary.risks).toEqual(["Nobody owns the migration date"]);
693
+ expect(summary.spoken).toContain("migration date");
694
+ });
695
+
696
+ test("an empty risk list is an ANSWER, not a retry", async () => {
697
+ // A schema that demanded a risk would get an invented one, which is worse than
698
+ // silence on a call that really had none.
699
+ restores.push(stubReporter().restore);
700
+ installStubGateway(
701
+ JSON.stringify({
702
+ headline: "Weekly standup",
703
+ risks: [],
704
+ actions: [],
705
+ spoken: "Nothing was decided and nothing is blocked.",
706
+ }),
707
+ );
708
+ await expect(summarize("…", "standup.wav", 60_000)).resolves.toMatchObject({ risks: [] });
709
+ });
710
+
711
+ test("a reply with no spoken script is a RETRY rather than silence", async () => {
712
+ // `spoken` is required rather than defaulted precisely so this fails: a default
713
+ // would turn a missing field into half a second of audio nobody notices.
714
+ restores.push(stubReporter().restore);
715
+ installStubGateway(JSON.stringify({ headline: "A call", risks: [], actions: [] }));
716
+ await expect(summarize("…", "call.m4a", 60_000)).rejects.toThrow();
717
+ });
718
+ });
719
+
720
+ describe("classifying a failure", () => {
721
+ /** An `FfmpegError` of one kind, with the argv a reader would want. */
722
+ function failed(kind: "exit" | "timeout" | "aborted" | "missing-binary") {
723
+ return new FfmpegError({
724
+ kind,
725
+ message: `ffmpeg ${kind}`,
726
+ binary: "ffmpeg",
727
+ argv: ["-i", "source"],
728
+ });
729
+ }
730
+
731
+ test("a file ffmpeg REFUSED is fatal, so it is not attempted five times", () => {
732
+ // `exit` means ffmpeg read the file and would read it the same way again.
733
+ expect(() => classifyFfmpeg(failed("exit"))).toThrow(FatalError);
734
+ });
735
+
736
+ test("no ffmpeg at all is fatal — a retry cannot install one", () => {
737
+ // The `aai dev` case, whose message already carries the install instructions.
738
+ expect(() => classifyFfmpeg(failed("missing-binary"))).toThrow(FatalError);
739
+ });
740
+
741
+ test("a run that timed out keeps its retries, and its argv", () => {
742
+ // Rethrown UNCHANGED rather than wrapped, which is what `toStepError` does with
743
+ // an error carrying no verdict — and the DevKit's default for anything that is
744
+ // not a `FatalError` is to retry. Asserting the class survives is asserting the
745
+ // diagnosis does: `argv` is the command you paste into a shell.
746
+ for (const kind of ["timeout", "aborted"] as const) {
747
+ const err = failed(kind);
748
+ expect(() => classifyFfmpeg(err)).toThrow(err);
749
+ expect(() => classifyFfmpeg(err)).not.toThrow(FatalError);
750
+ }
751
+ });
752
+
753
+ test("something that is not an ffmpeg failure at all is fatal", () => {
754
+ expect(() => classifyFfmpeg(new Error("no space left on device"))).toThrow(FatalError);
755
+ });
756
+
757
+ test("an analysis this desk cannot read is fatal, because a retry reads it again", () => {
758
+ // ffmpeg SUCCEEDED and printed something unrecognized — a renamed key, a lost
759
+ // `-loglevel info`. Every retry runs the same binary and prints the same thing.
760
+ expect(() =>
761
+ analyse(() => {
762
+ throw new MediaAnalysisError("no JSON block");
763
+ }),
764
+ ).toThrow(FatalError);
765
+ });
766
+
767
+ test("an analysis helper's OTHER failures are not swallowed", () => {
768
+ // Only `MediaAnalysisError` is a verdict. Anything else is a bug in the parser,
769
+ // and turning that into a terminal step failure would hide it.
770
+ const bug = new TypeError("cannot read properties of undefined");
771
+ expect(() =>
772
+ analyse(() => {
773
+ throw bug;
774
+ }),
775
+ ).toThrow(bug);
776
+ });
777
+ });
778
+
779
+ describe("the run's clock", () => {
780
+ test("is a STEP, so a replay does not re-measure it", async () => {
781
+ // A `Date.now()` in the body returns a different value on every replay, and
782
+ // every duration derived from it would be a different duration.
783
+ vi.useFakeTimers();
784
+ try {
785
+ vi.setSystemTime(1_700_000_000_000);
786
+ await expect(now()).resolves.toBe(1_700_000_000_000);
787
+ } finally {
788
+ vi.useRealTimers();
789
+ }
790
+ });
791
+ });
792
+
793
+ describe("the mastered narration", () => {
794
+ test("is not driven here, and the spec says why rather than pretending", () => {
795
+ // `narrate` speaks, writes a temp file, spawns ffmpeg and stores the result. A
796
+ // unit test may do none of those, so what is covered is `masterArgs` above and
797
+ // the classification below — and `stubSpeech` is imported to make the omission
798
+ // deliberate rather than an oversight: filling only that slot would leave the
799
+ // step failing on the subprocess, which is a test of the tier, not the code.
800
+ expect(typeof stubSpeech).toBe("function");
801
+ expect(masterArgs("in.wav", "out.mp3").at(-1)).toBe("out.mp3");
802
+ });
803
+ });
804
+
805
+ describe("moving bytes between the store and a local file", () => {
806
+ /**
807
+ * These DO touch the filesystem, which the unit tier otherwise avoids — and the
808
+ * exception is deliberate rather than a slip. A temp directory is hermetic, costs
809
+ * milliseconds, and is the only way to exercise the one bug this module is written
810
+ * to prevent: `fileChunks` reuses one buffer across reads, so yielding a view of
811
+ * it rather than a copy hands the consumer memory the next read overwrites. That
812
+ * failure produces a stored file made of the LAST chunk repeated, and it does not
813
+ * reproduce whenever the consumer happens to copy before the next iteration — so
814
+ * a mock consumer would not see it and only real bytes will.
815
+ *
816
+ * `aai-cli`'s unit specs take the same exception through their own `withTempDir`.
817
+ */
818
+ test("withTempDir gives the work a private directory and removes it after", async () => {
819
+ let seen: string | undefined;
820
+ await withTempDir(async (dir) => {
821
+ seen = dir;
822
+ await writeFile(join(dir, "probe"), "x");
823
+ await expect(stat(join(dir, "probe"))).resolves.toBeTruthy();
824
+ });
825
+ expect(seen).toBeTruthy();
826
+ // Gone. A guest's disk is small, and a step that left a copy of every recording
827
+ // it touched would fill it.
828
+ await expect(stat(seen ?? "")).rejects.toThrow();
829
+ });
830
+
831
+ test("the directory is removed even when the work THROWS", async () => {
832
+ let seen: string | undefined;
833
+ const boom = new Error("the conversion failed");
834
+ await expect(
835
+ withTempDir((dir) => {
836
+ seen = dir;
837
+ throw boom;
838
+ }),
839
+ ).rejects.toThrow(boom);
840
+ await expect(stat(seen ?? "")).rejects.toThrow();
841
+ });
842
+
843
+ test("materializeUpload writes the stored bytes to a path, in order", async () => {
844
+ // The upload store is the seam that makes this testable at all: `readUpload`
845
+ // reads a process-wide slot rather than dialling anything.
846
+ const bytes = new Uint8Array(4096).map((_, at) => at % 251);
847
+ restores.push(stubUploads({ [UPLOAD_ID]: { bytes, name: "call.m4a" } }));
848
+
849
+ await withTempDir(async (dir) => {
850
+ const path = join(dir, "source");
851
+ // A small window, so the loop really runs four times — at the real 8 MiB one
852
+ // a 4 KB upload would exercise a single pass and prove nothing about it.
853
+ await materializeUpload(UPLOAD_ID, bytes.byteLength, path, 1024);
854
+ expect(new Uint8Array(await readFile(path))).toEqual(bytes);
855
+ });
856
+ });
857
+
858
+ test("an empty upload materializes an empty file rather than failing", async () => {
859
+ restores.push(stubUploads({ [UPLOAD_ID]: { bytes: new Uint8Array(0) } }));
860
+ await withTempDir(async (dir) => {
861
+ const path = join(dir, "source");
862
+ await materializeUpload(UPLOAD_ID, 0, path);
863
+ expect((await stat(path)).size).toBe(0);
864
+ });
865
+ });
866
+
867
+ test("fileChunks yields the file's real bytes, not a reused buffer", async () => {
868
+ // The aliasing bug, and the reason these specs touch a disk. Verified to CATCH
869
+ // it: deleting the `.slice()` in `fileChunks` fails this test.
870
+ await withTempDir(async (dir) => {
871
+ const path = join(dir, "audio.pcm");
872
+ const bytes = new Uint8Array(5000).map((_, at) => (at * 7) % 251);
873
+ await writeFile(path, bytes);
874
+
875
+ // A 1 KB window over 5 KB of bytes, so there are five reads through ONE
876
+ // buffer. That is what makes the aliasing reachable; collecting the chunks and
877
+ // concatenating afterwards is what makes it visible, since every earlier chunk
878
+ // would then read as the last one.
879
+ const collected: Uint8Array[] = [];
880
+ for await (const chunk of fileChunks(path, 1024)) collected.push(chunk);
881
+
882
+ const rejoined = new Uint8Array(collected.reduce((n, c) => n + c.byteLength, 0));
883
+ let at = 0;
884
+ for (const chunk of collected) {
885
+ rejoined.set(chunk, at);
886
+ at += chunk.byteLength;
887
+ }
888
+ expect(rejoined).toEqual(bytes);
889
+ });
890
+ });
891
+
892
+ test("fileChunks over an empty file yields nothing rather than one empty chunk", async () => {
893
+ await withTempDir(async (dir) => {
894
+ const path = join(dir, "empty");
895
+ await writeFile(path, new Uint8Array(0));
896
+ const collected: Uint8Array[] = [];
897
+ for await (const chunk of fileChunks(path)) collected.push(chunk);
898
+ expect(collected).toEqual([]);
899
+ });
900
+ });
901
+ });
902
+
903
+ describe("the ffmpeg steps, up to the spawn", () => {
904
+ /**
905
+ * These reach the point where ffmpeg would run and stop there, DETERMINISTICALLY
906
+ * — which is the trick that makes them unit tests rather than scenario tests.
907
+ *
908
+ * `AAI_FFMPEG_PATH` / `AAI_FFPROBE_PATH` name the binary the SDK resolves, so
909
+ * pointing them at a path that does not exist produces `kind: "missing-binary"`
910
+ * on every machine: one where ffmpeg is installed, one where it is not, and CI's
911
+ * Linux leg alike. A test that instead relied on ffmpeg being ABSENT would pass
912
+ * here and behave differently in CI, which is the green-locally/red-in-CI
913
+ * asymmetry this repo is built to avoid.
914
+ *
915
+ * What they cover is everything before the subprocess — reading the upload,
916
+ * materializing it, building the argv — plus the classification of the failure.
917
+ * They also pin the behaviour a developer actually meets: `aai dev` on a laptop
918
+ * with no ffmpeg is the one place dev/prod parity is partial, and it must fail
919
+ * FATALLY with an installable remedy rather than retry four times.
920
+ */
921
+ beforeEach(() => {
922
+ vi.stubEnv("AAI_FFMPEG_PATH", "/nonexistent/aai-test/ffmpeg");
923
+ vi.stubEnv("AAI_FFPROBE_PATH", "/nonexistent/aai-test/ffprobe");
924
+ });
925
+
926
+ test("ingestRecording materializes the upload, then fails fatally with no ffprobe", async () => {
927
+ restores.push(
928
+ stubUploads({
929
+ [UPLOAD_ID]: { bytes: new Uint8Array(2048), name: "call.m4a", type: "audio/mp4" },
930
+ }),
931
+ stubReporter().restore,
932
+ );
933
+ // Fatal, not retryable: four more attempts find the same missing binary, and the
934
+ // message already carries the install instructions.
935
+ await expect(ingestRecording(UPLOAD_ID)).rejects.toBeInstanceOf(FatalError);
936
+ // And the retry budget is still raised, for the I/O halves that ARE transient.
937
+ expect(ingestRecording.maxRetries).toBe(5);
938
+ });
939
+
940
+ test("narrate speaks first, then fails fatally with no ffmpeg to master with", async () => {
941
+ // `stepSpeak` runs — the synthesis is not what is broken here — so this also
942
+ // pins the ORDER: a step that mastered before speaking would fail without ever
943
+ // calling the voice service.
944
+ const speech = stubSpeech();
945
+ restores.push(speech.restore, stubReporter().restore, stubUploads({}, { writable: true }));
946
+
947
+ await expect(narrate("Read this back.", "jane")).rejects.toBeInstanceOf(FatalError);
948
+ expect(speech.calls.length).toBe(1);
949
+ expect(speech.calls[0]?.text).toBe("Read this back.");
950
+ });
951
+
952
+ test("the temp directory is gone even though the mastering pass failed", async () => {
953
+ // The `finally` in `withTempDir`, on the path that matters: a guest's disk is
954
+ // small, and a step that leaked a directory per failed run would fill it.
955
+ const before = await readdir(tmpdir());
956
+ const speech = stubSpeech();
957
+ restores.push(speech.restore, stubReporter().restore, stubUploads({}, { writable: true }));
958
+
959
+ await expect(narrate("Read this back.")).rejects.toBeInstanceOf(FatalError);
960
+ const after = await readdir(tmpdir());
961
+ expect(after.filter((name) => name.startsWith("aai-call-audit-"))).toEqual(
962
+ before.filter((name) => name.startsWith("aai-call-audit-")),
963
+ );
964
+ });
965
+ });