@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
@@ -17,16 +17,23 @@
17
17
  * the decoder happily transcribes into confident nonsense.
18
18
  */
19
19
 
20
- import { stubReporter, stubStepFetch, stubUploads } from "@alexkroman1/aai/testing";
21
- import { omitUndefined, readUpload } from "@alexkroman1/aai/utils";
22
- import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
20
+ import { readdir } from "node:fs/promises";
21
+ import { tmpdir } from "node:os";
22
+ import { readUpload } from "@alexkroman1/aai/step";
23
+ import {
24
+ installStubReporter,
25
+ installStubStepFetch,
26
+ installStubTranscribe,
27
+ installStubUploads,
28
+ } from "@alexkroman1/aai/testing/vitest";
29
+ import { beforeEach, describe, expect, test, vi } from "vitest";
23
30
  import { FatalError, RetryableError } from "workflow";
24
31
  import { z } from "zod";
25
32
  import agentDef, { transcribe, transcribeBatch, transcribeStream } from "./agent.ts";
26
- import { createJob, pollTranscript, readTranscript, uploadToProvider } from "./workflows/batch.ts";
27
- import { planStreamed, probeUpload } from "./workflows/stream.ts";
33
+ import { createJob, pollTranscript, uploadToProvider } from "./workflows/batch.ts";
34
+ import { cuttable, normalizeRecording } from "./workflows/normalize.ts";
35
+ import { expectedSegments, planStreamed, probeUpload } from "./workflows/stream.ts";
28
36
  import {
29
- clock,
30
37
  mergeTranscript,
31
38
  splitRecording,
32
39
  stitchChunks,
@@ -45,12 +52,8 @@ import {
45
52
  SEGMENT_SECONDS,
46
53
  UnsupportedRecordingError,
47
54
  type WavFormat,
48
- wavWithHeader,
49
55
  } from "./workflows/wav.ts";
50
56
 
51
- /** Where the sync endpoint lives — the one URL these stubs answer differently. */
52
- const SYNC_ORIGIN = "https://sync.assemblyai.com";
53
-
54
57
  /** The id every spec below uploads under. */
55
58
  const UPLOAD_ID = "upl_test";
56
59
 
@@ -72,16 +75,12 @@ const STARTED_AT = 1_000_000;
72
75
  * bytes with no server, no database and no HTTP.
73
76
  */
74
77
  function publishRecording(bytes: Uint8Array, name = "standup.wav") {
75
- restore = stubUploads({ [UPLOAD_ID]: { bytes, name, type: "audio/wav" } });
78
+ // `installStubUploads` rather than `stubUploads`: the fake registers its own
79
+ // `onTestFinished`, which is what replaced the three hand-kept restore
80
+ // registries this file used to carry.
81
+ installStubUploads({ [UPLOAD_ID]: { bytes, name, type: "audio/wav" } });
76
82
  }
77
83
 
78
- /** Unpublished between specs — a slot left behind reaches the next file. */
79
- let restore: (() => void) | undefined;
80
- afterEach(() => {
81
- restore?.();
82
- restore = undefined;
83
- });
84
-
85
84
  /** 16 kHz mono 16-bit — one second of audio is 32,000 bytes. */
86
85
  const MONO_16K = { sampleRate: 16_000, channels: 1, bitsPerSample: 16 } as const;
87
86
 
@@ -143,8 +142,8 @@ describe("the agent declares its three workflows and nothing else", () => {
143
142
  // every flow, and the streaming one differs only in that the CLIENT chose the id
144
143
  // and PUT the file to it. A divergence here would mean the form had to ask a
145
144
  // person how the bytes should travel.
146
- for (const flow of [transcribe, transcribeStream, transcribeBatch]) {
147
- expect(flow.uploads).toEqual(["recording"]);
145
+ for (const wf of [transcribe, transcribeStream, transcribeBatch]) {
146
+ expect(wf.uploads).toEqual(["recording"]);
148
147
  }
149
148
  });
150
149
 
@@ -371,24 +370,6 @@ describe("planSegments decides the fan-out's width", () => {
371
370
  });
372
371
  });
373
372
 
374
- describe("wavWithHeader", () => {
375
- test("writes a header the endpoint can read the rate back out of", () => {
376
- const samples = new Uint8Array(3200).fill(7);
377
- const out = wavWithHeader({ ...MONO_16K, dataStart: 44, dataEnd: 3244 }, samples);
378
- const view = new DataView(out.buffer);
379
-
380
- expect(String.fromCharCode(...out.subarray(0, 4))).toBe("RIFF");
381
- expect(String.fromCharCode(...out.subarray(8, 12))).toBe("WAVE");
382
- expect(view.getUint32(24, true)).toBe(MONO_16K.sampleRate);
383
- expect(view.getUint16(22, true)).toBe(MONO_16K.channels);
384
- // The two lengths, which are what a decoder trusts: RIFF counts everything
385
- // after itself, `data` counts only the samples.
386
- expect(view.getUint32(4, true)).toBe(36 + samples.length);
387
- expect(view.getUint32(40, true)).toBe(samples.length);
388
- expect(out.subarray(44)).toEqual(samples);
389
- });
390
- });
391
-
392
373
  describe("stitchTranscript", () => {
393
374
  test("removes the words the overlap made duplicates", () => {
394
375
  expect(
@@ -468,13 +449,6 @@ describe("stitchChunks — what the PAGE renders while a run is going", () => {
468
449
  });
469
450
  });
470
451
 
471
- describe("clock", () => {
472
- test("renders a position a reader can find in the recording", () => {
473
- expect(clock(0)).toBe("0:00");
474
- expect(clock(65_000)).toBe("1:05");
475
- });
476
- });
477
-
478
452
  describe("splitRecording", () => {
479
453
  test("plans the segments and reports the duration", async () => {
480
454
  const seconds = 200;
@@ -524,49 +498,40 @@ describe("transcribeSegment", () => {
524
498
  /**
525
499
  * Publishes the recording and answers the sync endpoint.
526
500
  *
527
- * `stubStepFetch`, not `vi.stubGlobal("fetch", …)`: the step calls `stepFetch`,
528
- * which reaches a published slot rather than the globalsee
529
- * `sdk/step-fetch.ts` for why it has to (HTTP/1.1, so a batch of segments gets
530
- * a socket each instead of N streams on one connection). Stubbing the global
531
- * still passes, because an unpublished slot falls back to it, and would be
532
- * asserting against a path production does not take.
501
+ * `installStubTranscribe` routes AssemblyAI's four legs off the SDK's own
502
+ * endpoint constants, so this file no longer re-types the wireand it fills a
503
+ * published `stepFetch` rather than `vi.stubGlobal("fetch", …)`, because the
504
+ * step calls `stepFetch`, which reaches a published slot rather than the global
505
+ * (see `sdk/step-fetch.ts` for why it has to: HTTP/1.1, so a batch of segments
506
+ * gets a socket each instead of N streams on one connection). Stubbing the
507
+ * global still passes, because an unpublished slot falls back to it, and would
508
+ * be asserting against a path production does not take.
509
+ *
510
+ * A refusal is staged as a STATUS, which is what makes the classification specs
511
+ * below a test of the SDK's reading of it rather than of an error a fake minted.
533
512
  */
534
- function stubProvider(
535
- sync: { status?: number; body?: unknown; headers?: Record<string, string> } = {},
536
- ) {
513
+ function stubProvider(failure?: { status: number; message: string; retryAfterSeconds?: number }) {
537
514
  publishRecording(new Uint8Array(FORMAT.dataEnd));
538
- const stub = stubStepFetch(() => ({
539
- status: sync.status ?? 200,
540
- body: sync.body ?? { text: "hello there" },
541
- ...omitUndefined({ headers: sync.headers }),
542
- }));
543
- stubs.push(stub.restore);
544
- return stub.calls;
515
+ return installStubTranscribe({
516
+ text: "hello there",
517
+ failure: failure === undefined ? undefined : { leg: "sync", ...failure },
518
+ }).calls;
545
519
  }
546
520
 
547
- /** Unpublished per test a live one answers the next file's steps. */
548
- const stubs: (() => void)[] = [];
549
- afterEach(() => {
550
- for (const restore of stubs.splice(0)) restore();
551
- });
552
-
553
- test("sends the segment as a WAV, with the key and the model header", async () => {
521
+ test("sends the segment as a WAV named after its index", async () => {
522
+ // What the SYNC endpoint's request looks like — the URL, the raw-key auth, the
523
+ // multipart envelope — is the SDK's contract and `sdk/step-transcribe*.test.ts`
524
+ // owns it. What is left here is this template's: it re-attaches a header to a
525
+ // window it read, and it names the part after the segment.
554
526
  const calls = stubProvider();
555
527
  const result = await transcribeSegment(UPLOAD_ID, FORMAT, SEGMENT);
556
528
 
557
529
  expect(result).toEqual({ index: 0, text: "hello there" });
558
- const sync = calls.find((call) => call.url.startsWith(SYNC_ORIGIN));
559
- // The raw key: this endpoint takes it unprefixed, and `Bearer ` in front of
560
- // it is a 401 that reads like a wrong key.
561
- expect(sync?.headers.Authorization).toBe("sk-test");
562
- expect(sync?.headers["X-AAI-Model"]).toBe("universal-3-5-pro");
563
- // BYTES, and multipart — `stepFetch` takes no `FormData`, which is the
564
- // point: a branded object handed to a fetch from another undici realm goes
565
- // out as the string `[object FormData]`. `multipartBody` builds the envelope.
566
- expect(sync?.headers["Content-Type"]).toMatch(/^multipart\/form-data; boundary=/);
567
- const body = sync?.body;
568
- expect(body).toBeInstanceOf(Uint8Array);
569
- const decoded = new TextDecoder().decode(body as Uint8Array);
530
+ const sent = calls.find((call) => call.leg === "sync")?.body;
531
+ // Narrowed by a failing assertion rather than a cast: `expect.fail` returns
532
+ // `never`, and a body that is not bytes is a finding rather than a type error.
533
+ if (!(sent instanceof Uint8Array)) return expect.fail("the sync leg carries bytes");
534
+ const decoded = new TextDecoder().decode(sent);
570
535
  expect(decoded).toContain('name="audio"; filename="segment-0.wav"');
571
536
  // The WAV really rides in the part, header and all.
572
537
  expect(decoded).toContain("RIFF");
@@ -577,8 +542,7 @@ describe("transcribeSegment", () => {
577
542
  // page stitches whatever has arrived, so the transcript renders growing
578
543
  // instead of appearing when the last segment does. The reporter is the SDK's
579
544
  // published slot, which is the same seam `report()` goes through.
580
- const reported = stubReporter();
581
- stubs.push(reported.restore);
545
+ const reported = installStubReporter();
582
546
  stubProvider();
583
547
 
584
548
  await transcribeSegment(UPLOAD_ID, FORMAT, SEGMENT);
@@ -608,7 +572,7 @@ describe("transcribeSegment", () => {
608
572
  // `RetryableError` carrying `retryAfter` is the difference between draining
609
573
  // the 429s and re-collecting them `SEGMENT_CONCURRENCY` at a time on a
610
574
  // backoff the server did not choose.
611
- stubProvider({ status: 429, body: { detail: "slow down" }, headers: { "Retry-After": "30" } });
575
+ stubProvider({ status: 429, message: "slow down", retryAfterSeconds: 30 });
612
576
  const failure = await transcribeSegment(UPLOAD_ID, FORMAT, SEGMENT).catch(
613
577
  (err: unknown) => err,
614
578
  );
@@ -620,14 +584,14 @@ describe("transcribeSegment", () => {
620
584
  });
621
585
 
622
586
  test("retries a rate limit that named no delay", async () => {
623
- stubProvider({ status: 429, body: { detail: "slow down" } });
587
+ stubProvider({ status: 429, message: "slow down" });
624
588
  await expect(transcribeSegment(UPLOAD_ID, FORMAT, SEGMENT)).rejects.toBeInstanceOf(
625
589
  RetryableError,
626
590
  );
627
591
  });
628
592
 
629
593
  test("fails FATALLY on a rejected request, naming what the endpoint said", async () => {
630
- stubProvider({ status: 400, body: { error_code: "audio_too_short", message: "too short" } });
594
+ stubProvider({ status: 400, message: "too short" });
631
595
  await expect(transcribeSegment(UPLOAD_ID, FORMAT, SEGMENT)).rejects.toThrow(
632
596
  /HTTP 400 — too short/,
633
597
  );
@@ -687,7 +651,7 @@ describe("the streaming flow", () => {
687
651
  function publishPartial(stored: number, declared: number, complete = false) {
688
652
  const bytes = new Uint8Array(44 + stored);
689
653
  bytes.set(wavFile(MONO_16K, declared), 0);
690
- restore = stubUploads({
654
+ installStubUploads({
691
655
  [UPLOAD_ID]: { bytes, name: "standup.wav", type: "audio/wav", complete },
692
656
  });
693
657
  }
@@ -721,12 +685,12 @@ describe("the streaming flow", () => {
721
685
  // the file has finished — which is exactly what `transcribe` is for.
722
686
  const bytes = new Uint8Array(44 + 100);
723
687
  bytes.set(wavFile(MONO_16K, 100, { declaredDataSize: 0 }), 0);
724
- restore = stubUploads({ [UPLOAD_ID]: { bytes, complete: false } });
688
+ installStubUploads({ [UPLOAD_ID]: { bytes, complete: false } });
725
689
  await expect(planStreamed(UPLOAD_ID)).rejects.toThrow(/declares no data length/);
726
690
  });
727
691
 
728
692
  test("planStreamed refuses a file that is not a WAV, terminally", async () => {
729
- restore = stubUploads({ [UPLOAD_ID]: { bytes: new Uint8Array(2000), complete: false } });
693
+ installStubUploads({ [UPLOAD_ID]: { bytes: new Uint8Array(2000), complete: false } });
730
694
  // Fatal, not retryable: three more attempts read the same bytes.
731
695
  await expect(planStreamed(UPLOAD_ID)).rejects.toBeInstanceOf(FatalError);
732
696
  });
@@ -757,21 +721,20 @@ describe("the async flow", () => {
757
721
  vi.stubEnv("ASSEMBLYAI_API_KEY", "sk-test");
758
722
  });
759
723
 
760
- const batchStubs: (() => void)[] = [];
761
- afterEach(() => {
762
- for (const undo of batchStubs.splice(0)) undo();
763
- });
764
-
765
- /** Answer the async API, recording what was sent. */
766
- function stubBatch(answer: (url: string) => { status?: number; body?: unknown }) {
767
- const stub = stubStepFetch((req) => answer(req.url));
768
- batchStubs.push(stub.restore);
769
- return stub.calls;
724
+ /**
725
+ * Answer the async API, recording what was sent.
726
+ *
727
+ * `installStubTranscribe` routes the three legs off the SDK's own endpoint
728
+ * constants — so a spec cannot pass because the fake and the step agree on a
729
+ * typo and it restores itself when the test ends.
730
+ */
731
+ function stubBatch(options: Parameters<typeof installStubTranscribe>[0] = {}) {
732
+ return installStubTranscribe({ audioUrl: "https://cdn.example/abc", ...options }).calls;
770
733
  }
771
734
 
772
735
  test("uploadToProvider streams the file and answers with the provider's URL", async () => {
773
736
  publishRecording(new Uint8Array(5000), "standup.wav");
774
- const calls = stubBatch(() => ({ body: { upload_url: "https://cdn.example/abc" } }));
737
+ const calls = stubBatch();
775
738
  await expect(uploadToProvider(UPLOAD_ID)).resolves.toEqual({
776
739
  audioUrl: "https://cdn.example/abc",
777
740
  });
@@ -780,9 +743,9 @@ describe("the async flow", () => {
780
743
 
781
744
  test("the file is STREAMED, so a step never holds a whole recording", async () => {
782
745
  publishRecording(new Uint8Array(5000), "standup.wav");
783
- const calls = stubBatch(() => ({ body: { upload_url: "https://cdn.example/abc" } }));
746
+ const calls = stubBatch();
784
747
  await uploadToProvider(UPLOAD_ID);
785
- // `stubStepFetch` drains a streaming body into bytes, so what this asserts is that
748
+ // The fake drains a streaming body into bytes, so what this asserts is that
786
749
  // every byte went out — the streaming is what keeps a gigabyte off the heap, and
787
750
  // the bytes arriving intact is what says the windowing is right.
788
751
  const sent = calls[0]?.body;
@@ -795,7 +758,7 @@ describe("the async flow", () => {
795
758
  // thing five times and re-uploaded 24 MB on each attempt. The split is what makes
796
759
  // a retry of the cheap half cost the cheap half.
797
760
  publishRecording(new Uint8Array(5000));
798
- const calls = stubBatch(() => ({ status: 400, body: { error: "bad field" } }));
761
+ const calls = stubBatch({ failure: { leg: "submit", status: 400, message: "bad field" } });
799
762
  await expect(createJob("https://cdn.example/abc")).rejects.toBeInstanceOf(FatalError);
800
763
  // One call, and it is not the upload.
801
764
  expect(calls).toHaveLength(1);
@@ -804,7 +767,7 @@ describe("the async flow", () => {
804
767
 
805
768
  test("createJob asks for `speech_models`, plural — the singular field is a 400", async () => {
806
769
  publishRecording(new Uint8Array(10));
807
- const calls = stubBatch(() => ({ body: { id: "tr_1" } }));
770
+ const calls = stubBatch({ jobIdPrefix: "tr_" });
808
771
  await expect(createJob("https://cdn.example/abc")).resolves.toEqual({ id: "tr_1" });
809
772
  const sent = JSON.parse(String(calls[0]?.body)) as Record<string, unknown>;
810
773
  // The async API deprecated `speech_model` and answers 400 for any current model
@@ -816,48 +779,62 @@ describe("the async flow", () => {
816
779
 
817
780
  test("a job the provider gave up on is TERMINAL, not polled forever", async () => {
818
781
  publishRecording(new Uint8Array(10));
819
- stubBatch(() => ({ body: { status: "error", error: "audio too quiet" } }));
782
+ stubBatch({ jobError: "audio too quiet" });
820
783
  // The provider has decided; no number of polls changes it, so this must not come
821
784
  // back as "not done yet".
822
- await expect(pollTranscript("tr_1")).rejects.toBeInstanceOf(FatalError);
785
+ await expect(pollTranscript(UPLOAD_ID, "tr_1", STARTED_AT)).rejects.toBeInstanceOf(FatalError);
823
786
  });
824
787
 
825
788
  test("pollTranscript answers `done` on completed and not before", async () => {
826
789
  publishRecording(new Uint8Array(10));
827
- stubBatch(() => ({ body: { status: "processing" } }));
828
- await expect(pollTranscript("tr_1")).resolves.toEqual({ done: false, status: "processing" });
790
+ stubBatch({ pendingPolls: 1 });
791
+ await expect(pollTranscript(UPLOAD_ID, "tr_1", STARTED_AT)).resolves.toEqual({ done: false });
829
792
  });
830
793
 
831
794
  test("an unknown status is NOT done, so a new one cannot end a run early", async () => {
832
795
  publishRecording(new Uint8Array(10));
833
- stubBatch(() => ({ body: {} }));
834
- await expect(pollTranscript("tr_1")).resolves.toMatchObject({ done: false });
796
+ // Raw rather than `stubTranscribe`: a body with no status at all is not a
797
+ // shape the fake can stage, and it is the whole point of this spec.
798
+ installStubStepFetch(() => ({ body: {} }));
799
+ await expect(pollTranscript(UPLOAD_ID, "tr_1", STARTED_AT)).resolves.toMatchObject({
800
+ done: false,
801
+ });
835
802
  });
836
803
 
837
- test("readTranscript reports the provider's own duration and ONE segment", async () => {
804
+ test("a completed poll carries the transcript ONE request, not two", async () => {
838
805
  publishRecording(new Uint8Array(10), "standup.wav");
839
- stubBatch(() => ({ body: { text: " hello there ", audio_duration: 12.5 } }));
840
- await expect(readTranscript(UPLOAD_ID, "tr_1", STARTED_AT)).resolves.toMatchObject({
841
- source: "standup.wav",
842
- // Not a fudge: the async API transcribed the recording in one piece, which is
843
- // the difference this flow is here to show.
844
- segments: 1,
845
- durationMs: 12_500,
846
- words: 2,
847
- transcript: "hello there",
806
+ // It used to poll for a status and then fetch the identical URL again for the
807
+ // text the poll already had in its hand.
808
+ const calls = stubBatch({ text: " hello there ", durationSec: 12.5 });
809
+ await expect(pollTranscript(UPLOAD_ID, "tr_1", STARTED_AT)).resolves.toMatchObject({
810
+ done: true,
811
+ transcript: {
812
+ source: "standup.wav",
813
+ // Not a fudge: the async API transcribed the recording in one piece, which is
814
+ // the difference this flow is here to show.
815
+ segments: 1,
816
+ durationMs: 12_500,
817
+ words: 2,
818
+ transcript: "hello there",
819
+ },
848
820
  });
821
+ expect(calls).toHaveLength(1);
849
822
  });
850
823
 
851
824
  test("a rate limit is RETRYABLE, so a busy minute does not fail the run", async () => {
852
825
  publishRecording(new Uint8Array(10));
853
- stubBatch(() => ({ status: 429, body: { error: "slow down" } }));
854
- await expect(pollTranscript("tr_1")).rejects.toBeInstanceOf(RetryableError);
826
+ stubBatch({ failure: { leg: "poll", status: 429, message: "slow down" } });
827
+ await expect(pollTranscript(UPLOAD_ID, "tr_1", STARTED_AT)).rejects.toBeInstanceOf(
828
+ RetryableError,
829
+ );
855
830
  });
856
831
 
857
832
  test("all three flows report the same SHAPE, which is what lets one page render any", async () => {
858
833
  publishRecording(new Uint8Array(10), "standup.wav");
859
- stubBatch(() => ({ body: { text: "hi", audio_duration: 1 } }));
860
- const batched = await readTranscript(UPLOAD_ID, "tr_1", STARTED_AT);
834
+ stubBatch({ text: "hi", durationSec: 1 });
835
+ const progress = await pollTranscript(UPLOAD_ID, "tr_1", STARTED_AT);
836
+ if (!progress.done) return expect.fail("the stub reports a completed job");
837
+ const batched = progress.transcript;
861
838
  // One key set, so the page's summary line renders every flow's output. A field on
862
839
  // one flow and not the others is a panel that shows it for some runs and not
863
840
  // others, with nothing saying why.
@@ -873,3 +850,149 @@ describe("the async flow", () => {
873
850
  expect(batched.elapsedMs).toBeGreaterThan(0);
874
851
  });
875
852
  });
853
+
854
+ describe("normalizing the recording", () => {
855
+ /**
856
+ * The CONVERSION is not driven here, and the reason is the tier rather than the
857
+ * code: it spawns ffmpeg and writes a temp file, neither of which a unit test
858
+ * may do. What is reachable is everything that DECIDES — whether a file needs
859
+ * converting at all, and how a conversion's failure is classified — and those
860
+ * are the two places a mistake is silent. A file wrongly passed through fails
861
+ * later in `splitRecording` with a message about a header; a `timeout`
862
+ * classified as fatal is a run that gives up on work that would have finished.
863
+ */
864
+ test("a canonical WAV is cuttable, so the desk converts nothing", () => {
865
+ expect(cuttable(wavFile(MONO_16K, 32_000), 44 + 32_000)).toBe(true);
866
+ });
867
+
868
+ test("an extra chunk before the samples is still cuttable", () => {
869
+ // The `LIST`-chunk case, which is the reason the probe window is 64 KB rather
870
+ // than 44 bytes: a file the walk CAN read must not be re-encoded.
871
+ const head = wavFile(MONO_16K, 32_000, { extraChunk: "recorder" });
872
+ expect(cuttable(head, head.length + 32_000)).toBe(true);
873
+ });
874
+
875
+ test("an m4a is not, which is what puts ffmpeg in the path", () => {
876
+ // An MPEG-4 `ftyp` box — what a phone recording really starts with.
877
+ const m4a = new Uint8Array([
878
+ 0, 0, 0, 0x20, 0x66, 0x74, 0x79, 0x70, 0x4d, 0x34, 0x41, 0x20, 0, 0, 0, 0,
879
+ ]);
880
+ expect(cuttable(m4a, 4_000_000)).toBe(false);
881
+ });
882
+
883
+ test("a WAV whose encoding is not linear PCM is not", () => {
884
+ // The case an `ffprobe`-based check gets WRONG, which is why the check is
885
+ // `parseWav` itself: this file reports a PCM codec to ffprobe and is refused
886
+ // by the parser, so a probe would pass it through and the cut would fail.
887
+ const extensible = wavFile(MONO_16K, 32_000);
888
+ new DataView(extensible.buffer).setUint16(20, 0xff_fe, true);
889
+ expect(cuttable(extensible, 44 + 32_000)).toBe(false);
890
+ });
891
+
892
+ test("a WAV too dense to cut is not, and downsampling is what repairs it", () => {
893
+ // Past `MAX_BYTES_PER_SECOND`, so `parseWav` refuses it — and a conversion to
894
+ // 16 kHz mono makes it cuttable, which is a fix the desk gets for free from
895
+ // asking the parser rather than asking about the codec.
896
+ const dense = wavFile({ sampleRate: 4_000_000, channels: 8, bitsPerSample: 32 }, 32_000);
897
+ expect(cuttable(dense, 44 + 32_000)).toBe(false);
898
+ });
899
+
900
+ test("an already-cuttable recording keeps the id it came in under", async () => {
901
+ // The property that matters: no second upload, so the fan-out reads the file
902
+ // the caller stored. A step that copied it would double the storage every run
903
+ // pays for and would still report success.
904
+ publishRecording(wavFile(MONO_16K, 32_000), "standup.wav");
905
+ const reporter = installStubReporter();
906
+ await expect(normalizeRecording(UPLOAD_ID)).resolves.toEqual({
907
+ recording: UPLOAD_ID,
908
+ converted: false,
909
+ });
910
+ expect(reporter.lines.join(" ")).toContain("already linear-PCM WAV");
911
+ });
912
+
913
+ // The ffmpeg VERDICT is no longer tested here, and its absence is the change
914
+ // rather than a gap: `throwFfmpegStepError` on `@alexkroman1/aai/step-errors`
915
+ // owns it now, with every arm pinned in `sdk/step-errors.test.ts` — a refused
916
+ // file, a missing binary, a timeout rethrown UNCHANGED so its `argv` survives,
917
+ // and a cause that is not an ffmpeg failure at all. What stays above is what is
918
+ // still this desk's: WHICH files it decides to convert.
919
+ });
920
+
921
+ describe("expectedSegments", () => {
922
+ /** A plan over 16 kHz mono, cut into three 90-second segments. */
923
+ const PLAN = {
924
+ format: { ...MONO_16K, dataStart: 44, dataEnd: 44 + 270 * 32_000 },
925
+ segments: [0, 1, 2].map((index) => ({
926
+ index,
927
+ start: 44 + index * 90 * 32_000,
928
+ end: 44 + (index + 1) * 90 * 32_000,
929
+ startMs: index * 90_000,
930
+ endMs: (index + 1) * 90_000,
931
+ })),
932
+ };
933
+
934
+ test("counts every segment once the whole recording has arrived", () => {
935
+ expect(expectedSegments(PLAN, PLAN.format.dataEnd)).toBe(3);
936
+ });
937
+
938
+ test("ignores segments that start past the end of a SHORT upload", () => {
939
+ // The failure this guards is a run that never ENDS rather than one that fails:
940
+ // the plan came from the header's declared length, so a recording that came up
941
+ // short has segments beginning past the last byte, and waiting for them is
942
+ // waiting for audio nobody is going to send.
943
+ expect(expectedSegments(PLAN, 44 + 100 * 32_000)).toBe(2);
944
+ expect(expectedSegments(PLAN, 44 + 1)).toBe(1);
945
+ });
946
+
947
+ test("an upload with nothing in it expects no segments at all", () => {
948
+ expect(expectedSegments(PLAN, 0)).toBe(0);
949
+ });
950
+ });
951
+
952
+ describe("the conversion, up to the spawn", () => {
953
+ /**
954
+ * Reaches the point where ffmpeg would run and stops there, DETERMINISTICALLY.
955
+ *
956
+ * `AAI_FFPROBE_PATH` names the binary the SDK resolves, so pointing it at a path
957
+ * that does not exist produces `kind: "missing-binary"` on every machine — one
958
+ * where ffmpeg is installed, one where it is not, and CI's Linux leg alike. A
959
+ * test that instead relied on ffmpeg being ABSENT would pass on a laptop and
960
+ * behave differently in CI.
961
+ *
962
+ * What it covers is the whole step up to the subprocess: reading the header,
963
+ * deciding the file needs converting, and materializing it to a temp file. Plus
964
+ * the behaviour a developer actually meets — `aai dev` with no ffmpeg is the one
965
+ * place dev/prod parity is partial, and it must fail FATALLY with an installable
966
+ * remedy rather than burn five attempts on a binary that will not appear.
967
+ */
968
+ test("materializes the recording, then fails fatally with no ffprobe", async () => {
969
+ vi.stubEnv("AAI_FFPROBE_PATH", "/nonexistent/aai-test/ffprobe");
970
+ vi.stubEnv("AAI_FFMPEG_PATH", "/nonexistent/aai-test/ffmpeg");
971
+ // An m4a `ftyp` box, so `cuttable` says no and the conversion path is taken.
972
+ publishRecording(
973
+ new Uint8Array([0, 0, 0, 0x20, 0x66, 0x74, 0x79, 0x70, 0x4d, 0x34, 0x41, 0x20]),
974
+ "standup.m4a",
975
+ );
976
+ const reporter = installStubReporter();
977
+ // Fatal, not retryable: four more attempts find the same missing binary, and
978
+ // the message already carries the install instructions.
979
+ await expect(normalizeRecording(UPLOAD_ID)).rejects.toThrow(/ffprobe/);
980
+ await expect(normalizeRecording(UPLOAD_ID)).rejects.toBeInstanceOf(FatalError);
981
+ // It got as far as deciding the file needs converting — the failure is the
982
+ // binary, not the input.
983
+ expect(reporter.lines.join(" ")).toContain("standup.m4a");
984
+ expect(normalizeRecording.maxRetries).toBe(5);
985
+ });
986
+
987
+ test("leaves no temp directory behind when the conversion fails", async () => {
988
+ // The `finally`, on the path that matters: a guest's disk is small, and a step
989
+ // that leaked a directory per failed run would fill it.
990
+ vi.stubEnv("AAI_FFPROBE_PATH", "/nonexistent/aai-test/ffprobe");
991
+ const leaked = (names: string[]) => names.filter((n) => n.startsWith("aai-normalize-"));
992
+ const before = leaked(await readdir(tmpdir()));
993
+ publishRecording(new Uint8Array([0, 0, 0, 0x20, 0x66, 0x74, 0x79, 0x70]), "standup.m4a");
994
+ installStubReporter();
995
+ await expect(normalizeRecording(UPLOAD_ID)).rejects.toBeInstanceOf(FatalError);
996
+ expect(leaked(await readdir(tmpdir()))).toEqual(before);
997
+ });
998
+ });
@@ -16,15 +16,19 @@
16
16
  * - **`ASSEMBLYAI_API_KEY` in the agent env** — `.env` under `aai dev`,
17
17
  * `aai secret put ASSEMBLYAI_API_KEY` once deployed. `requiredEnv` below is
18
18
  * what makes a deploy check for it rather than letting the first run find out.
19
- * A step reads it with `requireStepEnv`; see `@alexkroman1/aai/utils`.
19
+ * A step reads it with `requireStepEnv`; see `@alexkroman1/aai/step`.
20
20
  * - **Storage** (`aai storage enable`, Settings → Database in the studio, or
21
21
  * `DATABASE_URL` under `aai dev`). REQUIRED here, unlike most workflow apps:
22
22
  * a run survives without it, but an UPLOAD's record is a row, so the form
23
23
  * below refuses by name until storage is on.
24
- * - **A linear-PCM WAV.** The cutting is arithmetic over byte offsets, which is
25
- * only possible on uncompressed audio; `workflows/wav.ts` says so in more
26
- * detail, and an unsupported file fails the run by name with the `ffmpeg`
27
- * line that fixes it.
24
+ * - **ffmpeg, under `aai dev` only.** A deployed guest's image installs it; on a
25
+ * laptop it is whatever is on `PATH` (or `AAI_FFMPEG_PATH`). The `transcribe`
26
+ * flow needs it for anything that is not already a linear-PCM WAV, because the
27
+ * cutting is arithmetic over byte offsets and that is only possible on
28
+ * uncompressed audio — `workflows/normalize.ts` is the conversion and
29
+ * `workflows/wav.ts` is why it has to happen. A WAV needs no ffmpeg at all,
30
+ * and `transcribeStream` never uses it: a recording that is still uploading is
31
+ * not something a decoder can be pointed at.
28
32
  *
29
33
  * ## The recording is UPLOADED, and the run carries its id
30
34
  *
@@ -49,7 +53,8 @@
49
53
  * | client sends | `POST /uploads` | `PUT /uploads/<id>` | `POST /uploads` |
50
54
  * | shape | plan, fan out, merge | poll, fan out, merge | submit, poll, read |
51
55
  * | client hook | `useWorkflowSubmit` | `useWorkflowStream` | `useWorkflowSubmit` |
52
- * | accepts | linear-PCM WAV | linear-PCM WAV | **any audio** |
56
+ * | accepts | **any audio** | linear-PCM WAV | **any audio** |
57
+ * | converts first | when it must | never | not needed |
53
58
  * | segments | 7 for 10 minutes | 7 | **1** |
54
59
  *
55
60
  * The first two are the same fan-out arranged two ways, and the difference between
@@ -102,7 +107,12 @@ export const transcribe = workflow({
102
107
  input: z.object({
103
108
  // A plain string, because an upload id is what the run really receives. What
104
109
  // makes it a file picker rather than a text box is the `uploads` line below.
105
- recording: z.string().describe("A linear-PCM WAV recording (16-bit or 8-bit, any rate)"),
110
+ //
111
+ // It says "any recording" now, and that is the whole of what
112
+ // `workflows/normalize.ts` bought: this used to name linear-PCM WAV and mean
113
+ // it, so the desk's real front door was a sentence telling the caller to run
114
+ // ffmpeg themselves. The cut still needs WAV — the run makes one.
115
+ recording: z.string().describe("Any recording — WAV, MP3, M4A, or a video's audio track"),
106
116
  }),
107
117
  // The one line that makes the form take a file: `<WorkflowFields>` renders a
108
118
  // picker for this property, `useWorkflowSubmit` stores the chosen file, and
@@ -128,6 +138,10 @@ export const transcribe = workflow({
128
138
  export const transcribeStream = workflow({
129
139
  description: "Transcribe a recording while it is still uploading",
130
140
  input: z.object({
141
+ // Still WAV, and this is the one flow where that is not a limitation to be
142
+ // fixed: it cuts the recording while the bytes are arriving, and a partial
143
+ // file is not something ffmpeg can transcode. `transcribe` converts because
144
+ // it has the whole file before it plans anything.
131
145
  recording: z.string().describe("A linear-PCM WAV recording (16-bit or 8-bit, any rate)"),
132
146
  }),
133
147
  uploads: ["recording"],