@alexkroman1/aai-cli 6.10.1 → 7.0.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 (162) hide show
  1. package/dist/{_agent-DpH2pBJd.mjs → _agent-BTwZJu4P.mjs} +2 -2
  2. package/dist/{_bundler-C2BDwU7r.mjs → _bundler-B4RqNF5Z.mjs} +2 -2
  3. package/dist/_dev-env.d.ts +22 -2
  4. package/dist/{_dev-server-LLLWnFBs.mjs → _dev-server-CiKFH9qw.mjs} +38 -47
  5. package/dist/_dev-server.d.ts +0 -17
  6. package/dist/{_init-C57bWAQ8.mjs → _init-CE4HKdgV.mjs} +3 -2
  7. package/dist/{_preflight-BtfaYtbE.mjs → _preflight-Co1G9Jww.mjs} +1 -1
  8. package/dist/_preflight.d.ts +1 -1
  9. package/dist/{_slug-api-BxrsJXVI.mjs → _slug-api-nRFaBEPJ.mjs} +2 -2
  10. package/dist/{_studio-U3jhKlA3.mjs → _studio-DR5yzZ1T.mjs} +2 -1
  11. package/dist/{_templates-Dxb_P2Wz.mjs → _templates-FI3xPEMj.mjs} +1 -1
  12. package/dist/_workflow-scan.d.ts +108 -0
  13. package/dist/{build-Dfza2pRI.mjs → build-6jfaakgc.mjs} +5 -2
  14. package/dist/cli.mjs +30 -25
  15. package/dist/{client-bundler-BEIqgOtd.mjs → client-bundler-CWnG42cU.mjs} +2 -1
  16. package/dist/client-bundler.mjs +1 -1
  17. package/dist/{delete-vbpjGzqs.mjs → delete-Dl_LSfvI.mjs} +3 -3
  18. package/dist/{deploy-B8lubiRT.mjs → deploy-DvYeD8ia.mjs} +5 -5
  19. package/dist/{dev-Uq5ujP8a.mjs → dev-B59JIfWO.mjs} +1 -1
  20. package/dist/{eject-31gjtaHF.mjs → eject-C9WJyyr2.mjs} +1 -1
  21. package/dist/{init-l6xfU4xX.mjs → init-CpJVjEg0.mjs} +3 -3
  22. package/dist/{login-C71-qz8F.mjs → login-BBuM1sxH.mjs} +3 -4
  23. package/dist/{logs-BWVGSjLR.mjs → logs-9q-psa4q.mjs} +2 -2
  24. package/dist/project-config.mjs +1 -1
  25. package/dist/scaffold/CLAUDE.md +93 -14
  26. package/dist/scaffold/package.json +5 -3
  27. package/dist/scaffold/server.mjs +13 -4
  28. package/dist/scaffold/vite.config.ts +1 -1
  29. package/dist/{secret-BuMuFR4B.mjs → secret-CVvSLIDV.mjs} +2 -2
  30. package/dist/{storage-DzRZ-eCw.mjs → storage-BvUrnvM3.mjs} +2 -2
  31. package/dist/{studio-BCNUpDgP.mjs → studio--MUV0cid.mjs} +4 -4
  32. package/dist/templates/call-audit/agent.test.ts +818 -0
  33. package/dist/templates/call-audit/agent.ts +171 -0
  34. package/dist/templates/call-audit/client.tsx +198 -0
  35. package/dist/templates/call-audit/workflows/audit.ts +298 -0
  36. package/dist/templates/call-audit/workflows/ingest.ts +251 -0
  37. package/dist/templates/call-audit/workflows/media.ts +637 -0
  38. package/dist/templates/call-audit/workflows/summarize.ts +201 -0
  39. package/dist/templates/call-audit/workflows/sync-api.ts +44 -0
  40. package/dist/templates/dispatch-center/agent.test.ts +149 -25
  41. package/dist/templates/dispatch-center/client.tsx +239 -129
  42. package/dist/templates/dispatch-center/shared.ts +99 -1
  43. package/dist/templates/dispatch-center/system-prompt.md +3 -1
  44. package/dist/templates/dispatch-center/tools/incident_add_note.ts +16 -13
  45. package/dist/templates/dispatch-center/tools/incident_create.ts +19 -1
  46. package/dist/templates/dispatch-center/tools/incident_escalate.ts +68 -59
  47. package/dist/templates/dispatch-center/tools/incident_triage.ts +57 -43
  48. package/dist/templates/dispatch-center/tools/incident_update_status.ts +49 -40
  49. package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +8 -2
  50. package/dist/templates/dispatch-center/tools/resources_dispatch.ts +69 -52
  51. package/dist/templates/dispatch-center/tools/resources_update_status.ts +45 -41
  52. package/dist/templates/embedded-assets/agent.test.ts +17 -14
  53. package/dist/templates/health-assistant/agent.test.ts +22 -4
  54. package/dist/templates/infocom-adventure/agent.test.ts +4 -6
  55. package/dist/templates/infocom-adventure/client.tsx +246 -164
  56. package/dist/templates/link-digest/agent.test.ts +24 -19
  57. package/dist/templates/link-digest/client.tsx +47 -61
  58. package/dist/templates/link-digest/workflows/digest.ts +19 -31
  59. package/dist/templates/night-owl/agent.test.ts +70 -19
  60. package/dist/templates/night-owl/agent.ts +5 -0
  61. package/dist/templates/night-owl/client.tsx +56 -56
  62. package/dist/templates/night-owl/shared.ts +24 -0
  63. package/dist/templates/night-owl/tools/recommend.ts +18 -6
  64. package/dist/templates/pizza-ordering/agent.test.ts +27 -27
  65. package/dist/templates/pizza-ordering/client.tsx +9 -26
  66. package/dist/templates/plan-and-execute/agent.test.ts +99 -58
  67. package/dist/templates/plan-and-execute/agent.ts +1 -1
  68. package/dist/templates/plan-and-execute/client.tsx +12 -15
  69. package/dist/templates/plan-and-execute/shared.ts +71 -2
  70. package/dist/templates/plan-and-execute/tools/plan_status.ts +18 -6
  71. package/dist/templates/plan-and-execute/tools/revise_plan.ts +18 -10
  72. package/dist/templates/plan-and-execute/tools/start_plan.ts +13 -2
  73. package/dist/templates/plan-and-execute/tools/work_next_step.ts +70 -22
  74. package/dist/templates/podcast-digest/agent.test.ts +746 -0
  75. package/dist/templates/podcast-digest/agent.ts +139 -0
  76. package/dist/templates/podcast-digest/client.tsx +154 -0
  77. package/dist/templates/podcast-digest/workflows/digest.ts +411 -0
  78. package/dist/templates/podcast-digest/workflows/feeds.ts +507 -0
  79. package/dist/templates/podcast-digest/workflows/slack.ts +209 -0
  80. package/dist/templates/recap-workflow/agent.test.ts +107 -96
  81. package/dist/templates/recap-workflow/tools/recap_progress.ts +9 -12
  82. package/dist/templates/recap-workflow/tools/recap_status.ts +8 -9
  83. package/dist/templates/recap-workflow/tools/request_recap.ts +2 -1
  84. package/dist/templates/recap-workflow/workflows/recap.ts +48 -41
  85. package/dist/templates/redline/agent.test.ts +17 -15
  86. package/dist/templates/redline/client.tsx +12 -12
  87. package/dist/templates/redline/workflows/redline.ts +19 -31
  88. package/dist/templates/research-workflow/agent.test.ts +60 -59
  89. package/dist/templates/research-workflow/tools/research_progress.ts +9 -12
  90. package/dist/templates/research-workflow/tools/research_status.ts +9 -11
  91. package/dist/templates/research-workflow/workflows/research.ts +44 -61
  92. package/dist/templates/retail/agent.test.ts +26 -23
  93. package/dist/templates/retail/client.tsx +226 -117
  94. package/dist/templates/retail/registry.test.ts +38 -6
  95. package/dist/templates/retail/store.test.ts +82 -15
  96. package/dist/templates/retail/store.ts +174 -47
  97. package/dist/templates/retail/system-prompt.md +11 -2
  98. package/dist/templates/retail/tools/cancel_pending_order.ts +2 -2
  99. package/dist/templates/retail/tools/exchange_delivered_order_items.ts +2 -2
  100. package/dist/templates/retail/tools/find_user_id_by_email.ts +8 -5
  101. package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +4 -5
  102. package/dist/templates/retail/tools/get_item_details.ts +3 -4
  103. package/dist/templates/retail/tools/get_order_details.ts +3 -4
  104. package/dist/templates/retail/tools/get_product_details.ts +3 -4
  105. package/dist/templates/retail/tools/get_user_details.ts +2 -2
  106. package/dist/templates/retail/tools/list_all_product_types.ts +5 -5
  107. package/dist/templates/retail/tools/modify_pending_order_address.ts +2 -2
  108. package/dist/templates/retail/tools/modify_pending_order_items.ts +2 -3
  109. package/dist/templates/retail/tools/modify_pending_order_payment.ts +2 -2
  110. package/dist/templates/retail/tools/modify_user_address.ts +2 -2
  111. package/dist/templates/retail/tools/return_delivered_order_items.ts +2 -2
  112. package/dist/templates/retail/tools/transfer_to_human_agents.ts +8 -4
  113. package/dist/templates/solo-rpg/agent.test.ts +227 -48
  114. package/dist/templates/solo-rpg/client.tsx +38 -37
  115. package/dist/templates/solo-rpg/shared.ts +145 -19
  116. package/dist/templates/solo-rpg/system-prompt.md +3 -2
  117. package/dist/templates/solo-rpg/tools/action_roll.ts +82 -63
  118. package/dist/templates/solo-rpg/tools/burn_momentum.ts +85 -50
  119. package/dist/templates/solo-rpg/tools/check_state.ts +24 -7
  120. package/dist/templates/solo-rpg/tools/load_game.ts +13 -1
  121. package/dist/templates/solo-rpg/tools/save_game.ts +16 -7
  122. package/dist/templates/solo-rpg/tools/setup_character.ts +22 -2
  123. package/dist/templates/solo-rpg/tools/update_state.ts +113 -100
  124. package/dist/templates/spoken-summary/agent.test.ts +314 -0
  125. package/dist/templates/spoken-summary/agent.ts +155 -0
  126. package/dist/templates/spoken-summary/client.tsx +185 -0
  127. package/dist/templates/spoken-summary/workflows/summarize.ts +237 -0
  128. package/dist/templates/spoken-summary/workflows/transcribe.ts +138 -0
  129. package/dist/templates/support-line/agent.test.ts +11 -16
  130. package/dist/templates/support-line/agent.ts +1 -1
  131. package/dist/templates/support-line/client.tsx +9 -9
  132. package/dist/templates/support-line/nodes.ts +100 -0
  133. package/dist/templates/support-line/procedure.ts +407 -0
  134. package/dist/templates/support-line/tools/answer_question.ts +17 -4
  135. package/dist/templates/transcription-workflow/agent.test.ts +246 -123
  136. package/dist/templates/transcription-workflow/agent.ts +21 -7
  137. package/dist/templates/transcription-workflow/client.tsx +17 -42
  138. package/dist/templates/transcription-workflow/workflows/batch.ts +79 -180
  139. package/dist/templates/transcription-workflow/workflows/normalize.ts +247 -0
  140. package/dist/templates/transcription-workflow/workflows/stitch.ts +0 -11
  141. package/dist/templates/transcription-workflow/workflows/stream.ts +10 -9
  142. package/dist/templates/transcription-workflow/workflows/sync-api.ts +26 -94
  143. package/dist/templates/transcription-workflow/workflows/transcribe.ts +55 -40
  144. package/dist/templates/transcription-workflow/workflows/wav.ts +31 -31
  145. package/dist/templates/travel-concierge/agent.test.ts +64 -33
  146. package/dist/templates/travel-concierge/client.tsx +11 -23
  147. package/dist/templates/travel-concierge/routing.ts +34 -15
  148. package/dist/templates/travel-concierge/shared.ts +70 -3
  149. package/dist/templates/travel-concierge/tools/book_car_rental.ts +2 -2
  150. package/dist/templates/travel-concierge/tools/book_excursion.ts +2 -2
  151. package/dist/templates/travel-concierge/tools/book_hotel.ts +2 -2
  152. package/dist/templates/travel-concierge/tools/cancel_ticket.ts +2 -2
  153. package/dist/templates/travel-concierge/tools/update_ticket.ts +2 -2
  154. package/dist/{worker-bundler-CGD4r8Kc.mjs → worker-bundler-COxnqstQ.mjs} +221 -3
  155. package/dist/worker-bundler.mjs +1 -1
  156. package/dist/{workflow-CFpxOFfQ.mjs → workflow-D2AQf2Pl.mjs} +28 -1
  157. package/dist/workflow-bundler.d.ts +6 -1
  158. package/dist/workflow.d.ts +1 -1
  159. package/package.json +5 -4
  160. package/dist/templates/support-line/graph.ts +0 -224
  161. package/dist/{_config-D_s09e7g.mjs → _config-CmJOFsAP.mjs} +1 -1
  162. /package/dist/templates/plan-and-execute/{graph.ts → procedure.ts} +0 -0
@@ -0,0 +1,818 @@
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 } from "node:fs/promises";
27
+ import { tmpdir } from "node:os";
28
+ import { stubSpeech } from "@alexkroman1/aai/testing";
29
+ import {
30
+ installStubGateway,
31
+ installStubReporter,
32
+ installStubSpeech,
33
+ installStubTranscribe,
34
+ installStubUploads,
35
+ } from "@alexkroman1/aai/testing/vitest";
36
+ import { beforeEach, describe, expect, test, vi } from "vitest";
37
+ import { FatalError, RetryableError } from "workflow";
38
+ import agentDef, { audit } from "./agent.ts";
39
+ import { joinSegments, now, SEGMENT_CONCURRENCY, transcribeSegment } from "./workflows/audit.ts";
40
+ import { analyse, ingestRecording } from "./workflows/ingest.ts";
41
+ import {
42
+ ANALYSIS_FORMAT,
43
+ BYTES_PER_SECOND,
44
+ durationSeconds,
45
+ type Loudness,
46
+ MAX_SEGMENT_SECONDS,
47
+ MediaAnalysisError,
48
+ MIN_SILENCE_SECONDS,
49
+ masterArgs,
50
+ measureLoudnessArgs,
51
+ normalizeArgs,
52
+ parseLoudness,
53
+ parseSilences,
54
+ planSegments,
55
+ type Silence,
56
+ speechFraction,
57
+ } from "./workflows/media.ts";
58
+ import { narrate, summarize } from "./workflows/summarize.ts";
59
+
60
+ /** The id every spec below uploads under. */
61
+ const UPLOAD_ID = "upl_test";
62
+
63
+ /**
64
+ * `loudnorm`'s first pass, exactly as ffmpeg 6.1.1 printed it to stderr.
65
+ *
66
+ * Kept verbatim — the leading log line, the blank line, the tab indentation, and
67
+ * every value as a STRING. Two of those are load-bearing: the parser has to FIND
68
+ * the block rather than assume the text starts with it, and it has to coerce
69
+ * strings rather than read numbers.
70
+ */
71
+ const LOUDNORM_STDERR = `[Parsed_loudnorm_0 @ 0x558882c84140]
72
+ {
73
+ \t"input_i" : "-16.19",
74
+ \t"input_tp" : "-7.42",
75
+ \t"input_lra" : "4.80",
76
+ \t"input_thresh" : "-26.34",
77
+ \t"output_i" : "-16.13",
78
+ \t"output_tp" : "-7.48",
79
+ \t"output_lra" : "4.80",
80
+ \t"output_thresh" : "-26.26",
81
+ \t"normalization_type" : "dynamic",
82
+ \t"target_offset" : "0.13"
83
+ }
84
+ `;
85
+
86
+ /**
87
+ * What `ametadata=mode=print:file=…` wrote for a 20-second recording, verbatim.
88
+ *
89
+ * Two things about it are the whole reason `parseSilences` is not a two-line
90
+ * regex, and both were verified rather than assumed:
91
+ *
92
+ * - The event times are NOT the frame's `pts_time`. `silence_start=3` sits on a
93
+ * frame at 3.599, because the filter needed 0.6s of silence to be sure.
94
+ * - The LAST event has no `silence_end`. The recording ended during a pause, so
95
+ * the filter never saw the sound come back.
96
+ */
97
+ const SILENCE_LOG = `frame:155 pts:158720 pts_time:3.59909
98
+ lavfi.silence_start=3
99
+ frame:215 pts:220160 pts_time:4.99229
100
+ lavfi.silence_end=5.00005
101
+ lavfi.silence_duration=2.00005
102
+ frame:370 pts:378880 pts_time:8.59138
103
+ lavfi.silence_start=7.99998
104
+ frame:430 pts:440320 pts_time:9.98458
105
+ lavfi.silence_end=10
106
+ lavfi.silence_duration=2.00007
107
+ frame:585 pts:599040 pts_time:13.5837
108
+ lavfi.silence_start=13
109
+ frame:645 pts:660480 pts_time:14.9769
110
+ lavfi.silence_end=15
111
+ lavfi.silence_duration=2.00007
112
+ frame:801 pts:820224 pts_time:18.5992
113
+ lavfi.silence_start=18
114
+ `;
115
+
116
+ /**
117
+ * Bytes the real normalize pass wrote for the recording {@link SILENCE_LOG} came
118
+ * from, and the exact seconds that is.
119
+ *
120
+ * Not 20 seconds: AAC carries encoder priming samples, so decoding a nominally
121
+ * 20-second file yields 20.015625. The difference is small and it is the whole
122
+ * reason `planSegments` takes a byte count — see `durationSeconds`.
123
+ */
124
+ const REAL_PCM_BYTES = 640_500;
125
+ const EXACT_SECONDS = durationSeconds(REAL_PCM_BYTES);
126
+
127
+ /** The measurement above, parsed — the input the second pass's argv is built from. */
128
+ const MEASURED: Loudness = {
129
+ inputLufs: -16.19,
130
+ inputTruePeak: -7.42,
131
+ inputRange: 4.8,
132
+ inputThreshold: -26.34,
133
+ targetOffset: 0.13,
134
+ };
135
+
136
+ /**
137
+ * The bytes one stubbed request carried, ASSERTED rather than cast.
138
+ *
139
+ * A `throw` returns `never`, so this narrows without an `as Uint8Array` — and the
140
+ * cast is what a first draft reaches for, which Biome then rejects as unsafe
141
+ * optional chaining, because a missing call would throw on `.byteLength` rather
142
+ * than fail with a message. A typed seam is the repo's remedy for a concentration
143
+ * of identical casts, and two is where it starts paying.
144
+ *
145
+ * A bare `throw` rather than `expect.fail`, which is the shape the repo prefers in
146
+ * a test BODY and which Biome's `noMisplacedAssertion` forbids in a helper — an
147
+ * assertion outside a `test()` is a real hazard, and here the throw is doing type
148
+ * narrowing rather than making a claim.
149
+ */
150
+ function sentBytes(body: unknown): Uint8Array {
151
+ if (!(body instanceof Uint8Array)) {
152
+ throw new TypeError("the stub records a Uint8Array request body");
153
+ }
154
+ return body;
155
+ }
156
+
157
+ /** A pause at each of `starts`, each exactly long enough to be a candidate. */
158
+ function pauses(...starts: number[]): Silence[] {
159
+ return starts.map((startSec) => ({ startSec, endSec: startSec + MIN_SILENCE_SECONDS }));
160
+ }
161
+
162
+ beforeEach(() => {
163
+ // The step env, which is where `requireStepEnv`, `stepSpeak` and the gateway
164
+ // read the key. `vi.stubEnv` rather than an assignment: `unstubEnvs` undoes it
165
+ // before every test, so nothing here has to remember to put it back.
166
+ vi.stubEnv("ASSEMBLYAI_API_KEY", "test-key");
167
+ });
168
+
169
+ describe("the declaration", () => {
170
+ test("is a workflow app with the one workflow the page starts by name", () => {
171
+ // The page calls `api.start("audit", …)`, so a rename here is a runtime 400
172
+ // rather than a compile error. This is what pins it.
173
+ expect(Object.keys(agentDef.workflows ?? {})).toEqual(["audit"]);
174
+ expect(agentDef.page).toBe("static");
175
+ });
176
+
177
+ test("declares no providers and exactly the one credential its steps read", () => {
178
+ // A workflow app has no session, so nothing else in its config could name one
179
+ // — and one AssemblyAI key covers transcription, the model and the voice.
180
+ // Note what is NOT here: ffmpeg. `requiredEnv` checks the environment, and a
181
+ // binary on `PATH` is not an environment variable.
182
+ expect(agentDef.requiredEnv).toEqual(["ASSEMBLYAI_API_KEY"]);
183
+ });
184
+
185
+ test("takes the recording as an UPLOAD, which is what makes the form a file picker", () => {
186
+ expect(audit.uploads).toEqual(["recording"]);
187
+ });
188
+
189
+ test("offers real voice ids, so the synthesis cannot fail silently in band", async () => {
190
+ // A wrong voice is accepted by the socket and refused in band, so the schema
191
+ // is the only thing that can catch one. The list is read from the SDK catalog.
192
+ expect(
193
+ (await audit.input?.["~standard"].validate({ recording: UPLOAD_ID, voice: "not-a-voice" }))
194
+ ?.issues,
195
+ ).toBeTruthy();
196
+ expect(
197
+ (await audit.input?.["~standard"].validate({ recording: UPLOAD_ID, voice: "jane" }))?.issues,
198
+ ).toBeUndefined();
199
+ });
200
+
201
+ test("accepts a recording with no voice chosen, so the SDK default applies", async () => {
202
+ expect(
203
+ (await audit.input?.["~standard"].validate({ recording: UPLOAD_ID }))?.issues,
204
+ ).toBeUndefined();
205
+ });
206
+ });
207
+
208
+ describe("the ffmpeg argv", () => {
209
+ test("every invocation is quiet, non-interactive, and overwrites", () => {
210
+ // `-nostdin` is the one that matters in a guest: an ffmpeg that decides to
211
+ // read stdin is a process that never exits.
212
+ for (const argv of [
213
+ measureLoudnessArgs("in.m4a"),
214
+ normalizeArgs("in.m4a", MEASURED, "out.pcm", "silence.txt"),
215
+ masterArgs("spoken.wav", "out.mp3"),
216
+ ]) {
217
+ expect(argv).toEqual(expect.arrayContaining(["-hide_banner", "-nostats", "-nostdin", "-y"]));
218
+ }
219
+ });
220
+
221
+ test("the measure pass writes no audio and asks for JSON", () => {
222
+ const argv = measureLoudnessArgs("in.m4a");
223
+ // `-f null -` is what makes this cost a decode and produce five numbers.
224
+ expect(argv.slice(-3)).toEqual(["-f", "null", "-"]);
225
+ expect(argv.join(" ")).toContain("print_format=json");
226
+ });
227
+
228
+ test("the measure pass runs at `info`, because that is where the JSON is printed", () => {
229
+ // The failure this pins is silent: at `-loglevel error` the pass still runs
230
+ // and still succeeds, and prints nothing at all — which reads as a parser bug.
231
+ const argv = measureLoudnessArgs("in.m4a");
232
+ expect(argv[argv.indexOf("-loglevel") + 1]).toBe("info");
233
+ });
234
+
235
+ test("the normalize pass stays quiet, because its analysis goes to a FILE", () => {
236
+ const argv = normalizeArgs("in.m4a", MEASURED, "out.pcm", "silence.txt");
237
+ expect(argv[argv.indexOf("-loglevel") + 1]).toBe("error");
238
+ // `ametadata` writes the path directly rather than through the log, which is
239
+ // the property that lets this pass be both silent and complete.
240
+ expect(argv.join(" ")).toContain("ametadata=mode=print:file=silence.txt");
241
+ });
242
+
243
+ test("the normalize pass feeds back every measured value", () => {
244
+ // A missing `measured_*` makes loudnorm silently run a ONE-pass normalization
245
+ // instead — no error, just a different result — so all five are pinned.
246
+ const filter = normalizeArgs("in.m4a", MEASURED, "out.pcm", "silence.txt").join(" ");
247
+ expect(filter).toContain("measured_I=-16.19");
248
+ expect(filter).toContain("measured_TP=-7.42");
249
+ expect(filter).toContain("measured_LRA=4.8");
250
+ expect(filter).toContain("measured_thresh=-26.34");
251
+ expect(filter).toContain("offset=0.13");
252
+ // One constant gain rather than a moving one, so speech does not pump.
253
+ expect(filter).toContain("linear=true");
254
+ });
255
+
256
+ test("the normalize pass writes HEADERLESS PCM in the analysis format", () => {
257
+ // The decision the whole template rests on: `-f s16le`, not `-f wav`, so byte
258
+ // zero is second zero and nothing has to parse a RIFF chunk list.
259
+ const argv = normalizeArgs("in.m4a", MEASURED, "out.pcm", "silence.txt");
260
+ expect(argv.slice(-3)).toEqual(["-f", "s16le", "out.pcm"]);
261
+ expect(argv[argv.indexOf("-ar") + 1]).toBe(String(ANALYSIS_FORMAT.sampleRate));
262
+ expect(argv[argv.indexOf("-ac") + 1]).toBe(String(ANALYSIS_FORMAT.channels));
263
+ expect(argv[argv.indexOf("-c:a") + 1]).toBe("pcm_s16le");
264
+ });
265
+
266
+ test("the filter chain is ONE argv element, so its commas never meet a shell", () => {
267
+ // `runFfmpeg` spawns without a shell, which is what makes an unquoted filter
268
+ // graph safe — and what would break if this were ever assembled into a string.
269
+ const argv = normalizeArgs("in.m4a", MEASURED, "out.pcm", "silence.txt");
270
+ const filter = argv[argv.indexOf("-af") + 1] ?? "";
271
+ expect(filter).toContain(",silencedetect=");
272
+ expect(filter.startsWith("loudnorm=")).toBe(true);
273
+ });
274
+
275
+ test("the mastering pass encodes MP3, which is the point of running it", () => {
276
+ const argv = masterArgs("spoken.wav", "out.mp3");
277
+ expect(argv[argv.indexOf("-c:a") + 1]).toBe("libmp3lame");
278
+ expect(argv.at(-1)).toBe("out.mp3");
279
+ });
280
+ });
281
+
282
+ describe("reading the loudness measurement", () => {
283
+ test("parses ffmpeg's own block, strings and all", () => {
284
+ expect(parseLoudness(LOUDNORM_STDERR)).toEqual(MEASURED);
285
+ });
286
+
287
+ test("finds the block after whatever info-level chatter preceded it", () => {
288
+ // The pass runs at `-loglevel info`, so the captured stderr tail holds stream
289
+ // descriptions and a muxing-overhead line before the JSON.
290
+ const noisy = ` Stream #0:0(und): Audio: pcm_s16le, 192000 Hz, mono\nsize=N/A time=00:00:17.20\n${LOUDNORM_STDERR}`;
291
+ expect(parseLoudness(noisy)).toEqual(MEASURED);
292
+ });
293
+
294
+ test("takes the LAST block, so a retried pass reads its own numbers", () => {
295
+ const twice = LOUDNORM_STDERR + LOUDNORM_STDERR.replace('"-16.19"', '"-20.50"');
296
+ expect(parseLoudness(twice).inputLufs).toBe(-20.5);
297
+ });
298
+
299
+ test("names the missing `-loglevel info` when nothing was printed", () => {
300
+ // The most likely way this breaks, so the message says how to fix it rather
301
+ // than reporting a parse failure.
302
+ expect(() => parseLoudness("size=N/A time=00:00:17.20\n")).toThrow(MediaAnalysisError);
303
+ expect(() => parseLoudness("")).toThrow(/-loglevel info/);
304
+ });
305
+
306
+ test("names the KEY when a value is missing or unparseable", () => {
307
+ // A silent `NaN` would flow into the second pass's argv as the literal text
308
+ // `NaN` and come back as an ffmpeg option-parsing error about a filter.
309
+ const missing = LOUDNORM_STDERR.replace('"input_tp" : "-7.42",', "");
310
+ expect(() => parseLoudness(missing)).toThrow(/input_tp/);
311
+ });
312
+
313
+ test("refuses a block that is not JSON at all", () => {
314
+ expect(() => parseLoudness("[loudnorm] {not json}")).toThrow(MediaAnalysisError);
315
+ });
316
+ });
317
+
318
+ describe("reading the pauses", () => {
319
+ test("parses every event ffmpeg logged, and closes the unterminated one", () => {
320
+ // The trailing `silence_start=18` has no `silence_end` — the recording ended
321
+ // during the pause. It is closed at the duration, which is why this function
322
+ // takes one.
323
+ expect(parseSilences(SILENCE_LOG, EXACT_SECONDS)).toEqual([
324
+ { startSec: 3, endSec: 5.000_05 },
325
+ { startSec: 7.999_98, endSec: 10 },
326
+ { startSec: 13, endSec: 15 },
327
+ { startSec: 18, endSec: EXACT_SECONDS },
328
+ ]);
329
+ });
330
+
331
+ test("reads the EVENT time, not the frame's", () => {
332
+ // `silence_start=3` sits on a frame at `pts_time:3.59909`. Reading the frame
333
+ // would put every cut 0.6s late and lose the word before it.
334
+ const [first] = parseSilences(SILENCE_LOG, EXACT_SECONDS);
335
+ expect(first?.startSec).toBe(3);
336
+ });
337
+
338
+ test("an empty log is no pauses, not a failure", () => {
339
+ // Verified against ffmpeg 6.1: `ametadata` creates the file at filter-init, so
340
+ // a recording with no pause in it leaves an empty log rather than none.
341
+ expect(parseSilences("", 30)).toEqual([]);
342
+ });
343
+
344
+ test("a pause that never ended before a recording that already did is dropped", () => {
345
+ // `durationSec` is measured from the PCM byte count and the log from the same
346
+ // pass, so they agree — but a zero-length pause would produce a cut candidate
347
+ // at the very end, which `planSegments` would then have to reject.
348
+ expect(parseSilences("lavfi.silence_start=30\n", 30)).toEqual([]);
349
+ });
350
+
351
+ test("an end without a start is ignored rather than inventing a pause at zero", () => {
352
+ expect(parseSilences("lavfi.silence_end=4\n", 30)).toEqual([]);
353
+ });
354
+
355
+ test("a truncated numeric value is skipped rather than read as NaN", () => {
356
+ // A `NaN` boundary would make every downstream comparison false, so the cut
357
+ // planner would silently fall back to blind cuts for the whole recording.
358
+ expect(parseSilences("lavfi.silence_start=\nlavfi.silence_end=4\n", 30)).toEqual([]);
359
+ });
360
+ });
361
+
362
+ describe("planning where to cut", () => {
363
+ test("a recording inside the cap is one segment, whatever its pauses", () => {
364
+ // Nothing to decide, and the pauses must not tempt the planner into cutting: a
365
+ // request per pause would be dozens of requests for a two-minute call.
366
+ const segments = planSegments(pauses(10, 20, 30), 60 * BYTES_PER_SECOND);
367
+ expect(segments).toHaveLength(1);
368
+ expect(segments[0]).toMatchObject({ index: 0, startByte: 0, startMs: 0, endMs: 60_000 });
369
+ expect(segments[0]?.endByte).toBe(60 * BYTES_PER_SECOND);
370
+ });
371
+
372
+ test("cuts at the LAST pause that still fits, not the first", () => {
373
+ // Greedy from the front, so segments are as long as the cap allows — the
374
+ // alternative is twice as many requests for the same audio.
375
+ const segments = planSegments(pauses(30, 60, 100), 150 * BYTES_PER_SECOND);
376
+ // The pause at 100 spans 100–100.6, so its midpoint is 100.3.
377
+ expect(segments.map((s) => s.endMs)).toEqual([100_300, 150_000]);
378
+ });
379
+
380
+ test("the cut is the pause's MIDPOINT, so neither side is clipped", () => {
381
+ // Cutting at the start clips the decay of the word before; cutting at the end
382
+ // clips the attack of the word after.
383
+ const segments = planSegments([{ startSec: 100, endSec: 101 }], 150 * BYTES_PER_SECOND);
384
+ expect(segments[0]?.endMs).toBe(100_500);
385
+ });
386
+
387
+ test("segments are contiguous and non-overlapping, which is what deletes the stitcher", () => {
388
+ const segments = planSegments(pauses(50, 100, 160, 210), 260 * BYTES_PER_SECOND);
389
+ expect(segments.length).toBeGreaterThan(1);
390
+ for (const [i, segment] of segments.entries()) {
391
+ if (i === 0) continue;
392
+ expect(segment.startByte).toBe(segments[i - 1]?.endByte);
393
+ expect(segment.startMs).toBe(segments[i - 1]?.endMs);
394
+ }
395
+ expect(segments[0]?.startByte).toBe(0);
396
+ expect(segments.at(-1)?.endByte).toBe(260 * BYTES_PER_SECOND);
397
+ });
398
+
399
+ test("no segment exceeds the endpoint's cap", () => {
400
+ const segments = planSegments(pauses(20, 40, 61, 130, 200), 400 * BYTES_PER_SECOND);
401
+ for (const segment of segments) {
402
+ expect(segment.endMs - segment.startMs).toBeLessThanOrEqual(MAX_SEGMENT_SECONDS * 1000);
403
+ }
404
+ });
405
+
406
+ test("an unbroken monologue falls back to a blind cut, and SAYS so", () => {
407
+ // The case the pretty invariant cannot serve. Refusing it would be the worse
408
+ // trade, so it degrades to exactly what `transcription-workflow` does — and
409
+ // reports it, because a mangled word at a seam is otherwise a mystery.
410
+ const segments = planSegments([], 300 * BYTES_PER_SECOND);
411
+ expect(segments).toHaveLength(3);
412
+ expect(segments.map((s) => s.endMs)).toEqual([110_000, 220_000, 300_000]);
413
+ expect(segments.map((s) => s.cutInSpeech)).toEqual([true, true, false]);
414
+ });
415
+
416
+ test("a cut that landed in a pause is NOT reported as a blind cut", () => {
417
+ const segments = planSegments(pauses(100), 150 * BYTES_PER_SECOND);
418
+ expect(segments.map((s) => s.cutInSpeech)).toEqual([false, false]);
419
+ });
420
+
421
+ test("a tail too short to be worth a request joins its predecessor", () => {
422
+ // The endpoint refuses audio under 80ms, and a 0.4-second tail holds at most one
423
+ // word — but the word is a word, so it is merged rather than dropped. A pause at
424
+ // 150 leaves the predecessor well short of the cap, which is what makes the merge
425
+ // legal; see the cap test below for when it is not.
426
+ const segments = planSegments(pauses(150), 200.5 * BYTES_PER_SECOND);
427
+ expect(segments).toHaveLength(2);
428
+ expect(segments.at(-1)?.endMs).toBe(200_500);
429
+ });
430
+
431
+ test("a short tail is NOT absorbed into a segment already at the cap", () => {
432
+ // The greedy loop leaves a final segment of at most `MAX_SEGMENT_SECONDS`, so
433
+ // merging a sub-second tail into a predecessor already at the cap would make one
434
+ // 110.5 seconds long — inside the endpoint's own 120-second limit, and outside
435
+ // the bound this module promises. A short final request is the cheaper mistake,
436
+ // and it is still an order of magnitude above the 80ms the endpoint refuses.
437
+ const segments = planSegments([], 110.5 * BYTES_PER_SECOND);
438
+ expect(segments).toHaveLength(2);
439
+ for (const segment of segments) {
440
+ expect(segment.endMs - segment.startMs).toBeLessThanOrEqual(MAX_SEGMENT_SECONDS * 1000);
441
+ }
442
+ // Still contiguous, still covering the whole recording.
443
+ expect(segments[0]?.endByte).toBe(segments[1]?.startByte);
444
+ expect(segments.at(-1)?.endMs).toBe(110_500);
445
+ });
446
+
447
+ test("every byte offset lands on a sample-frame boundary", () => {
448
+ // A byte offset mid-sample shifts every sample after it by one byte, which is
449
+ // not a click — it is white noise the decoder transcribes into confident
450
+ // nonsense.
451
+ const frame = (ANALYSIS_FORMAT.channels * ANALYSIS_FORMAT.bitsPerSample) / 8;
452
+ for (const segment of planSegments(pauses(37.333, 88.777, 150.5), 200 * BYTES_PER_SECOND)) {
453
+ expect(segment.startByte % frame).toBe(0);
454
+ expect(segment.endByte % frame).toBe(0);
455
+ }
456
+ });
457
+
458
+ test("an empty recording plans nothing rather than one empty request", () => {
459
+ expect(planSegments([], 0)).toEqual([]);
460
+ });
461
+
462
+ test("the real 20-second capture is one segment covering all of it", () => {
463
+ // End to end over the captured fixture, which is the case a reader can check
464
+ // against the numbers in `SILENCE_LOG`.
465
+ const silences = parseSilences(SILENCE_LOG, EXACT_SECONDS);
466
+ const segments = planSegments(silences, REAL_PCM_BYTES);
467
+ expect(segments).toHaveLength(1);
468
+ expect(segments[0]?.cutInSpeech).toBe(false);
469
+ });
470
+
471
+ test("the last segment never addresses a byte the file does not have", () => {
472
+ // The twelve-byte bug this API shape exists to prevent: planning from a
473
+ // duration rounded to whole milliseconds put `endByte` at 640,512 for a
474
+ // 640,500-byte file. `readUpload` clamps a window to the stored size, so
475
+ // nothing threw — the plan was simply describing audio that does not exist.
476
+ // Found by running the real argv against a real ffmpeg, which is the only
477
+ // place a twelve-byte error was ever going to surface.
478
+ for (const bytes of [REAL_PCM_BYTES, 32_001, 999_999, 2]) {
479
+ const segments = planSegments(parseSilences(SILENCE_LOG, durationSeconds(bytes)), bytes);
480
+ expect(segments.at(-1)?.endByte ?? 0).toBeLessThanOrEqual(bytes);
481
+ }
482
+ });
483
+ });
484
+
485
+ describe("what the page is told about the recording", () => {
486
+ test("speech is the complement of the pauses", () => {
487
+ // 20 seconds with 8 of pause in it.
488
+ expect(
489
+ Math.round(
490
+ speechFraction(
491
+ [
492
+ { startSec: 3, endSec: 5 },
493
+ { startSec: 8, endSec: 10 },
494
+ { startSec: 13, endSec: 15 },
495
+ { startSec: 18, endSec: 20 },
496
+ ],
497
+ 20,
498
+ ) * 100,
499
+ ),
500
+ ).toBe(60);
501
+ });
502
+
503
+ test("a recording of pure silence is 0% speech, and one with no pause is 100%", () => {
504
+ expect(speechFraction([{ startSec: 0, endSec: 30 }], 30)).toBe(0);
505
+ expect(speechFraction([], 30)).toBe(1);
506
+ });
507
+
508
+ test("a zero-length recording answers 0 rather than dividing by it", () => {
509
+ expect(speechFraction([], 0)).toBe(0);
510
+ });
511
+ });
512
+
513
+ describe("joining the segments", () => {
514
+ /** Two segments whose shared boundary is `cutInSpeech` on the first. */
515
+ function pair(cutInSpeech: boolean) {
516
+ return [
517
+ { index: 0, startByte: 0, endByte: 10, startMs: 0, endMs: 10, cutInSpeech },
518
+ { index: 1, startByte: 10, endByte: 20, startMs: 10, endMs: 20, cutInSpeech: false },
519
+ ];
520
+ }
521
+
522
+ test("a cut made in a pause becomes a paragraph break", () => {
523
+ // The plan knows where the turn boundaries are, so the transcript can show
524
+ // them. This is the one place `cutInSpeech` changes an output, not a report.
525
+ expect(
526
+ joinSegments(pair(false), [
527
+ { index: 0, text: "Good morning." },
528
+ { index: 1, text: "Let us begin." },
529
+ ]),
530
+ ).toBe("Good morning.\n\nLet us begin.");
531
+ });
532
+
533
+ test("a blind cut is joined with a space, because it landed mid-sentence", () => {
534
+ expect(
535
+ joinSegments(pair(true), [
536
+ { index: 0, text: "the number was" },
537
+ { index: 1, text: "roughly four" },
538
+ ]),
539
+ ).toBe("the number was roughly four");
540
+ });
541
+
542
+ test("puts the parts in index order however they arrived", () => {
543
+ // `mapConcurrent` resolves in item order, so this is belt and braces — a merge
544
+ // is where an ordering mistake would be invisible rather than loud.
545
+ expect(
546
+ joinSegments(pair(true), [
547
+ { index: 1, text: "second" },
548
+ { index: 0, text: "first" },
549
+ ]),
550
+ ).toBe("first second");
551
+ });
552
+
553
+ test("a segment that transcribed to nothing leaves no stray separator", () => {
554
+ // A stretch that is all room tone comes back empty, and a naive join would
555
+ // leave a leading or doubled break in the transcript.
556
+ expect(
557
+ joinSegments(pair(false), [
558
+ { index: 0, text: "" },
559
+ { index: 1, text: "words" },
560
+ ]),
561
+ ).toBe("words");
562
+ });
563
+ });
564
+
565
+ describe("transcribing one segment", () => {
566
+ /**
567
+ * One second of stored PCM, and a sync endpoint that answers.
568
+ *
569
+ * A published `stepFetch`, not `vi.stubGlobal("fetch", …)`: the step calls
570
+ * `stepFetch`, which reaches a published slot rather than the global. Stubbing
571
+ * the global still passes, because an unpublished slot falls back to it, and
572
+ * would be asserting against a path production does not take.
573
+ */
574
+ function stubProvider(failure?: { status: number; message: string }) {
575
+ installStubUploads({
576
+ [UPLOAD_ID]: {
577
+ bytes: new Uint8Array(BYTES_PER_SECOND),
578
+ name: "call.pcm",
579
+ type: "application/octet-stream",
580
+ },
581
+ });
582
+ installStubReporter();
583
+ // `installStubTranscribe` answers AssemblyAI's own endpoints off the SDK's
584
+ // endpoint constants, so this spec no longer re-types the wire — and a refusal
585
+ // is staged as a STATUS, which is what makes the classification below a test of
586
+ // the SDK's reading of it rather than of a `TranscribeError` a fake minted.
587
+ return installStubTranscribe({
588
+ text: "hello there",
589
+ failure: failure === undefined ? undefined : { leg: "sync", ...failure },
590
+ }).calls;
591
+ }
592
+
593
+ const SEGMENT = {
594
+ index: 0,
595
+ startByte: 0,
596
+ endByte: BYTES_PER_SECOND,
597
+ startMs: 0,
598
+ endMs: 1000,
599
+ cutInSpeech: false,
600
+ };
601
+
602
+ test("puts a WAV header back on the headerless span before sending it", async () => {
603
+ // The endpoint decodes each request independently, so a slice of raw PCM is
604
+ // meaningless bytes until a header says what they are. `encodeWav` is the SDK's
605
+ // — this template deliberately carries no copy of it.
606
+ const calls = stubProvider();
607
+ await expect(transcribeSegment(UPLOAD_ID, SEGMENT)).resolves.toEqual({
608
+ index: 0,
609
+ text: "hello there",
610
+ });
611
+
612
+ // Inside a multipart body, so the header is not at byte zero — what matters is
613
+ // that it is there at all, and immediately followed by `WAVE`.
614
+ const sent = new TextDecoder("latin1").decode(sentBytes(calls[0]?.body));
615
+ expect(sent).toContain("RIFF");
616
+ expect(sent.indexOf("WAVE")).toBe(sent.indexOf("RIFF") + 8);
617
+ });
618
+
619
+ test("sends the whole span, header included, and nothing else", async () => {
620
+ const calls = stubProvider();
621
+ await transcribeSegment(UPLOAD_ID, SEGMENT);
622
+ // 44 bytes of canonical header plus one second of audio, inside a multipart
623
+ // body — so the request is strictly larger than the span and close to it.
624
+ const size = sentBytes(calls[0]?.body).byteLength;
625
+ expect(size).toBeGreaterThan(BYTES_PER_SECOND);
626
+ expect(size).toBeLessThan(BYTES_PER_SECOND + 2000);
627
+ });
628
+
629
+ test("a rate limit is RETRYABLE, so one busy minute does not fail the run", async () => {
630
+ // The expected failure of a 32-wide fan-out, and the reason this step's
631
+ // `maxRetries` is above the default.
632
+ stubProvider({ status: 429, message: "slow down" });
633
+ await expect(transcribeSegment(UPLOAD_ID, SEGMENT)).rejects.toBeInstanceOf(RetryableError);
634
+ expect(transcribeSegment.maxRetries).toBe(5);
635
+ });
636
+
637
+ test("a rejected request is FATAL, so it is not asked five more times", async () => {
638
+ stubProvider({ status: 400, message: "that is not audio" });
639
+ await expect(transcribeSegment(UPLOAD_ID, SEGMENT)).rejects.toBeInstanceOf(FatalError);
640
+ });
641
+
642
+ test("the fan-out width is a constant, because the format is", () => {
643
+ // The payoff of normalizing: a segment is at most 3.5 MB whatever was
644
+ // uploaded, so there is no byte budget to divide — see the constant's doc.
645
+ expect(SEGMENT_CONCURRENCY).toBe(32);
646
+ expect(MAX_SEGMENT_SECONDS * BYTES_PER_SECOND * SEGMENT_CONCURRENCY).toBeLessThan(
647
+ 640 * 1024 * 1024,
648
+ );
649
+ });
650
+ });
651
+
652
+ describe("auditing the transcript", () => {
653
+ test("asks for a script as well as lists, and keeps both", async () => {
654
+ // The two-summaries decision: a voice reading a bullet list says "one. two.
655
+ // three." with no connective tissue, so the schema demands sentences too.
656
+ installStubReporter();
657
+ // The stub answers with TEXT, because that is what a gateway returns — and
658
+ // `stepGenerateJson` parsing it is part of what this exercises.
659
+ installStubGateway(
660
+ JSON.stringify({
661
+ headline: "Renewal call with Northwind",
662
+ risks: ["Nobody owns the migration date"],
663
+ actions: ["Ana to send revised pricing"],
664
+ spoken: "The renewal is close, but the migration date has no owner yet.",
665
+ }),
666
+ );
667
+
668
+ const summary = await summarize("… transcript …", "call.m4a", 600_000);
669
+ expect(summary.headline).toBe("Renewal call with Northwind");
670
+ expect(summary.risks).toEqual(["Nobody owns the migration date"]);
671
+ expect(summary.spoken).toContain("migration date");
672
+ });
673
+
674
+ test("an empty risk list is an ANSWER, not a retry", async () => {
675
+ // A schema that demanded a risk would get an invented one, which is worse than
676
+ // silence on a call that really had none.
677
+ installStubReporter();
678
+ installStubGateway(
679
+ JSON.stringify({
680
+ headline: "Weekly standup",
681
+ risks: [],
682
+ actions: [],
683
+ spoken: "Nothing was decided and nothing is blocked.",
684
+ }),
685
+ );
686
+ await expect(summarize("…", "standup.wav", 60_000)).resolves.toMatchObject({ risks: [] });
687
+ });
688
+
689
+ test("a reply with no spoken script is a RETRY rather than silence", async () => {
690
+ // `spoken` is required rather than defaulted precisely so this fails: a default
691
+ // would turn a missing field into half a second of audio nobody notices.
692
+ installStubReporter();
693
+ installStubGateway(JSON.stringify({ headline: "A call", risks: [], actions: [] }));
694
+ await expect(summarize("…", "call.m4a", 60_000)).rejects.toThrow();
695
+ });
696
+ });
697
+
698
+ describe("classifying a failure", () => {
699
+ /**
700
+ * The FFMPEG verdict is no longer tested here, and its absence is the change
701
+ * rather than a gap: `throwFfmpegStepError` on `@alexkroman1/aai/step-errors` owns
702
+ * it now, with both arms pinned in `sdk/step-errors.test.ts` — including the case
703
+ * this template contributed, a cause that is not an ffmpeg failure at all.
704
+ *
705
+ * What stays is what is still THIS desk's: `analyse`, which decides that an
706
+ * analysis `media.ts` cannot read is terminal.
707
+ */
708
+ test("an analysis this desk cannot read is fatal, because a retry reads it again", () => {
709
+ // ffmpeg SUCCEEDED and printed something unrecognized — a renamed key, a lost
710
+ // `-loglevel info`. Every retry runs the same binary and prints the same thing.
711
+ expect(() =>
712
+ analyse(() => {
713
+ throw new MediaAnalysisError("no JSON block");
714
+ }),
715
+ ).toThrow(FatalError);
716
+ });
717
+
718
+ test("an analysis helper's OTHER failures are not swallowed", () => {
719
+ // Only `MediaAnalysisError` is a verdict. Anything else is a bug in the parser,
720
+ // and turning that into a terminal step failure would hide it.
721
+ const bug = new TypeError("cannot read properties of undefined");
722
+ expect(() =>
723
+ analyse(() => {
724
+ throw bug;
725
+ }),
726
+ ).toThrow(bug);
727
+ });
728
+ });
729
+
730
+ describe("the run's clock", () => {
731
+ test("is a STEP, so a replay does not re-measure it", async () => {
732
+ // A `Date.now()` in the body returns a different value on every replay, and
733
+ // every duration derived from it would be a different duration.
734
+ vi.useFakeTimers();
735
+ try {
736
+ vi.setSystemTime(1_700_000_000_000);
737
+ await expect(now()).resolves.toBe(1_700_000_000_000);
738
+ } finally {
739
+ vi.useRealTimers();
740
+ }
741
+ });
742
+ });
743
+
744
+ describe("the mastered narration", () => {
745
+ test("is not driven here, and the spec says why rather than pretending", () => {
746
+ // `narrate` speaks, writes a temp file, spawns ffmpeg and stores the result. A
747
+ // unit test may do none of those, so what is covered is `masterArgs` above and
748
+ // the classification below — and `stubSpeech` is imported to make the omission
749
+ // deliberate rather than an oversight: filling only that slot would leave the
750
+ // step failing on the subprocess, which is a test of the tier, not the code.
751
+ expect(typeof stubSpeech).toBe("function");
752
+ expect(masterArgs("in.wav", "out.mp3").at(-1)).toBe("out.mp3");
753
+ });
754
+ });
755
+
756
+ describe("the ffmpeg steps, up to the spawn", () => {
757
+ /**
758
+ * These reach the point where ffmpeg would run and stop there, DETERMINISTICALLY
759
+ * — which is the trick that makes them unit tests rather than scenario tests.
760
+ *
761
+ * `AAI_FFMPEG_PATH` / `AAI_FFPROBE_PATH` name the binary the SDK resolves, so
762
+ * pointing them at a path that does not exist produces `kind: "missing-binary"`
763
+ * on every machine: one where ffmpeg is installed, one where it is not, and CI's
764
+ * Linux leg alike. A test that instead relied on ffmpeg being ABSENT would pass
765
+ * here and behave differently in CI, which is the green-locally/red-in-CI
766
+ * asymmetry this repo is built to avoid.
767
+ *
768
+ * What they cover is everything before the subprocess — reading the upload,
769
+ * materializing it, building the argv — plus the classification of the failure.
770
+ * They also pin the behaviour a developer actually meets: `aai dev` on a laptop
771
+ * with no ffmpeg is the one place dev/prod parity is partial, and it must fail
772
+ * FATALLY with an installable remedy rather than retry four times.
773
+ */
774
+ beforeEach(() => {
775
+ vi.stubEnv("AAI_FFMPEG_PATH", "/nonexistent/aai-test/ffmpeg");
776
+ vi.stubEnv("AAI_FFPROBE_PATH", "/nonexistent/aai-test/ffprobe");
777
+ });
778
+
779
+ test("ingestRecording materializes the upload, then fails fatally with no ffprobe", async () => {
780
+ installStubUploads({
781
+ [UPLOAD_ID]: { bytes: new Uint8Array(2048), name: "call.m4a", type: "audio/mp4" },
782
+ });
783
+ installStubReporter();
784
+ // Fatal, not retryable: four more attempts find the same missing binary, and the
785
+ // message already carries the install instructions.
786
+ await expect(ingestRecording(UPLOAD_ID)).rejects.toBeInstanceOf(FatalError);
787
+ // And the retry budget is still raised, for the I/O halves that ARE transient.
788
+ expect(ingestRecording.maxRetries).toBe(5);
789
+ });
790
+
791
+ test("narrate speaks first, then fails fatally with no ffmpeg to master with", async () => {
792
+ // `stepSpeak` runs — the synthesis is not what is broken here — so this also
793
+ // pins the ORDER: a step that mastered before speaking would fail without ever
794
+ // calling the voice service.
795
+ const speech = installStubSpeech();
796
+ installStubReporter();
797
+ installStubUploads({}, { writable: true });
798
+
799
+ await expect(narrate("Read this back.", "jane")).rejects.toBeInstanceOf(FatalError);
800
+ expect(speech.calls.length).toBe(1);
801
+ expect(speech.calls[0]?.text).toBe("Read this back.");
802
+ });
803
+
804
+ test("the temp directory is gone even though the mastering pass failed", async () => {
805
+ // The `finally` in `withTempDir`, on the path that matters: a guest's disk is
806
+ // small, and a step that leaked a directory per failed run would fill it.
807
+ const before = await readdir(tmpdir());
808
+ installStubSpeech();
809
+ installStubReporter();
810
+ installStubUploads({}, { writable: true });
811
+
812
+ await expect(narrate("Read this back.")).rejects.toBeInstanceOf(FatalError);
813
+ const after = await readdir(tmpdir());
814
+ expect(after.filter((name) => name.startsWith("aai-call-audit-"))).toEqual(
815
+ before.filter((name) => name.startsWith("aai-call-audit-")),
816
+ );
817
+ });
818
+ });