@alexkroman1/aai-cli 6.11.0 → 8.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 (234) hide show
  1. package/README.md +1 -0
  2. package/dist/{_agent-DpH2pBJd.mjs → _agent-BTwZJu4P.mjs} +2 -2
  3. package/dist/{_bundler-C2BDwU7r.mjs → _bundler-CDuYl5Gb.mjs} +2 -2
  4. package/dist/_dev-env.d.ts +22 -2
  5. package/dist/{_dev-server-LLLWnFBs.mjs → _dev-server-Me06wgeF.mjs} +38 -47
  6. package/dist/_dev-server.d.ts +0 -17
  7. package/dist/{_init-C57bWAQ8.mjs → _init-BhEfQ6Yi.mjs} +3 -2
  8. package/dist/{_preflight-BtfaYtbE.mjs → _preflight-Co1G9Jww.mjs} +1 -1
  9. package/dist/_preflight.d.ts +1 -1
  10. package/dist/_resource-commands.d.ts +23 -0
  11. package/dist/{_slug-api-BxrsJXVI.mjs → _slug-api-z-USxPtL.mjs} +16 -12
  12. package/dist/_slug-api.d.ts +5 -2
  13. package/dist/{_studio-U3jhKlA3.mjs → _studio-DR5yzZ1T.mjs} +2 -1
  14. package/dist/{_templates-Dxb_P2Wz.mjs → _templates-4WcKOjS5.mjs} +5 -6
  15. package/dist/_templates.d.ts +3 -4
  16. package/dist/_workflow-scan.d.ts +108 -0
  17. package/dist/{build-Dfza2pRI.mjs → build-BBxf8vom.mjs} +6 -3
  18. package/dist/cli.mjs +346 -349
  19. package/dist/{client-bundler-BEIqgOtd.mjs → client-bundler-CWnG42cU.mjs} +2 -1
  20. package/dist/client-bundler.mjs +1 -1
  21. package/dist/{delete-vbpjGzqs.mjs → delete-Dl_LSfvI.mjs} +3 -3
  22. package/dist/{deploy-B8lubiRT.mjs → deploy-DRC8jWr8.mjs} +5 -5
  23. package/dist/{dev-Uq5ujP8a.mjs → dev-BvcctCn2.mjs} +1 -1
  24. package/dist/eval-DfQ8cm5p.mjs +56 -0
  25. package/dist/eval.d.ts +43 -0
  26. package/dist/{init-l6xfU4xX.mjs → init-CUo87gcI.mjs} +3 -3
  27. package/dist/{login-C71-qz8F.mjs → login-BBuM1sxH.mjs} +3 -4
  28. package/dist/{logs-BWVGSjLR.mjs → logs-CQ2T46dr.mjs} +2 -2
  29. package/dist/project-config.mjs +1 -1
  30. package/dist/scaffold/CLAUDE.md +442 -80
  31. package/dist/scaffold/package.json +6 -3
  32. package/dist/scaffold/server.mjs +11 -4
  33. package/dist/scaffold/tsconfig.json +0 -1
  34. package/dist/{secret-BuMuFR4B.mjs → secret-Ct34kdgU.mjs} +2 -2
  35. package/dist/{storage-DzRZ-eCw.mjs → storage-i_79ZwAR.mjs} +2 -2
  36. package/dist/{studio-BCNUpDgP.mjs → studio-CzTzPqe1.mjs} +4 -4
  37. package/dist/templates/briefing-desk/agent.test.ts +350 -0
  38. package/dist/templates/briefing-desk/agent.ts +30 -0
  39. package/dist/templates/briefing-desk/shared.ts +200 -0
  40. package/dist/templates/briefing-desk/system-prompt.md +39 -0
  41. package/dist/templates/briefing-desk/tools/briefing_so_far.ts +35 -0
  42. package/dist/templates/briefing-desk/tools/research_topic.ts +88 -0
  43. package/dist/templates/briefing-desk/tools/verify_claim.ts +77 -0
  44. package/dist/templates/call-audit/agent.eval.test.ts +474 -0
  45. package/dist/templates/call-audit/agent.test.ts +57 -204
  46. package/dist/templates/call-audit/agent.ts +32 -19
  47. package/dist/templates/call-audit/client.tsx +17 -54
  48. package/dist/templates/call-audit/workflows/audit.ts +11 -18
  49. package/dist/templates/call-audit/workflows/ingest.ts +106 -114
  50. package/dist/templates/call-audit/workflows/media.ts +2 -12
  51. package/dist/templates/call-audit/workflows/summarize.ts +47 -52
  52. package/dist/templates/call-audit/workflows/sync-api.ts +7 -7
  53. package/dist/templates/code-interpreter/agent.eval.test.ts +165 -0
  54. package/dist/templates/dispatch-center/agent.eval.test.ts +349 -0
  55. package/dist/templates/dispatch-center/agent.test.ts +149 -25
  56. package/dist/templates/dispatch-center/client.tsx +239 -129
  57. package/dist/templates/dispatch-center/shared.ts +99 -1
  58. package/dist/templates/dispatch-center/system-prompt.md +3 -1
  59. package/dist/templates/dispatch-center/tools/incident_add_note.ts +16 -13
  60. package/dist/templates/dispatch-center/tools/incident_create.ts +19 -1
  61. package/dist/templates/dispatch-center/tools/incident_escalate.ts +68 -59
  62. package/dist/templates/dispatch-center/tools/incident_triage.ts +57 -43
  63. package/dist/templates/dispatch-center/tools/incident_update_status.ts +49 -40
  64. package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +8 -2
  65. package/dist/templates/dispatch-center/tools/resources_dispatch.ts +69 -52
  66. package/dist/templates/dispatch-center/tools/resources_update_status.ts +45 -41
  67. package/dist/templates/embedded-assets/agent.eval.test.ts +138 -0
  68. package/dist/templates/embedded-assets/agent.test.ts +17 -14
  69. package/dist/templates/health-assistant/agent.eval.test.ts +167 -0
  70. package/dist/templates/health-assistant/agent.test.ts +22 -4
  71. package/dist/templates/infocom-adventure/agent.eval.test.ts +158 -0
  72. package/dist/templates/infocom-adventure/agent.test.ts +53 -24
  73. package/dist/templates/infocom-adventure/agent.ts +21 -0
  74. package/dist/templates/infocom-adventure/client.tsx +246 -164
  75. package/dist/templates/infocom-adventure/shared.ts +20 -9
  76. package/dist/templates/infocom-adventure/system-prompt.md +1 -1
  77. package/dist/templates/infocom-adventure/tools/game_state_move.ts +3 -2
  78. package/dist/templates/link-digest/agent.eval.test.ts +195 -0
  79. package/dist/templates/link-digest/agent.test.ts +24 -19
  80. package/dist/templates/link-digest/client.tsx +47 -61
  81. package/dist/templates/link-digest/workflows/digest.ts +19 -31
  82. package/dist/templates/math-buddy/agent.eval.test.ts +146 -0
  83. package/dist/templates/night-owl/agent.eval.test.ts +272 -0
  84. package/dist/templates/night-owl/agent.test.ts +70 -19
  85. package/dist/templates/night-owl/agent.ts +5 -0
  86. package/dist/templates/night-owl/client.tsx +56 -56
  87. package/dist/templates/night-owl/shared.ts +24 -0
  88. package/dist/templates/night-owl/tools/recommend.ts +18 -6
  89. package/dist/templates/personal-finance/agent.eval.test.ts +168 -0
  90. package/dist/templates/personal-finance/system-prompt.md +4 -0
  91. package/dist/templates/pipeline-simple/agent.eval.test.ts +78 -0
  92. package/dist/templates/pipeline-simple/agent.ts +2 -2
  93. package/dist/templates/pizza-ordering/agent.eval.test.ts +171 -0
  94. package/dist/templates/pizza-ordering/agent.test.ts +42 -27
  95. package/dist/templates/pizza-ordering/client.tsx +9 -26
  96. package/dist/templates/pizza-ordering/shared.ts +20 -1
  97. package/dist/templates/pizza-ordering/system-prompt.md +6 -0
  98. package/dist/templates/plan-and-execute/agent.eval.test.ts +209 -0
  99. package/dist/templates/plan-and-execute/agent.test.ts +99 -58
  100. package/dist/templates/plan-and-execute/agent.ts +1 -1
  101. package/dist/templates/plan-and-execute/client.tsx +12 -15
  102. package/dist/templates/plan-and-execute/shared.ts +71 -2
  103. package/dist/templates/plan-and-execute/tools/plan_status.ts +18 -6
  104. package/dist/templates/plan-and-execute/tools/revise_plan.ts +18 -10
  105. package/dist/templates/plan-and-execute/tools/start_plan.ts +13 -2
  106. package/dist/templates/plan-and-execute/tools/work_next_step.ts +70 -22
  107. package/dist/templates/podcast-digest/agent.eval.test.ts +411 -0
  108. package/dist/templates/podcast-digest/agent.test.ts +717 -0
  109. package/dist/templates/podcast-digest/agent.ts +139 -0
  110. package/dist/templates/podcast-digest/client.tsx +154 -0
  111. package/dist/templates/podcast-digest/workflows/digest.ts +444 -0
  112. package/dist/templates/podcast-digest/workflows/feeds.ts +507 -0
  113. package/dist/templates/podcast-digest/workflows/slack.ts +88 -0
  114. package/dist/templates/recap-workflow/agent.eval.test.ts +366 -0
  115. package/dist/templates/recap-workflow/agent.test.ts +97 -94
  116. package/dist/templates/recap-workflow/tools/recap_progress.ts +9 -12
  117. package/dist/templates/recap-workflow/tools/recap_status.ts +8 -9
  118. package/dist/templates/recap-workflow/tools/request_recap.ts +2 -1
  119. package/dist/templates/recap-workflow/workflows/recap.ts +36 -40
  120. package/dist/templates/redline/agent.eval.test.ts +209 -0
  121. package/dist/templates/redline/agent.test.ts +17 -15
  122. package/dist/templates/redline/client.tsx +12 -12
  123. package/dist/templates/redline/workflows/redline.ts +19 -31
  124. package/dist/templates/research-workflow/agent.eval.test.ts +329 -0
  125. package/dist/templates/research-workflow/agent.test.ts +60 -59
  126. package/dist/templates/research-workflow/tools/research_progress.ts +9 -12
  127. package/dist/templates/research-workflow/tools/research_status.ts +9 -11
  128. package/dist/templates/research-workflow/workflows/research.ts +44 -61
  129. package/dist/templates/retail/address.ts +87 -1
  130. package/dist/templates/retail/agent.eval.test.ts +314 -0
  131. package/dist/templates/retail/agent.test.ts +195 -112
  132. package/dist/templates/retail/agent.ts +35 -1
  133. package/dist/templates/retail/cancel.ts +116 -0
  134. package/dist/templates/retail/client.tsx +265 -117
  135. package/dist/templates/retail/payment.ts +107 -0
  136. package/dist/templates/retail/pending.test.ts +196 -0
  137. package/dist/templates/retail/pending.ts +137 -0
  138. package/dist/templates/retail/registry.test.ts +150 -7
  139. package/dist/templates/retail/returns.ts +102 -0
  140. package/dist/templates/retail/shared.test.ts +42 -0
  141. package/dist/templates/retail/shared.ts +35 -0
  142. package/dist/templates/retail/store.test.ts +84 -15
  143. package/dist/templates/retail/store.ts +231 -48
  144. package/dist/templates/retail/swap.test.ts +4 -4
  145. package/dist/templates/retail/swap.ts +248 -15
  146. package/dist/templates/retail/system-prompt.md +69 -28
  147. package/dist/templates/retail/tools/cancel_change.ts +33 -0
  148. package/dist/templates/retail/tools/cancel_pending_order.ts +16 -60
  149. package/dist/templates/retail/tools/confirm_change.ts +42 -0
  150. package/dist/templates/retail/tools/exchange_delivered_order_items.ts +22 -67
  151. package/dist/templates/retail/tools/find_user_id_by_email.ts +8 -5
  152. package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +4 -5
  153. package/dist/templates/retail/tools/get_item_details.ts +3 -4
  154. package/dist/templates/retail/tools/get_order_details.ts +3 -4
  155. package/dist/templates/retail/tools/get_product_details.ts +3 -4
  156. package/dist/templates/retail/tools/get_user_details.ts +2 -2
  157. package/dist/templates/retail/tools/list_all_product_types.ts +5 -5
  158. package/dist/templates/retail/tools/modify_pending_order_address.ts +13 -25
  159. package/dist/templates/retail/tools/modify_pending_order_items.ts +23 -62
  160. package/dist/templates/retail/tools/modify_pending_order_payment.ts +16 -78
  161. package/dist/templates/retail/tools/modify_user_address.ts +13 -16
  162. package/dist/templates/retail/tools/return_delivered_order_items.ts +17 -72
  163. package/dist/templates/retail/tools/transfer_to_human_agents.ts +12 -6
  164. package/dist/templates/simple/agent.eval.test.ts +54 -0
  165. package/dist/templates/solo-rpg/agent.eval.test.ts +320 -0
  166. package/dist/templates/solo-rpg/agent.test.ts +227 -48
  167. package/dist/templates/solo-rpg/client.tsx +38 -37
  168. package/dist/templates/solo-rpg/shared.ts +145 -19
  169. package/dist/templates/solo-rpg/system-prompt.md +3 -2
  170. package/dist/templates/solo-rpg/tools/action_roll.ts +82 -63
  171. package/dist/templates/solo-rpg/tools/burn_momentum.ts +85 -50
  172. package/dist/templates/solo-rpg/tools/check_state.ts +24 -7
  173. package/dist/templates/solo-rpg/tools/load_game.ts +13 -1
  174. package/dist/templates/solo-rpg/tools/save_game.ts +16 -7
  175. package/dist/templates/solo-rpg/tools/setup_character.ts +22 -2
  176. package/dist/templates/solo-rpg/tools/update_state.ts +113 -100
  177. package/dist/templates/spoken-summary/agent.eval.test.ts +342 -0
  178. package/dist/templates/spoken-summary/agent.test.ts +68 -97
  179. package/dist/templates/spoken-summary/agent.ts +30 -17
  180. package/dist/templates/spoken-summary/client.tsx +10 -50
  181. package/dist/templates/spoken-summary/workflows/summarize.ts +17 -22
  182. package/dist/templates/spoken-summary/workflows/transcribe.ts +19 -26
  183. package/dist/templates/support-line/agent.eval.test.ts +273 -0
  184. package/dist/templates/support-line/agent.test.ts +11 -16
  185. package/dist/templates/support-line/agent.ts +1 -1
  186. package/dist/templates/support-line/client.tsx +9 -9
  187. package/dist/templates/support-line/nodes.ts +100 -0
  188. package/dist/templates/support-line/procedure.ts +407 -0
  189. package/dist/templates/support-line/system-prompt.md +2 -1
  190. package/dist/templates/support-line/tools/answer_question.ts +66 -4
  191. package/dist/templates/transcription-workflow/agent.eval.test.ts +234 -0
  192. package/dist/templates/transcription-workflow/agent.test.ts +94 -194
  193. package/dist/templates/transcription-workflow/agent.ts +1 -1
  194. package/dist/templates/transcription-workflow/client.tsx +17 -42
  195. package/dist/templates/transcription-workflow/workflows/batch.ts +19 -22
  196. package/dist/templates/transcription-workflow/workflows/normalize.ts +90 -186
  197. package/dist/templates/transcription-workflow/workflows/stitch.ts +0 -11
  198. package/dist/templates/transcription-workflow/workflows/stream.ts +4 -5
  199. package/dist/templates/transcription-workflow/workflows/sync-api.ts +6 -6
  200. package/dist/templates/transcription-workflow/workflows/transcribe.ts +32 -26
  201. package/dist/templates/transcription-workflow/workflows/wav.ts +0 -31
  202. package/dist/templates/travel-concierge/agent.eval.test.ts +323 -0
  203. package/dist/templates/travel-concierge/agent.test.ts +174 -43
  204. package/dist/templates/travel-concierge/agent.ts +18 -9
  205. package/dist/templates/travel-concierge/client.tsx +11 -23
  206. package/dist/templates/travel-concierge/routing.ts +34 -15
  207. package/dist/templates/travel-concierge/shared.ts +180 -12
  208. package/dist/templates/travel-concierge/system-prompt.md +7 -1
  209. package/dist/templates/travel-concierge/tools/book_car_rental.ts +8 -5
  210. package/dist/templates/travel-concierge/tools/book_excursion.ts +8 -5
  211. package/dist/templates/travel-concierge/tools/book_hotel.ts +8 -5
  212. package/dist/templates/travel-concierge/tools/cancel_ticket.ts +8 -5
  213. package/dist/templates/travel-concierge/tools/search_car_rentals.ts +9 -5
  214. package/dist/templates/travel-concierge/tools/search_excursions.ts +9 -5
  215. package/dist/templates/travel-concierge/tools/search_flights.ts +9 -6
  216. package/dist/templates/travel-concierge/tools/search_hotels.ts +9 -5
  217. package/dist/templates/travel-concierge/tools/update_ticket.ts +8 -5
  218. package/dist/templates/web-researcher/agent.eval.test.ts +121 -0
  219. package/dist/templates/web-researcher/system-prompt.md +15 -4
  220. package/dist/{test-9kPKJI-w.mjs → test-BvyT6sAc.mjs} +24 -10
  221. package/dist/test.d.ts +29 -5
  222. package/dist/{worker-bundler-CGD4r8Kc.mjs → worker-bundler-CCVEDjm1.mjs} +241 -3
  223. package/dist/worker-bundler.mjs +1 -1
  224. package/dist/{workflow-CFpxOFfQ.mjs → workflow-BflATMmU.mjs} +47 -12
  225. package/dist/workflow-bundler.d.ts +6 -1
  226. package/dist/workflow.d.ts +1 -1
  227. package/package.json +5 -4
  228. package/dist/eject-31gjtaHF.mjs +0 -116
  229. package/dist/eject.d.ts +0 -60
  230. package/dist/templates/call-audit/workflows/temp-media.ts +0 -138
  231. package/dist/templates/infocom-adventure/tools/game_state_history.ts +0 -14
  232. package/dist/templates/support-line/graph.ts +0 -224
  233. package/dist/{_config-D_s09e7g.mjs → _config-CmJOFsAP.mjs} +1 -1
  234. /package/dist/templates/plan-and-execute/{graph.ts → procedure.ts} +0 -0
@@ -19,18 +19,21 @@
19
19
 
20
20
  import { readdir } from "node:fs/promises";
21
21
  import { tmpdir } from "node:os";
22
- import { FfmpegError } from "@alexkroman1/aai/ffmpeg";
23
- import { stubReporter, stubStepFetch, stubUploads } from "@alexkroman1/aai/testing";
24
- import { omitUndefined, readUpload } from "@alexkroman1/aai/utils";
25
- import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
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";
26
30
  import { FatalError, RetryableError } from "workflow";
27
31
  import { z } from "zod";
28
32
  import agentDef, { transcribe, transcribeBatch, transcribeStream } from "./agent.ts";
29
33
  import { createJob, pollTranscript, uploadToProvider } from "./workflows/batch.ts";
30
- import { classifyFfmpeg, cuttable, normalizeRecording } from "./workflows/normalize.ts";
34
+ import { cuttable, normalizeRecording } from "./workflows/normalize.ts";
31
35
  import { expectedSegments, planStreamed, probeUpload } from "./workflows/stream.ts";
32
36
  import {
33
- clock,
34
37
  mergeTranscript,
35
38
  splitRecording,
36
39
  stitchChunks,
@@ -49,12 +52,8 @@ import {
49
52
  SEGMENT_SECONDS,
50
53
  UnsupportedRecordingError,
51
54
  type WavFormat,
52
- wavWithHeader,
53
55
  } from "./workflows/wav.ts";
54
56
 
55
- /** Where the sync endpoint lives — the one URL these stubs answer differently. */
56
- const SYNC_ORIGIN = "https://sync.assemblyai.com";
57
-
58
57
  /** The id every spec below uploads under. */
59
58
  const UPLOAD_ID = "upl_test";
60
59
 
@@ -76,16 +75,12 @@ const STARTED_AT = 1_000_000;
76
75
  * bytes with no server, no database and no HTTP.
77
76
  */
78
77
  function publishRecording(bytes: Uint8Array, name = "standup.wav") {
79
- 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" } });
80
82
  }
81
83
 
82
- /** Unpublished between specs — a slot left behind reaches the next file. */
83
- let restore: (() => void) | undefined;
84
- afterEach(() => {
85
- restore?.();
86
- restore = undefined;
87
- });
88
-
89
84
  /** 16 kHz mono 16-bit — one second of audio is 32,000 bytes. */
90
85
  const MONO_16K = { sampleRate: 16_000, channels: 1, bitsPerSample: 16 } as const;
91
86
 
@@ -147,8 +142,8 @@ describe("the agent declares its three workflows and nothing else", () => {
147
142
  // every flow, and the streaming one differs only in that the CLIENT chose the id
148
143
  // and PUT the file to it. A divergence here would mean the form had to ask a
149
144
  // person how the bytes should travel.
150
- for (const flow of [transcribe, transcribeStream, transcribeBatch]) {
151
- expect(flow.uploads).toEqual(["recording"]);
145
+ for (const wf of [transcribe, transcribeStream, transcribeBatch]) {
146
+ expect(wf.uploads).toEqual(["recording"]);
152
147
  }
153
148
  });
154
149
 
@@ -375,24 +370,6 @@ describe("planSegments decides the fan-out's width", () => {
375
370
  });
376
371
  });
377
372
 
378
- describe("wavWithHeader", () => {
379
- test("writes a header the endpoint can read the rate back out of", () => {
380
- const samples = new Uint8Array(3200).fill(7);
381
- const out = wavWithHeader({ ...MONO_16K, dataStart: 44, dataEnd: 3244 }, samples);
382
- const view = new DataView(out.buffer);
383
-
384
- expect(String.fromCharCode(...out.subarray(0, 4))).toBe("RIFF");
385
- expect(String.fromCharCode(...out.subarray(8, 12))).toBe("WAVE");
386
- expect(view.getUint32(24, true)).toBe(MONO_16K.sampleRate);
387
- expect(view.getUint16(22, true)).toBe(MONO_16K.channels);
388
- // The two lengths, which are what a decoder trusts: RIFF counts everything
389
- // after itself, `data` counts only the samples.
390
- expect(view.getUint32(4, true)).toBe(36 + samples.length);
391
- expect(view.getUint32(40, true)).toBe(samples.length);
392
- expect(out.subarray(44)).toEqual(samples);
393
- });
394
- });
395
-
396
373
  describe("stitchTranscript", () => {
397
374
  test("removes the words the overlap made duplicates", () => {
398
375
  expect(
@@ -472,13 +449,6 @@ describe("stitchChunks — what the PAGE renders while a run is going", () => {
472
449
  });
473
450
  });
474
451
 
475
- describe("clock", () => {
476
- test("renders a position a reader can find in the recording", () => {
477
- expect(clock(0)).toBe("0:00");
478
- expect(clock(65_000)).toBe("1:05");
479
- });
480
- });
481
-
482
452
  describe("splitRecording", () => {
483
453
  test("plans the segments and reports the duration", async () => {
484
454
  const seconds = 200;
@@ -528,49 +498,40 @@ describe("transcribeSegment", () => {
528
498
  /**
529
499
  * Publishes the recording and answers the sync endpoint.
530
500
  *
531
- * `stubStepFetch`, not `vi.stubGlobal("fetch", …)`: the step calls `stepFetch`,
532
- * which reaches a published slot rather than the globalsee
533
- * `sdk/step-fetch.ts` for why it has to (HTTP/1.1, so a batch of segments gets
534
- * a socket each instead of N streams on one connection). Stubbing the global
535
- * still passes, because an unpublished slot falls back to it, and would be
536
- * 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.
537
512
  */
538
- function stubProvider(
539
- sync: { status?: number; body?: unknown; headers?: Record<string, string> } = {},
540
- ) {
513
+ function stubProvider(failure?: { status: number; message: string; retryAfterSeconds?: number }) {
541
514
  publishRecording(new Uint8Array(FORMAT.dataEnd));
542
- const stub = stubStepFetch(() => ({
543
- status: sync.status ?? 200,
544
- body: sync.body ?? { text: "hello there" },
545
- ...omitUndefined({ headers: sync.headers }),
546
- }));
547
- stubs.push(stub.restore);
548
- return stub.calls;
515
+ return installStubTranscribe({
516
+ text: "hello there",
517
+ failure: failure === undefined ? undefined : { leg: "sync", ...failure },
518
+ }).calls;
549
519
  }
550
520
 
551
- /** Unpublished per test a live one answers the next file's steps. */
552
- const stubs: (() => void)[] = [];
553
- afterEach(() => {
554
- for (const restore of stubs.splice(0)) restore();
555
- });
556
-
557
- 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.
558
526
  const calls = stubProvider();
559
527
  const result = await transcribeSegment(UPLOAD_ID, FORMAT, SEGMENT);
560
528
 
561
529
  expect(result).toEqual({ index: 0, text: "hello there" });
562
- const sync = calls.find((call) => call.url.startsWith(SYNC_ORIGIN));
563
- // The raw key: this endpoint takes it unprefixed, and `Bearer ` in front of
564
- // it is a 401 that reads like a wrong key.
565
- expect(sync?.headers.Authorization).toBe("sk-test");
566
- expect(sync?.headers["X-AAI-Model"]).toBe("universal-3-5-pro");
567
- // BYTES, and multipart — `stepFetch` takes no `FormData`, which is the
568
- // point: a branded object handed to a fetch from another undici realm goes
569
- // out as the string `[object FormData]`. `multipartBody` builds the envelope.
570
- expect(sync?.headers["Content-Type"]).toMatch(/^multipart\/form-data; boundary=/);
571
- const body = sync?.body;
572
- expect(body).toBeInstanceOf(Uint8Array);
573
- 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);
574
535
  expect(decoded).toContain('name="audio"; filename="segment-0.wav"');
575
536
  // The WAV really rides in the part, header and all.
576
537
  expect(decoded).toContain("RIFF");
@@ -581,8 +542,7 @@ describe("transcribeSegment", () => {
581
542
  // page stitches whatever has arrived, so the transcript renders growing
582
543
  // instead of appearing when the last segment does. The reporter is the SDK's
583
544
  // published slot, which is the same seam `report()` goes through.
584
- const reported = stubReporter();
585
- stubs.push(reported.restore);
545
+ const reported = installStubReporter();
586
546
  stubProvider();
587
547
 
588
548
  await transcribeSegment(UPLOAD_ID, FORMAT, SEGMENT);
@@ -612,7 +572,7 @@ describe("transcribeSegment", () => {
612
572
  // `RetryableError` carrying `retryAfter` is the difference between draining
613
573
  // the 429s and re-collecting them `SEGMENT_CONCURRENCY` at a time on a
614
574
  // backoff the server did not choose.
615
- stubProvider({ status: 429, body: { detail: "slow down" }, headers: { "Retry-After": "30" } });
575
+ stubProvider({ status: 429, message: "slow down", retryAfterSeconds: 30 });
616
576
  const failure = await transcribeSegment(UPLOAD_ID, FORMAT, SEGMENT).catch(
617
577
  (err: unknown) => err,
618
578
  );
@@ -624,14 +584,14 @@ describe("transcribeSegment", () => {
624
584
  });
625
585
 
626
586
  test("retries a rate limit that named no delay", async () => {
627
- stubProvider({ status: 429, body: { detail: "slow down" } });
587
+ stubProvider({ status: 429, message: "slow down" });
628
588
  await expect(transcribeSegment(UPLOAD_ID, FORMAT, SEGMENT)).rejects.toBeInstanceOf(
629
589
  RetryableError,
630
590
  );
631
591
  });
632
592
 
633
593
  test("fails FATALLY on a rejected request, naming what the endpoint said", async () => {
634
- stubProvider({ status: 400, body: { error_code: "audio_too_short", message: "too short" } });
594
+ stubProvider({ status: 400, message: "too short" });
635
595
  await expect(transcribeSegment(UPLOAD_ID, FORMAT, SEGMENT)).rejects.toThrow(
636
596
  /HTTP 400 — too short/,
637
597
  );
@@ -691,7 +651,7 @@ describe("the streaming flow", () => {
691
651
  function publishPartial(stored: number, declared: number, complete = false) {
692
652
  const bytes = new Uint8Array(44 + stored);
693
653
  bytes.set(wavFile(MONO_16K, declared), 0);
694
- restore = stubUploads({
654
+ installStubUploads({
695
655
  [UPLOAD_ID]: { bytes, name: "standup.wav", type: "audio/wav", complete },
696
656
  });
697
657
  }
@@ -725,12 +685,12 @@ describe("the streaming flow", () => {
725
685
  // the file has finished — which is exactly what `transcribe` is for.
726
686
  const bytes = new Uint8Array(44 + 100);
727
687
  bytes.set(wavFile(MONO_16K, 100, { declaredDataSize: 0 }), 0);
728
- restore = stubUploads({ [UPLOAD_ID]: { bytes, complete: false } });
688
+ installStubUploads({ [UPLOAD_ID]: { bytes, complete: false } });
729
689
  await expect(planStreamed(UPLOAD_ID)).rejects.toThrow(/declares no data length/);
730
690
  });
731
691
 
732
692
  test("planStreamed refuses a file that is not a WAV, terminally", async () => {
733
- restore = stubUploads({ [UPLOAD_ID]: { bytes: new Uint8Array(2000), complete: false } });
693
+ installStubUploads({ [UPLOAD_ID]: { bytes: new Uint8Array(2000), complete: false } });
734
694
  // Fatal, not retryable: three more attempts read the same bytes.
735
695
  await expect(planStreamed(UPLOAD_ID)).rejects.toBeInstanceOf(FatalError);
736
696
  });
@@ -761,21 +721,20 @@ describe("the async flow", () => {
761
721
  vi.stubEnv("ASSEMBLYAI_API_KEY", "sk-test");
762
722
  });
763
723
 
764
- const batchStubs: (() => void)[] = [];
765
- afterEach(() => {
766
- for (const undo of batchStubs.splice(0)) undo();
767
- });
768
-
769
- /** Answer the async API, recording what was sent. */
770
- function stubBatch(answer: (url: string) => { status?: number; body?: unknown }) {
771
- const stub = stubStepFetch((req) => answer(req.url));
772
- batchStubs.push(stub.restore);
773
- 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;
774
733
  }
775
734
 
776
735
  test("uploadToProvider streams the file and answers with the provider's URL", async () => {
777
736
  publishRecording(new Uint8Array(5000), "standup.wav");
778
- const calls = stubBatch(() => ({ body: { upload_url: "https://cdn.example/abc" } }));
737
+ const calls = stubBatch();
779
738
  await expect(uploadToProvider(UPLOAD_ID)).resolves.toEqual({
780
739
  audioUrl: "https://cdn.example/abc",
781
740
  });
@@ -784,9 +743,9 @@ describe("the async flow", () => {
784
743
 
785
744
  test("the file is STREAMED, so a step never holds a whole recording", async () => {
786
745
  publishRecording(new Uint8Array(5000), "standup.wav");
787
- const calls = stubBatch(() => ({ body: { upload_url: "https://cdn.example/abc" } }));
746
+ const calls = stubBatch();
788
747
  await uploadToProvider(UPLOAD_ID);
789
- // `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
790
749
  // every byte went out — the streaming is what keeps a gigabyte off the heap, and
791
750
  // the bytes arriving intact is what says the windowing is right.
792
751
  const sent = calls[0]?.body;
@@ -799,7 +758,7 @@ describe("the async flow", () => {
799
758
  // thing five times and re-uploaded 24 MB on each attempt. The split is what makes
800
759
  // a retry of the cheap half cost the cheap half.
801
760
  publishRecording(new Uint8Array(5000));
802
- const calls = stubBatch(() => ({ status: 400, body: { error: "bad field" } }));
761
+ const calls = stubBatch({ failure: { leg: "submit", status: 400, message: "bad field" } });
803
762
  await expect(createJob("https://cdn.example/abc")).rejects.toBeInstanceOf(FatalError);
804
763
  // One call, and it is not the upload.
805
764
  expect(calls).toHaveLength(1);
@@ -808,7 +767,7 @@ describe("the async flow", () => {
808
767
 
809
768
  test("createJob asks for `speech_models`, plural — the singular field is a 400", async () => {
810
769
  publishRecording(new Uint8Array(10));
811
- const calls = stubBatch(() => ({ body: { id: "tr_1" } }));
770
+ const calls = stubBatch({ jobIdPrefix: "tr_" });
812
771
  await expect(createJob("https://cdn.example/abc")).resolves.toEqual({ id: "tr_1" });
813
772
  const sent = JSON.parse(String(calls[0]?.body)) as Record<string, unknown>;
814
773
  // The async API deprecated `speech_model` and answers 400 for any current model
@@ -820,7 +779,7 @@ describe("the async flow", () => {
820
779
 
821
780
  test("a job the provider gave up on is TERMINAL, not polled forever", async () => {
822
781
  publishRecording(new Uint8Array(10));
823
- stubBatch(() => ({ body: { status: "error", error: "audio too quiet" } }));
782
+ stubBatch({ jobError: "audio too quiet" });
824
783
  // The provider has decided; no number of polls changes it, so this must not come
825
784
  // back as "not done yet".
826
785
  await expect(pollTranscript(UPLOAD_ID, "tr_1", STARTED_AT)).rejects.toBeInstanceOf(FatalError);
@@ -828,13 +787,15 @@ describe("the async flow", () => {
828
787
 
829
788
  test("pollTranscript answers `done` on completed and not before", async () => {
830
789
  publishRecording(new Uint8Array(10));
831
- stubBatch(() => ({ body: { status: "processing" } }));
790
+ stubBatch({ pendingPolls: 1 });
832
791
  await expect(pollTranscript(UPLOAD_ID, "tr_1", STARTED_AT)).resolves.toEqual({ done: false });
833
792
  });
834
793
 
835
794
  test("an unknown status is NOT done, so a new one cannot end a run early", async () => {
836
795
  publishRecording(new Uint8Array(10));
837
- stubBatch(() => ({ body: {} }));
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: {} }));
838
799
  await expect(pollTranscript(UPLOAD_ID, "tr_1", STARTED_AT)).resolves.toMatchObject({
839
800
  done: false,
840
801
  });
@@ -844,9 +805,7 @@ describe("the async flow", () => {
844
805
  publishRecording(new Uint8Array(10), "standup.wav");
845
806
  // It used to poll for a status and then fetch the identical URL again for the
846
807
  // text the poll already had in its hand.
847
- const calls = stubBatch(() => ({
848
- body: { status: "completed", text: " hello there ", audio_duration: 12.5 },
849
- }));
808
+ const calls = stubBatch({ text: " hello there ", durationSec: 12.5 });
850
809
  await expect(pollTranscript(UPLOAD_ID, "tr_1", STARTED_AT)).resolves.toMatchObject({
851
810
  done: true,
852
811
  transcript: {
@@ -864,7 +823,7 @@ describe("the async flow", () => {
864
823
 
865
824
  test("a rate limit is RETRYABLE, so a busy minute does not fail the run", async () => {
866
825
  publishRecording(new Uint8Array(10));
867
- stubBatch(() => ({ status: 429, body: { error: "slow down" } }));
826
+ stubBatch({ failure: { leg: "poll", status: 429, message: "slow down" } });
868
827
  await expect(pollTranscript(UPLOAD_ID, "tr_1", STARTED_AT)).rejects.toBeInstanceOf(
869
828
  RetryableError,
870
829
  );
@@ -872,7 +831,7 @@ describe("the async flow", () => {
872
831
 
873
832
  test("all three flows report the same SHAPE, which is what lets one page render any", async () => {
874
833
  publishRecording(new Uint8Array(10), "standup.wav");
875
- stubBatch(() => ({ body: { status: "completed", text: "hi", audio_duration: 1 } }));
834
+ stubBatch({ text: "hi", durationSec: 1 });
876
835
  const progress = await pollTranscript(UPLOAD_ID, "tr_1", STARTED_AT);
877
836
  if (!progress.done) return expect.fail("the stub reports a completed job");
878
837
  const batched = progress.transcript;
@@ -943,71 +902,20 @@ describe("normalizing the recording", () => {
943
902
  // the caller stored. A step that copied it would double the storage every run
944
903
  // pays for and would still report success.
945
904
  publishRecording(wavFile(MONO_16K, 32_000), "standup.wav");
946
- const reporter = stubReporter();
947
- try {
948
- await expect(normalizeRecording(UPLOAD_ID)).resolves.toEqual({
949
- recording: UPLOAD_ID,
950
- converted: false,
951
- });
952
- expect(reporter.lines.join(" ")).toContain("already linear-PCM WAV");
953
- } finally {
954
- reporter.restore();
955
- }
956
- });
957
-
958
- test("a conversion that ffmpeg REFUSED is fatal, so it is not attempted five times", () => {
959
- // `exit` means ffmpeg read the file and would read it the same way again.
960
- expect(() =>
961
- classifyFfmpeg(
962
- new FfmpegError({
963
- kind: "exit",
964
- message: "Invalid data found when processing input",
965
- binary: "ffmpeg",
966
- argv: [],
967
- exitCode: 1,
968
- }),
969
- ),
970
- ).toThrow(FatalError);
971
- });
972
-
973
- test("a conversion that ran out of time keeps its retries, and its argv", () => {
974
- // Rethrown UNCHANGED rather than wrapped, which is what `toStepError` does
975
- // with an error carrying no verdict — and the DevKit's default for anything
976
- // that is not a `FatalError` is to retry. Asserting the class survives is
977
- // asserting the diagnosis does: `argv` is the command you paste into a shell,
978
- // and a `new RetryableError(message)` here would throw it away.
979
- const timedOut = new FfmpegError({
980
- kind: "timeout",
981
- message: "timed out",
982
- binary: "ffmpeg",
983
- argv: ["-i", "source"],
905
+ const reporter = installStubReporter();
906
+ await expect(normalizeRecording(UPLOAD_ID)).resolves.toEqual({
907
+ recording: UPLOAD_ID,
908
+ converted: false,
984
909
  });
985
- expect(() => classifyFfmpeg(timedOut)).toThrow(timedOut);
986
- expect(() => classifyFfmpeg(timedOut)).not.toThrow(FatalError);
987
- });
988
-
989
- test("no ffmpeg at all is fatal — a retry cannot install one", () => {
990
- // The `aai dev` case. Fatal deliberately: the message already carries the
991
- // install instructions, and four more attempts only delay a person reading it.
992
- expect(() =>
993
- classifyFfmpeg(
994
- new FfmpegError({
995
- kind: "missing-binary",
996
- message: "ffmpeg is not installed",
997
- binary: "ffmpeg",
998
- argv: [],
999
- }),
1000
- ),
1001
- ).toThrow(FatalError);
1002
- });
1003
-
1004
- test("something that is not an ffmpeg failure at all is fatal", () => {
1005
- // The store rejecting a write, say. Fatal rather than retryable because this
1006
- // step's own `maxRetries` exists for the I/O halves that report themselves as
1007
- // transient; an unrecognized error has said nothing about being worth another
1008
- // attempt, and guessing yes is how a run burns its budget before failing.
1009
- expect(() => classifyFfmpeg(new Error("no space left on device"))).toThrow(FatalError);
910
+ expect(reporter.lines.join(" ")).toContain("already linear-PCM WAV");
1010
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.
1011
919
  });
1012
920
 
1013
921
  describe("expectedSegments", () => {
@@ -1065,19 +973,15 @@ describe("the conversion, up to the spawn", () => {
1065
973
  new Uint8Array([0, 0, 0, 0x20, 0x66, 0x74, 0x79, 0x70, 0x4d, 0x34, 0x41, 0x20]),
1066
974
  "standup.m4a",
1067
975
  );
1068
- const reporter = stubReporter();
1069
- try {
1070
- // Fatal, not retryable: four more attempts find the same missing binary, and
1071
- // the message already carries the install instructions.
1072
- await expect(normalizeRecording(UPLOAD_ID)).rejects.toThrow(/ffprobe/);
1073
- await expect(normalizeRecording(UPLOAD_ID)).rejects.toBeInstanceOf(FatalError);
1074
- // It got as far as deciding the file needs converting — the failure is the
1075
- // binary, not the input.
1076
- expect(reporter.lines.join(" ")).toContain("standup.m4a");
1077
- expect(normalizeRecording.maxRetries).toBe(5);
1078
- } finally {
1079
- reporter.restore();
1080
- }
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);
1081
985
  });
1082
986
 
1083
987
  test("leaves no temp directory behind when the conversion fails", async () => {
@@ -1087,12 +991,8 @@ describe("the conversion, up to the spawn", () => {
1087
991
  const leaked = (names: string[]) => names.filter((n) => n.startsWith("aai-normalize-"));
1088
992
  const before = leaked(await readdir(tmpdir()));
1089
993
  publishRecording(new Uint8Array([0, 0, 0, 0x20, 0x66, 0x74, 0x79, 0x70]), "standup.m4a");
1090
- const reporter = stubReporter();
1091
- try {
1092
- await expect(normalizeRecording(UPLOAD_ID)).rejects.toBeInstanceOf(FatalError);
1093
- expect(leaked(await readdir(tmpdir()))).toEqual(before);
1094
- } finally {
1095
- reporter.restore();
1096
- }
994
+ installStubReporter();
995
+ await expect(normalizeRecording(UPLOAD_ID)).rejects.toBeInstanceOf(FatalError);
996
+ expect(leaked(await readdir(tmpdir()))).toEqual(before);
1097
997
  });
1098
998
  });
@@ -16,7 +16,7 @@
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
@@ -151,7 +151,8 @@
151
151
  */
152
152
 
153
153
  import "@alexkroman1/aai-ui/styles.css";
154
- import type { WorkflowOutputOf } from "@alexkroman1/aai";
154
+ import { countWords, formatDuration, plural } from "@alexkroman1/aai/utils";
155
+ import type { WorkflowOutputOf } from "@alexkroman1/aai/workflow-api";
155
156
  import {
156
157
  Form,
157
158
  isTerminal,
@@ -162,19 +163,14 @@ import {
162
163
  useWorkflowRuns,
163
164
  useWorkflowStream,
164
165
  useWorkflowSubmit,
166
+ WORKFLOW_STATUS_LABELS,
165
167
  WorkflowFields,
166
168
  WorkflowProgress,
167
169
  type WorkflowRun,
168
170
  } from "@alexkroman1/aai-ui";
169
171
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
170
172
  import type { transcribe } from "./agent.ts";
171
- import {
172
- clock,
173
- countWords,
174
- stitchChunks,
175
- TRANSCRIPT_STREAM,
176
- type TranscriptChunk,
177
- } from "./workflows/stitch.ts";
173
+ import { stitchChunks, TRANSCRIPT_STREAM, type TranscriptChunk } from "./workflows/stitch.ts";
178
174
 
179
175
  /**
180
176
  * What a finished run reports.
@@ -343,10 +339,10 @@ function TotalLatency({
343
339
  {running ? "Elapsed" : "Total latency"}
344
340
  </h2>
345
341
  <span className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
346
- <span className="text-sm tabular-nums">{duration(elapsedMs)}</span>
342
+ <span className="text-sm tabular-nums">{formatDuration(elapsedMs)}</span>
347
343
  {runMs !== undefined && outside !== undefined && (
348
344
  <span className="text-xs tabular-nums opacity-60">
349
- {duration(outside)} before the run · {duration(runMs)} inside it
345
+ {formatDuration(outside)} before the run · {formatDuration(runMs)} inside it
350
346
  </span>
351
347
  )}
352
348
  </span>
@@ -647,9 +643,9 @@ function RunPanel({ run, onClear }: { run: WorkflowRun<Transcript>; onClear?: ()
647
643
  {run.status === "completed" && (
648
644
  <>
649
645
  <p className="text-xs opacity-60">
650
- {run.output.segments} {run.output.segments === 1 ? "segment" : "segments"} ·{" "}
651
- {duration(run.output.durationMs)} of audio · took {duration(run.output.elapsedMs)} ·{" "}
652
- {run.output.words} words
646
+ {run.output.segments} {plural(run.output.segments, "segment")} ·{" "}
647
+ {formatDuration(run.output.durationMs)} of audio · took{" "}
648
+ {formatDuration(run.output.elapsedMs)} · {run.output.words} words
653
649
  </p>
654
650
  <pre className="whitespace-pre-wrap text-sm leading-relaxed">{run.output.transcript}</pre>
655
651
  </>
@@ -691,47 +687,26 @@ function LiveTranscript({ runId }: { runId: string }) {
691
687
  return (
692
688
  <div className="flex flex-col gap-2">
693
689
  <p className="text-xs opacity-60">
694
- {countWords(transcript)} words so far · through {clock(covered)}
690
+ {countWords(transcript)} words so far · through {formatDuration(covered)}
695
691
  </p>
696
692
  <pre className="whitespace-pre-wrap text-sm leading-relaxed opacity-80">{transcript}</pre>
697
693
  </div>
698
694
  );
699
695
  }
700
696
 
701
- /**
702
- * A duration a person can read.
703
- *
704
- * `${Math.round(ms / 1000)}s` was what this printed, and an hour-long recording came
705
- * out as `3746s` — which a reader asked whether they should parse as 37.46 seconds.
706
- * A raw second count stops being readable at about ninety of them, and the recordings
707
- * this desk is FOR are the ones past that.
708
- *
709
- * The hours component is omitted when it is zero rather than padded to `0:02:26`, so
710
- * a two-minute clip reads as `2:26` and only a long one grows a field.
711
- */
712
- function duration(ms: number): string {
713
- const total = Math.max(0, Math.round(ms / 1000));
714
- const seconds = String(total % 60).padStart(2, "0");
715
- const minutes = Math.floor(total / 60) % 60;
716
- const hours = Math.floor(total / 3600);
717
- return hours > 0
718
- ? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}`
719
- : `${minutes}:${seconds}`;
720
- }
721
-
722
697
  /**
723
698
  * One line describing where a run has got to.
724
699
  *
725
- * A `Record` keyed by the status union rather than a switch, so a status added
726
- * to the SDK is a compile error here instead of falling through a `default:`
727
- * into whichever line was last.
700
+ * `WORKFLOW_STATUS_LABELS` is the SDK's neutral map a `Record` keyed by the
701
+ * status union rather than a switch, so a status added upstream is a compile
702
+ * error in one place every page inherits, and spreading a complete record cannot
703
+ * drop a key. Two of these keys are really this desk's: a page knows what its
704
+ * workflow does and the SDK does not.
728
705
  */
729
- const STATUS_LINE: Record<WorkflowRun["status"], string> = {
730
- pending: "Queued",
706
+ const STATUS_LINE = {
707
+ ...WORKFLOW_STATUS_LABELS,
731
708
  running: "Transcribing…",
732
709
  completed: "Transcript ready",
733
- failed: "Failed",
734
- cancelled: "Cancelled",
735
710
  };
736
711
 
737
712
  page({ name: "Transcription Desk", component: TranscriptionDesk });