@alexkroman1/aai-cli 5.14.0 → 6.1.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 (231) hide show
  1. package/dist/{_agent-C8Jk-ZcP.mjs → _agent-vGW5SBnX.mjs} +23 -5
  2. package/dist/_agent.d.ts +18 -0
  3. package/dist/{_api-client-BX8Qv51n.mjs → _api-client-LgLksMhN.mjs} +45 -8
  4. package/dist/_api-client.d.ts +39 -0
  5. package/dist/{_bundler-BuIyDdvs.mjs → _bundler-BapCUUJN.mjs} +42 -14
  6. package/dist/_bundler.d.ts +43 -5
  7. package/dist/_cli-common.d.ts +67 -3
  8. package/dist/{_config-CzwAWOWr.mjs → _config-D_s09e7g.mjs} +36 -18
  9. package/dist/_deploy.d.ts +2 -5
  10. package/dist/_dev-restart.d.ts +6 -1
  11. package/dist/{_dev-server-Ch7K8e8i.mjs → _dev-server-DQ1t9sbw.mjs} +94 -38
  12. package/dist/_dev-server.d.ts +30 -2
  13. package/dist/_e2e-test-utils.d.ts +80 -0
  14. package/dist/_fault-mode.d.ts +175 -0
  15. package/dist/{_init-CbkrE2wq.mjs → _init-CpISqoR8.mjs} +3 -3
  16. package/dist/{_output-CC300DzW.mjs → _output-CKkmWs7i.mjs} +8 -1
  17. package/dist/_output.d.ts +9 -2
  18. package/dist/{_server-common-61fL__39.mjs → _server-common-6e0QI4mq.mjs} +1 -1
  19. package/dist/{_slug-api-D30zPmsn.mjs → _slug-api-D2heILuC.mjs} +2 -2
  20. package/dist/{_studio-D2ciIhbi.mjs → _studio-zAJycZ8j.mjs} +3 -3
  21. package/dist/{_templates-B1kt3BGt.mjs → _templates-iDGjGiPf.mjs} +4 -6
  22. package/dist/{_typecheck-gate-4v9UBZHh.mjs → _typecheck-gate-BXvUNnfs.mjs} +1 -1
  23. package/dist/{_utils-0DHbcqUb.mjs → _utils-B8QmtFhK.mjs} +21 -7
  24. package/dist/_utils.d.ts +18 -2
  25. package/dist/{_vite-env-Dg_QlVv0.mjs → _vite-env-BNveawd1.mjs} +31 -1
  26. package/dist/_vite-env.d.ts +30 -0
  27. package/dist/build-BO1Ni6oJ.mjs +62 -0
  28. package/dist/build.d.ts +19 -0
  29. package/dist/cli-workflow.d.ts +10 -0
  30. package/dist/cli.d.ts +1 -2
  31. package/dist/cli.mjs +406 -224
  32. package/dist/{client-bundler-C9qXL8A2.mjs → client-bundler-B8Q9F8gJ.mjs} +3 -22
  33. package/dist/client-bundler.mjs +1 -1
  34. package/dist/{delete-DCRX3d96.mjs → delete-4I0uNR4a.mjs} +6 -7
  35. package/dist/delete.d.ts +2 -8
  36. package/dist/{deploy-DjsEV-40.mjs → deploy-DJ3HRcia.mjs} +13 -14
  37. package/dist/{dev-CvPH5dbF.mjs → dev-C6SWaAZP.mjs} +3 -3
  38. package/dist/{eject-LgBRfnbH.mjs → eject-Zrzk5KV3.mjs} +41 -15
  39. package/dist/eject.d.ts +30 -3
  40. package/dist/{init-BppxMR8r.mjs → init-BTRGiPQr.mjs} +61 -26
  41. package/dist/init.d.ts +11 -0
  42. package/dist/{login-BQQaxJhL.mjs → login-k0Z3VQu9.mjs} +5 -5
  43. package/dist/project-config.mjs +1 -1
  44. package/dist/scaffold/CLAUDE.md +498 -91
  45. package/dist/scaffold/package.json +11 -8
  46. package/dist/scaffold/pnpm-workspace.yaml +8 -0
  47. package/dist/scaffold/server.mjs +53 -57
  48. package/dist/{secret-Ds9dvqAj.mjs → secret-DzSQx1Ds.mjs} +6 -3
  49. package/dist/{storage-DbrQSHFk.mjs → storage-C-Do0mIA.mjs} +18 -6
  50. package/dist/{studio-GSFd4s5O.mjs → studio-BGVmCA2G.mjs} +10 -10
  51. package/dist/templates/code-interpreter/agent.ts +0 -2
  52. package/dist/templates/dispatch-center/agent.test.ts +17 -19
  53. package/dist/templates/dispatch-center/agent.ts +2 -29
  54. package/dist/templates/dispatch-center/client.tsx +33 -16
  55. package/dist/templates/dispatch-center/shared.ts +53 -19
  56. package/dist/templates/dispatch-center/tools/incident_add_note.ts +12 -14
  57. package/dist/templates/dispatch-center/tools/incident_create.ts +41 -44
  58. package/dist/templates/dispatch-center/tools/incident_escalate.ts +59 -61
  59. package/dist/templates/dispatch-center/tools/incident_get.ts +3 -4
  60. package/dist/templates/dispatch-center/tools/incident_triage.ts +43 -45
  61. package/dist/templates/dispatch-center/tools/incident_update_status.ts +39 -41
  62. package/dist/templates/dispatch-center/tools/ops_dashboard.ts +2 -5
  63. package/dist/templates/dispatch-center/tools/ops_protocols.ts +1 -1
  64. package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +20 -23
  65. package/dist/templates/dispatch-center/tools/resources_dispatch.ts +52 -54
  66. package/dist/templates/dispatch-center/tools/resources_get_available.ts +2 -4
  67. package/dist/templates/dispatch-center/tools/resources_update_status.ts +39 -42
  68. package/dist/templates/embedded-assets/agent.test.ts +83 -0
  69. package/dist/templates/embedded-assets/agent.ts +1 -48
  70. package/dist/templates/embedded-assets/shared.ts +17 -0
  71. package/dist/templates/embedded-assets/system-prompt.md +13 -0
  72. package/dist/templates/embedded-assets/tools/list_topics.ts +9 -0
  73. package/dist/templates/embedded-assets/tools/search_knowledge.ts +26 -0
  74. package/dist/templates/health-assistant/agent.test.ts +169 -0
  75. package/dist/templates/health-assistant/agent.ts +1 -166
  76. package/dist/templates/health-assistant/fda.ts +100 -0
  77. package/dist/templates/health-assistant/tools/check_drug_interaction.ts +59 -0
  78. package/dist/templates/health-assistant/tools/medication_lookup.ts +31 -0
  79. package/dist/templates/infocom-adventure/agent.test.ts +205 -0
  80. package/dist/templates/infocom-adventure/agent.ts +3 -106
  81. package/dist/templates/infocom-adventure/client.tsx +28 -12
  82. package/dist/templates/infocom-adventure/shared.ts +28 -11
  83. package/dist/templates/infocom-adventure/tools/game_state_drop.ts +13 -0
  84. package/dist/templates/infocom-adventure/tools/game_state_flag.ts +13 -0
  85. package/dist/templates/infocom-adventure/tools/game_state_get.ts +16 -0
  86. package/dist/templates/infocom-adventure/tools/game_state_history.ts +14 -0
  87. package/dist/templates/infocom-adventure/tools/game_state_move.ts +14 -0
  88. package/dist/templates/infocom-adventure/tools/game_state_restart.ts +13 -0
  89. package/dist/templates/infocom-adventure/tools/game_state_score.ts +13 -0
  90. package/dist/templates/infocom-adventure/tools/game_state_take.ts +13 -0
  91. package/dist/templates/link-digest/agent.test.ts +223 -0
  92. package/dist/templates/link-digest/agent.ts +79 -0
  93. package/dist/templates/link-digest/client.tsx +146 -0
  94. package/dist/templates/link-digest/workflows/digest.ts +248 -0
  95. package/dist/templates/math-buddy/agent.ts +0 -2
  96. package/dist/templates/night-owl/agent.test.ts +80 -0
  97. package/dist/templates/night-owl/agent.ts +1 -68
  98. package/dist/templates/night-owl/system-prompt.md +10 -0
  99. package/dist/templates/night-owl/tools/recommend.ts +64 -0
  100. package/dist/templates/personal-finance/agent.ts +0 -2
  101. package/dist/templates/pizza-ordering/agent.test.ts +52 -47
  102. package/dist/templates/pizza-ordering/agent.ts +9 -158
  103. package/dist/templates/pizza-ordering/shared.ts +35 -12
  104. package/dist/templates/pizza-ordering/tools/add_pizza.ts +31 -0
  105. package/dist/templates/pizza-ordering/tools/place_order.ts +22 -0
  106. package/dist/templates/pizza-ordering/tools/remove_pizza.ts +22 -0
  107. package/dist/templates/pizza-ordering/tools/set_customer_name.ts +13 -0
  108. package/dist/templates/pizza-ordering/tools/update_pizza.ts +28 -0
  109. package/dist/templates/pizza-ordering/tools/view_order.ts +21 -0
  110. package/dist/templates/plan-and-execute/agent.test.ts +411 -0
  111. package/dist/templates/plan-and-execute/agent.ts +23 -0
  112. package/dist/templates/plan-and-execute/client.tsx +115 -0
  113. package/dist/templates/plan-and-execute/graph.ts +178 -0
  114. package/dist/templates/plan-and-execute/prompts.ts +110 -0
  115. package/dist/templates/plan-and-execute/shared.ts +161 -0
  116. package/dist/templates/plan-and-execute/system-prompt.md +18 -0
  117. package/dist/templates/plan-and-execute/tools/plan_status.ts +20 -0
  118. package/dist/templates/plan-and-execute/tools/revise_plan.ts +63 -0
  119. package/dist/templates/plan-and-execute/tools/start_plan.ts +51 -0
  120. package/dist/templates/plan-and-execute/tools/work_next_step.ts +101 -0
  121. package/dist/templates/recap-workflow/agent.test.ts +762 -0
  122. package/dist/templates/recap-workflow/agent.ts +95 -0
  123. package/dist/templates/recap-workflow/shared.ts +43 -0
  124. package/dist/templates/recap-workflow/tools/cancel_recap.ts +23 -0
  125. package/dist/templates/recap-workflow/tools/keep_transcript.ts +31 -0
  126. package/dist/templates/recap-workflow/tools/recap_progress.ts +27 -0
  127. package/dist/templates/recap-workflow/tools/recap_status.ts +46 -0
  128. package/dist/templates/recap-workflow/tools/request_recap.ts +43 -0
  129. package/dist/templates/recap-workflow/workflows/recap.ts +544 -0
  130. package/dist/templates/recap-workflow/workflows/tokens.ts +26 -0
  131. package/dist/templates/redline/agent.test.ts +222 -0
  132. package/dist/templates/redline/agent.ts +89 -0
  133. package/dist/templates/redline/client.tsx +184 -0
  134. package/dist/templates/redline/workflows/prompts.ts +68 -0
  135. package/dist/templates/redline/workflows/redline.ts +258 -0
  136. package/dist/templates/research-workflow/agent.test.ts +525 -0
  137. package/dist/templates/research-workflow/agent.ts +94 -0
  138. package/dist/templates/research-workflow/shared.ts +35 -0
  139. package/dist/templates/research-workflow/tools/file_it_now.ts +22 -0
  140. package/dist/templates/research-workflow/tools/request_research.ts +33 -0
  141. package/dist/templates/research-workflow/tools/research_progress.ts +26 -0
  142. package/dist/templates/research-workflow/tools/research_status.ts +40 -0
  143. package/dist/templates/research-workflow/workflows/prompts.ts +143 -0
  144. package/dist/templates/research-workflow/workflows/research.ts +558 -0
  145. package/dist/templates/retail/agent.test.ts +236 -172
  146. package/dist/templates/retail/agent.ts +5 -42
  147. package/dist/templates/retail/client.tsx +33 -11
  148. package/dist/templates/retail/registry.test.ts +31 -16
  149. package/dist/templates/retail/resolve.ts +28 -64
  150. package/dist/templates/retail/shared.test.ts +4 -3
  151. package/dist/templates/retail/shared.ts +24 -7
  152. package/dist/templates/retail/store.test.ts +12 -10
  153. package/dist/templates/retail/store.ts +69 -30
  154. package/dist/templates/retail/tools/cancel_pending_order.ts +4 -9
  155. package/dist/templates/retail/tools/exchange_delivered_order_items.ts +22 -11
  156. package/dist/templates/retail/tools/find_user_id_by_email.ts +5 -9
  157. package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +5 -7
  158. package/dist/templates/retail/tools/get_item_details.ts +5 -7
  159. package/dist/templates/retail/tools/get_order_details.ts +5 -7
  160. package/dist/templates/retail/tools/get_product_details.ts +4 -7
  161. package/dist/templates/retail/tools/get_user_details.ts +4 -7
  162. package/dist/templates/retail/tools/list_all_product_types.ts +5 -7
  163. package/dist/templates/retail/tools/modify_pending_order_address.ts +4 -9
  164. package/dist/templates/retail/tools/modify_pending_order_items.ts +4 -16
  165. package/dist/templates/retail/tools/modify_pending_order_payment.ts +3 -9
  166. package/dist/templates/retail/tools/modify_user_address.ts +4 -9
  167. package/dist/templates/retail/tools/return_delivered_order_items.ts +3 -9
  168. package/dist/templates/retail/tools/transfer_to_human_agents.ts +1 -1
  169. package/dist/templates/solo-rpg/agent.test.ts +152 -29
  170. package/dist/templates/solo-rpg/agent.ts +3 -21
  171. package/dist/templates/solo-rpg/client.tsx +19 -6
  172. package/dist/templates/solo-rpg/shared.ts +19 -11
  173. package/dist/templates/solo-rpg/tools/action_roll.ts +2 -4
  174. package/dist/templates/solo-rpg/tools/burn_momentum.ts +2 -4
  175. package/dist/templates/solo-rpg/tools/check_state.ts +6 -6
  176. package/dist/templates/solo-rpg/tools/load_game.ts +1 -1
  177. package/dist/templates/solo-rpg/tools/oracle.ts +18 -11
  178. package/dist/templates/solo-rpg/tools/save_game.ts +10 -7
  179. package/dist/templates/solo-rpg/tools/setup_character.ts +1 -1
  180. package/dist/templates/solo-rpg/tools/update_state.ts +3 -4
  181. package/dist/templates/support-line/agent.test.ts +343 -0
  182. package/dist/templates/support-line/agent.ts +27 -0
  183. package/dist/templates/support-line/client.tsx +122 -0
  184. package/dist/templates/support-line/graph.ts +224 -0
  185. package/dist/templates/support-line/knowledge.json +65 -0
  186. package/dist/templates/support-line/prompts.ts +95 -0
  187. package/dist/templates/support-line/shared.ts +240 -0
  188. package/dist/templates/support-line/system-prompt.md +17 -0
  189. package/dist/templates/support-line/tools/answer_question.ts +72 -0
  190. package/dist/templates/support-line/tools/list_topics.ts +22 -0
  191. package/dist/templates/support-line/tools/log_ticket.ts +30 -0
  192. package/dist/templates/transcription-workflow/agent.test.ts +563 -0
  193. package/dist/templates/transcription-workflow/agent.ts +92 -0
  194. package/dist/templates/transcription-workflow/client.tsx +234 -0
  195. package/dist/templates/transcription-workflow/workflows/transcribe.ts +416 -0
  196. package/dist/templates/transcription-workflow/workflows/wav.ts +314 -0
  197. package/dist/templates/travel-concierge/agent.test.ts +338 -0
  198. package/dist/templates/travel-concierge/agent.ts +29 -0
  199. package/dist/templates/travel-concierge/client.tsx +161 -0
  200. package/dist/templates/travel-concierge/routing.ts +138 -0
  201. package/dist/templates/travel-concierge/shared.ts +547 -0
  202. package/dist/templates/travel-concierge/system-prompt.md +24 -0
  203. package/dist/templates/travel-concierge/tools/book_car_rental.ts +20 -0
  204. package/dist/templates/travel-concierge/tools/book_excursion.ts +18 -0
  205. package/dist/templates/travel-concierge/tools/book_hotel.ts +20 -0
  206. package/dist/templates/travel-concierge/tools/cancel_action.ts +7 -0
  207. package/dist/templates/travel-concierge/tools/cancel_ticket.ts +16 -0
  208. package/dist/templates/travel-concierge/tools/complete_or_escalate.ts +7 -0
  209. package/dist/templates/travel-concierge/tools/confirm_action.ts +7 -0
  210. package/dist/templates/travel-concierge/tools/lookup_booking.ts +35 -0
  211. package/dist/templates/travel-concierge/tools/search_car_rentals.ts +33 -0
  212. package/dist/templates/travel-concierge/tools/search_excursions.ts +37 -0
  213. package/dist/templates/travel-concierge/tools/search_flights.ts +43 -0
  214. package/dist/templates/travel-concierge/tools/search_hotels.ts +36 -0
  215. package/dist/templates/travel-concierge/tools/to_car_rental_assistant.ts +11 -0
  216. package/dist/templates/travel-concierge/tools/to_excursion_assistant.ts +11 -0
  217. package/dist/templates/travel-concierge/tools/to_flight_assistant.ts +11 -0
  218. package/dist/templates/travel-concierge/tools/to_hotel_assistant.ts +11 -0
  219. package/dist/templates/travel-concierge/tools/update_ticket.ts +22 -0
  220. package/dist/templates/web-researcher/agent.ts +0 -2
  221. package/dist/templates/web-researcher/system-prompt.md +10 -0
  222. package/dist/{test-2LWBcXvY.mjs → test-B6ZBxSpk.mjs} +2 -2
  223. package/dist/typecheck.mjs +17 -15
  224. package/dist/worker-bundler-CIdDhZ0c.mjs +398 -0
  225. package/dist/worker-bundler.d.ts +14 -0
  226. package/dist/worker-bundler.mjs +1 -108
  227. package/dist/workflow-B-O97AqI.mjs +125 -0
  228. package/dist/workflow-bundler.d.ts +133 -0
  229. package/dist/workflow.d.ts +67 -0
  230. package/package.json +13 -9
  231. package/dist/build-DQJ1ZSm9.mjs +0 -39
@@ -0,0 +1,72 @@
1
+ import { errorMessage, tool, toolFailure } from "@alexkroman1/aai";
2
+ import { z } from "zod";
3
+ import { runCorrectiveRag } from "../graph.ts";
4
+ import { recordQuestion, supportSlot } from "../shared.ts";
5
+
6
+ /**
7
+ * The whole graph, as one tool.
8
+ *
9
+ * The model's ONLY route to an answer — the system prompt forbids answering
10
+ * from memory, and this is what makes that instruction enforceable: everything
11
+ * this returns is either grounded in a document the grader passed, or an
12
+ * admission that nothing covers the question.
13
+ *
14
+ * The verdicts ride back with the answer rather than being swallowed. A
15
+ * grounded-but-not-useful answer is still worth speaking, and the caller is the
16
+ * one who should hear that it may not be the whole story.
17
+ */
18
+ export default tool({
19
+ description:
20
+ "Answer a caller's question from the support knowledge base. This is the " +
21
+ "only way to answer anything about the product — never answer from your " +
22
+ "own knowledge. Pass the caller's question as they asked it.",
23
+ inputSchema: z.object({
24
+ question: z.string().max(500).describe("The caller's question, in their own words"),
25
+ }),
26
+ async execute(args, ctx) {
27
+ let trace: Awaited<ReturnType<typeof runCorrectiveRag>>;
28
+ try {
29
+ trace = await runCorrectiveRag(ctx.generate, args.question);
30
+ } catch (err: unknown) {
31
+ // A broken model call is the tool's to report: the model can tell the
32
+ // caller the lookup failed, which is a better turn than silence.
33
+ return toolFailure(`The knowledge base lookup failed: ${errorMessage(err)}`);
34
+ }
35
+
36
+ // No `await`: `slot.update` is SYNCHRONOUS, and that is the invariant
37
+ // rather than an implementation detail — the model call above is awaited in
38
+ // FRONT of the mutation for exactly that reason. Awaiting it would read as
39
+ // though the window could span a turn.
40
+ supportSlot.update(ctx, (state) => {
41
+ state.trace = trace;
42
+ recordQuestion(state, args.question);
43
+ });
44
+
45
+ const sources = trace.docs.filter((doc) => doc.relevant).map((doc) => doc.title);
46
+
47
+ if (!trace.answer) {
48
+ return {
49
+ answer: null,
50
+ rewrites: trace.rewrites,
51
+ guidance:
52
+ "Nothing in the knowledge base covers this. Say so plainly — do not " +
53
+ "guess — and offer to log a ticket with log_ticket.",
54
+ };
55
+ }
56
+
57
+ return {
58
+ answer: trace.answer,
59
+ sources,
60
+ grounded: trace.grounded,
61
+ answersTheQuestion: trace.useful,
62
+ rewrites: trace.rewrites,
63
+ ...(trace.exhausted
64
+ ? {
65
+ guidance:
66
+ "This is grounded in the documents but may not be the whole " +
67
+ "answer. Give it, then offer to log a ticket with log_ticket.",
68
+ }
69
+ : {}),
70
+ };
71
+ },
72
+ });
@@ -0,0 +1,22 @@
1
+ import { tool } from "@alexkroman1/aai";
2
+ import { DOCS, PRODUCT, TOPICS } from "../shared.ts";
3
+
4
+ /**
5
+ * What the knowledge base covers.
6
+ *
7
+ * Not a retrieval path — it is how the agent steers a caller who opens with
8
+ * "I've got a problem" instead of a question, and how it says what it CAN help
9
+ * with when `answer_question` comes back empty.
10
+ */
11
+ export default tool({
12
+ description:
13
+ "List what the knowledge base covers. Use this when the caller does not " +
14
+ "know what to ask, or to say what you can help with after a failed lookup.",
15
+ execute() {
16
+ return {
17
+ product: PRODUCT,
18
+ topics: TOPICS,
19
+ articles: DOCS.map((doc) => doc.title),
20
+ };
21
+ },
22
+ });
@@ -0,0 +1,30 @@
1
+ import { z } from "zod";
2
+ import { supportSlot } from "../shared.ts";
3
+
4
+ /**
5
+ * The exit the grading apparatus needs.
6
+ *
7
+ * A support line that can only answer is a support line that will eventually
8
+ * answer wrong. `exhausted` is a reachable state in the graph precisely so
9
+ * there is somewhere to go from it, and this is that somewhere.
10
+ */
11
+ export default supportSlot.updateTool({
12
+ description:
13
+ "Log a callback ticket for a question the knowledge base could not answer. " +
14
+ "Ask for a callback number first, and read the reference back.",
15
+ inputSchema: z.object({
16
+ question: z.string().max(500).describe("What the caller needs answered"),
17
+ callback: z.string().max(40).describe("The number to call them back on"),
18
+ }),
19
+ execute(args, state) {
20
+ state.ticketCounter++;
21
+ const reference = `TCK${String(4000 + state.ticketCounter)}`;
22
+ // The callback number stays in state and never reaches the browser — see
23
+ // `supportView`, which projects the reference alone.
24
+ state.ticket = { reference, question: args.question, callback: args.callback };
25
+ return {
26
+ reference,
27
+ message: `Ticket ${reference} logged. Read the reference back to the caller.`,
28
+ };
29
+ },
30
+ });
@@ -0,0 +1,563 @@
1
+ // Copyright 2026 the AAI authors. MIT license.
2
+ /**
3
+ * Specs for the transcription desk's declaration, its WAV arithmetic, and its
4
+ * steps.
5
+ *
6
+ * **The body itself is not driven here, and that is a property of what this
7
+ * template demonstrates rather than a gap in the spec.** Imported through vitest
8
+ * with no bundler in the path, a `"use step"` function is an ordinary async
9
+ * function — so its retries, its `FatalError` guards, its HTTP handling and its
10
+ * merge are all testable, while durability, suspension and replay are not. A
11
+ * body test that looked like a durability test would be the worse failure; the
12
+ * real thing is exercised end to end by `aai-cli`'s
13
+ * `dev-workflow.scenario.test.ts`, which builds a project and runs one.
14
+ *
15
+ * The WAV half is worth its own section because it is where a silent bug lives:
16
+ * a cut that lands mid-frame, or an off-by-one in the chunk walk, produces audio
17
+ * the decoder happily transcribes into confident nonsense.
18
+ */
19
+
20
+ import { stubStepFetch, stubUploads } from "@alexkroman1/aai/testing";
21
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
22
+ import { RetryableError } from "workflow";
23
+ import { z } from "zod";
24
+ import agentDef, { transcribe } from "./agent.ts";
25
+ import {
26
+ clock,
27
+ mergeTranscript,
28
+ splitRecording,
29
+ stitchTranscript,
30
+ transcribeSegment,
31
+ } from "./workflows/transcribe.ts";
32
+ import {
33
+ blockAlign,
34
+ bytesPerSecond,
35
+ MAX_BYTES_PER_SECOND,
36
+ MAX_SEGMENT_BYTES,
37
+ MAX_SEGMENT_SECONDS,
38
+ parseWav,
39
+ planSegments,
40
+ SEGMENT_SECONDS,
41
+ UnsupportedRecordingError,
42
+ type WavFormat,
43
+ wavWithHeader,
44
+ } from "./workflows/wav.ts";
45
+
46
+ /** Where the sync endpoint lives — the one URL these stubs answer differently. */
47
+ const SYNC_ORIGIN = "https://sync.assemblyai.com";
48
+
49
+ /** The id every spec below uploads under. */
50
+ const UPLOAD_ID = "upl_test";
51
+
52
+ /**
53
+ * Publish one in-memory upload, the way `createServer` publishes a real store.
54
+ *
55
+ * This is the seam that makes a step testable at all: `readUpload` reads a
56
+ * process-wide slot rather than dialling anything, so a spec supplies its own
57
+ * bytes with no server, no database and no HTTP.
58
+ */
59
+ function publishRecording(bytes: Uint8Array, name = "standup.wav") {
60
+ restore = stubUploads({ [UPLOAD_ID]: { bytes, name, type: "audio/wav" } });
61
+ }
62
+
63
+ /** Unpublished between specs — a slot left behind reaches the next file. */
64
+ let restore: (() => void) | undefined;
65
+ afterEach(() => {
66
+ restore?.();
67
+ restore = undefined;
68
+ });
69
+
70
+ /** 16 kHz mono 16-bit — one second of audio is 32,000 bytes. */
71
+ const MONO_16K = { sampleRate: 16_000, channels: 1, bitsPerSample: 16 } as const;
72
+
73
+ /** A canonical WAV header in front of `dataBytes` of (absent) samples. */
74
+ function wavFile(
75
+ fmt: { sampleRate: number; channels: number; bitsPerSample: number },
76
+ dataBytes: number,
77
+ overrides: { declaredDataSize?: number; extraChunk?: string } = {},
78
+ ): Uint8Array {
79
+ const extra = overrides.extraChunk;
80
+ const extraLength = extra === undefined ? 0 : 8 + extra.length + (extra.length % 2);
81
+ const head = new Uint8Array(44 + extraLength);
82
+ const view = new DataView(head.buffer);
83
+ const write = (at: number, text: string) => {
84
+ for (let i = 0; i < text.length; i++) view.setUint8(at + i, text.charCodeAt(i));
85
+ };
86
+
87
+ write(0, "RIFF");
88
+ view.setUint32(4, 36 + extraLength + dataBytes, true);
89
+ write(8, "WAVEfmt ");
90
+ view.setUint32(16, 16, true);
91
+ view.setUint16(20, 1, true);
92
+ view.setUint16(22, fmt.channels, true);
93
+ view.setUint32(24, fmt.sampleRate, true);
94
+ view.setUint32(28, (fmt.channels * fmt.bitsPerSample * fmt.sampleRate) / 8, true);
95
+ view.setUint16(32, (fmt.channels * fmt.bitsPerSample) / 8, true);
96
+ view.setUint16(34, fmt.bitsPerSample, true);
97
+
98
+ // An odd-length chunk before `data`, which is what the walk's padding rule is
99
+ // for — a recorder's `LIST`/`bext` block sits exactly here.
100
+ let at = 36;
101
+ if (extra !== undefined) {
102
+ write(at, "LIST");
103
+ view.setUint32(at + 4, extra.length, true);
104
+ write(at + 8, extra);
105
+ at += extraLength;
106
+ }
107
+ write(at, "data");
108
+ view.setUint32(at + 4, overrides.declaredDataSize ?? dataBytes, true);
109
+ return head;
110
+ }
111
+
112
+ describe("the agent declares its workflow and nothing else", () => {
113
+ test("under the name the REST route resolves it by", () => {
114
+ expect(Object.keys(agentDef.workflows ?? {})).toEqual(["transcribe"]);
115
+ expect(agentDef.workflows?.transcribe).toBe(transcribe);
116
+ });
117
+
118
+ test("with no tools, because the interface is the page and the API", () => {
119
+ // The point of the template: a workflow app needs no conversation. A tool
120
+ // reappearing here would mean the voice path had crept back in.
121
+ expect(Object.keys(agentDef.tools ?? {})).toEqual([]);
122
+ });
123
+
124
+ test("declaring the key its steps read, so a deploy checks for it", () => {
125
+ // Without this a missing credential is discovered by the first run, minutes
126
+ // after the deploy reported success.
127
+ expect(agentDef.requiredEnv).toContain("ASSEMBLYAI_API_KEY");
128
+ });
129
+ });
130
+
131
+ describe("the input schema", () => {
132
+ test("accepts what the page's form collects, with no mapping in between", async () => {
133
+ const result = await transcribe.input?.["~standard"].validate({
134
+ recording: "upl_9f3c1d",
135
+ });
136
+ expect(result?.issues).toBeUndefined();
137
+ });
138
+
139
+ test("takes the recording alone — there is nothing else to ask for", async () => {
140
+ const result = await transcribe.input?.["~standard"].validate({
141
+ recording: "upl_9f3c1d",
142
+ });
143
+ // Re-tested rather than trusted: a Standard Schema result is a union, so
144
+ // this is what makes `value` reachable without a cast.
145
+ expect(result?.issues).toBeUndefined();
146
+ if (result?.issues) expect.fail("expected the submission to validate");
147
+ expect(result?.value).toMatchObject({ recording: "upl_9f3c1d" });
148
+ });
149
+
150
+ test("rejects a submission with no recording at the call site rather than in a step", async () => {
151
+ // A 400 on the POST, with the run never created, instead of a failed run
152
+ // discovered a minute later.
153
+ const result = await transcribe.input?.["~standard"].validate({});
154
+ expect(result?.issues).toBeDefined();
155
+ });
156
+
157
+ test("declares the recording as an upload, which is what makes the form take a file", () => {
158
+ // Without this the page renders a text box asking for an id no person has —
159
+ // the property is a plain string in the schema, deliberately, because an
160
+ // upload id is what the run receives.
161
+ expect(transcribe.uploads).toEqual(["recording"]);
162
+ });
163
+
164
+ test("describes the recording, which is what labels it on the page", async () => {
165
+ // `<WorkflowFields>` renders a control per scalar property and uses each
166
+ // `.describe()` as its hint, so a missing description is a bare field.
167
+ // Narrowed rather than cast: `input` is a Standard Schema, and only a
168
+ // `ZodObject` has the `shape` this reads.
169
+ const schema = transcribe.input;
170
+ if (!(schema instanceof z.ZodObject)) expect.fail("expected a zod object schema");
171
+ expect(schema.shape.recording?.description).toBeTruthy();
172
+ });
173
+ });
174
+
175
+ describe("parseWav", () => {
176
+ test("reads the format and where the samples start", () => {
177
+ const head = wavFile(MONO_16K, 320_000);
178
+ expect(parseWav(head, 44 + 320_000)).toEqual({
179
+ ...MONO_16K,
180
+ dataStart: 44,
181
+ dataEnd: 44 + 320_000,
182
+ });
183
+ });
184
+
185
+ test("walks past a chunk in front of the samples, padding included", () => {
186
+ // A `LIST` of odd length: the padding byte is not counted by the chunk's
187
+ // own length field, which is the off-by-one that lands `dataStart` inside
188
+ // the audio and makes every segment one byte out of frame.
189
+ const head = wavFile(MONO_16K, 320_000, { extraChunk: "INFOxyz" });
190
+ expect(parseWav(head, head.length + 320_000).dataStart).toBe(head.length);
191
+ });
192
+
193
+ test("caps a declared length at what was actually served", () => {
194
+ // A truncated download declares more than it holds; reading past the end
195
+ // would make the last segment a range the server answers 416 for.
196
+ const head = wavFile(MONO_16K, 320_000);
197
+ expect(parseWav(head, 44 + 100_000).dataEnd).toBe(44 + 100_000);
198
+ });
199
+
200
+ test("treats an unknown declared length as 'to the end of the file'", () => {
201
+ // What a streaming encoder writes — the length was not known when the
202
+ // header went out.
203
+ const head = wavFile(MONO_16K, 320_000, { declaredDataSize: 0xff_ff_ff_ff });
204
+ expect(parseWav(head, 44 + 320_000).dataEnd).toBe(44 + 320_000);
205
+ });
206
+
207
+ test("refuses a file that is not a WAV, naming the fix", () => {
208
+ const notWav = new Uint8Array(64).fill(0x66);
209
+ expect(() => parseWav(notWav, 64)).toThrow(UnsupportedRecordingError);
210
+ expect(() => parseWav(notWav, 64)).toThrow(/ffmpeg/);
211
+ });
212
+
213
+ test("refuses a WAV that is not linear PCM", () => {
214
+ // Cutting a compressed payload by arithmetic produces noise, and noise
215
+ // transcribes into confident nonsense rather than failing.
216
+ const head = wavFile(MONO_16K, 320_000);
217
+ new DataView(head.buffer).setUint16(20, 0xff_fe, true);
218
+ expect(() => parseWav(head, 44 + 320_000)).toThrow(/linear PCM/);
219
+ });
220
+
221
+ // The two rates below are why the guard is in `parseWav` and not in
222
+ // `planSegments`: both make that loop spin on pure CPU with no `await` in it,
223
+ // so neither `AbortSignal.timeout` nor a step's retry budget can interrupt
224
+ // one — and this is the workflow app that takes an arbitrary uploaded file
225
+ // over a public form.
226
+
227
+ test("refuses a WAV declaring a sample rate of 0, which would hang the cut", () => {
228
+ // `bytesPerSecond` is 0, so `stride` is 0, so `start += stride` never
229
+ // advances and the loop pushes a Segment per iteration until it runs out of
230
+ // memory.
231
+ const head = wavFile({ ...MONO_16K, sampleRate: 0 }, 320_000);
232
+ expect(() => parseWav(head, 44 + 320_000)).toThrow(UnsupportedRecordingError);
233
+ expect(() => parseWav(head, 44 + 320_000)).toThrow(/sample rate of 0/);
234
+ });
235
+
236
+ test("refuses a rate so high the overlap alone exceeds the request cap", () => {
237
+ // The same hang from the other end: the overlap is subtracted from
238
+ // MAX_SEGMENT_BYTES, so past MAX_BYTES_PER_SECOND the stride goes NEGATIVE
239
+ // and the loop walks backwards. `sampleRate` is a uint32, so a header can
240
+ // ask for this.
241
+ const perSecond = MAX_BYTES_PER_SECOND + blockAlign(MONO_16K);
242
+ const head = wavFile(
243
+ { ...MONO_16K, sampleRate: Math.ceil(perSecond / blockAlign(MONO_16K)) },
244
+ 320_000,
245
+ );
246
+ expect(() => parseWav(head, 44 + 320_000)).toThrow(/bytes a second/);
247
+ });
248
+
249
+ test("48 kHz 24-bit stereo — the realistic ceiling — is nowhere near the bound", () => {
250
+ // The guard has to refuse the pathological headers without refusing any
251
+ // recording a person would actually upload.
252
+ const studio = { sampleRate: 48_000, channels: 2, bitsPerSample: 24 };
253
+ expect(bytesPerSecond(studio)).toBeLessThan(MAX_BYTES_PER_SECOND);
254
+ const head = wavFile(studio, 4_000_000);
255
+ expect(parseWav(head, 44 + 4_000_000).sampleRate).toBe(48_000);
256
+ });
257
+ });
258
+
259
+ describe("planSegments decides the fan-out's width", () => {
260
+ /** A format covering `seconds` of 16 kHz mono audio. */
261
+ function format(seconds: number): WavFormat {
262
+ const bytes = seconds * MONO_16K.sampleRate * blockAlign(MONO_16K);
263
+ return { ...MONO_16K, dataStart: 44, dataEnd: 44 + bytes };
264
+ }
265
+
266
+ test("covers the whole recording", () => {
267
+ const segments = planSegments(format(600));
268
+ expect(segments[0]?.start).toBe(44);
269
+ expect(segments.at(-1)?.end).toBe(format(600).dataEnd);
270
+ });
271
+
272
+ test("keeps every segment inside the endpoint's limit", () => {
273
+ // The cap the whole template exists to work around — one segment over it is
274
+ // a 413 rather than a shorter transcript.
275
+ for (const segment of planSegments(format(3600))) {
276
+ expect(segment.endMs - segment.startMs).toBeLessThanOrEqual(MAX_SEGMENT_SECONDS * 1000);
277
+ }
278
+ });
279
+
280
+ test("overlaps each segment with the one before it", () => {
281
+ // The overlap is what stops a cut mid-word being heard as half a word by
282
+ // both sides; `stitchTranscript` removes the duplicate.
283
+ const segments = planSegments(format(600));
284
+ expect(segments.length).toBeGreaterThan(1);
285
+ for (const [at, segment] of segments.entries()) {
286
+ if (at === 0) continue;
287
+ expect(segment.startMs).toBeLessThan(segments[at - 1]?.endMs ?? 0);
288
+ }
289
+ });
290
+
291
+ test("cuts only on frame boundaries", () => {
292
+ // A cut mid-sample shifts every following byte into the wrong channel and
293
+ // the wrong half of a 16-bit word — audible as noise, never as an error.
294
+ const stereo = { sampleRate: 44_100, channels: 2, bitsPerSample: 16 };
295
+ const frame = blockAlign(stereo);
296
+ const segments = planSegments({
297
+ ...stereo,
298
+ dataStart: 44,
299
+ dataEnd: 44 + 600 * stereo.sampleRate * frame,
300
+ });
301
+ for (const segment of segments) {
302
+ expect((segment.start - 44) % frame).toBe(0);
303
+ expect((segment.end - 44) % frame).toBe(0);
304
+ }
305
+ });
306
+
307
+ test("keeps every segment inside the endpoint's byte cap too", () => {
308
+ // The cap that binds on high-rate audio rather than long audio: 96 kHz
309
+ // stereo 24-bit reaches 40 MB in ~73 seconds, well inside the 120-second
310
+ // one. The overlap counts toward it, which is why the stride subtracts it.
311
+ const hiFi = { sampleRate: 96_000, channels: 2, bitsPerSample: 24 };
312
+ const perSecond = hiFi.sampleRate * blockAlign(hiFi);
313
+ const segments = planSegments({
314
+ ...hiFi,
315
+ dataStart: 44,
316
+ dataEnd: 44 + 600 * perSecond,
317
+ });
318
+ expect(segments.length).toBeGreaterThan(1);
319
+ for (const segment of segments) {
320
+ expect(segment.end - segment.start).toBeLessThanOrEqual(MAX_SEGMENT_BYTES);
321
+ }
322
+ });
323
+
324
+ test("emits one segment for a recording shorter than the stride", () => {
325
+ expect(planSegments(format(SEGMENT_SECONDS - 1))).toHaveLength(1);
326
+ });
327
+
328
+ test("emits no trailing empty segment when the audio divides evenly", () => {
329
+ // The case a loop with the wrong bound fans one extra step out over
330
+ // nothing, which the endpoint answers 400 for.
331
+ const segments = planSegments(format(SEGMENT_SECONDS * 3));
332
+ expect(segments.at(-1)?.end).toBeGreaterThan(segments.at(-1)?.start ?? 0);
333
+ expect(segments).toHaveLength(3);
334
+ });
335
+
336
+ test("refuses a recording shorter than the endpoint's floor", () => {
337
+ expect(() => planSegments(format(0.01))).toThrow(UnsupportedRecordingError);
338
+ });
339
+ });
340
+
341
+ describe("wavWithHeader", () => {
342
+ test("writes a header the endpoint can read the rate back out of", () => {
343
+ const samples = new Uint8Array(3200).fill(7);
344
+ const out = wavWithHeader({ ...MONO_16K, dataStart: 44, dataEnd: 3244 }, samples);
345
+ const view = new DataView(out.buffer);
346
+
347
+ expect(String.fromCharCode(...out.subarray(0, 4))).toBe("RIFF");
348
+ expect(String.fromCharCode(...out.subarray(8, 12))).toBe("WAVE");
349
+ expect(view.getUint32(24, true)).toBe(MONO_16K.sampleRate);
350
+ expect(view.getUint16(22, true)).toBe(MONO_16K.channels);
351
+ // The two lengths, which are what a decoder trusts: RIFF counts everything
352
+ // after itself, `data` counts only the samples.
353
+ expect(view.getUint32(4, true)).toBe(36 + samples.length);
354
+ expect(view.getUint32(40, true)).toBe(samples.length);
355
+ expect(out.subarray(44)).toEqual(samples);
356
+ });
357
+ });
358
+
359
+ describe("stitchTranscript", () => {
360
+ test("removes the words the overlap made duplicates", () => {
361
+ expect(
362
+ stitchTranscript(["we should ship it on Friday", "ship it on Friday if the tests pass"]),
363
+ ).toBe("we should ship it on Friday if the tests pass");
364
+ });
365
+
366
+ test("matches a seam the two passes punctuated differently", () => {
367
+ // The common case, not an edge one: one segment ends a sentence where the
368
+ // other is mid-clause, so a raw compare finds no seam at all.
369
+ expect(stitchTranscript(["that is all for today.", "Today we ship."])).toBe(
370
+ "that is all for today. we ship.",
371
+ );
372
+ });
373
+
374
+ test("keeps both sides when there is no seam", () => {
375
+ expect(stitchTranscript(["alpha beta", "gamma delta"])).toBe("alpha beta gamma delta");
376
+ });
377
+
378
+ test("prefers the longest seam over an accidental short one", () => {
379
+ // A repeated "the" is not evidence of anything; taking it would delete
380
+ // speech, which is the one failure worse than a repeated phrase.
381
+ expect(stitchTranscript(["the plan is the same", "the same next week"])).toBe(
382
+ "the plan is the same next week",
383
+ );
384
+ });
385
+
386
+ test("skips a segment that transcribed to nothing", () => {
387
+ // A segment of silence, which a long recording legitimately contains.
388
+ expect(stitchTranscript(["alpha", " ", "beta"])).toBe("alpha beta");
389
+ });
390
+ });
391
+
392
+ describe("clock", () => {
393
+ test("renders a position a reader can find in the recording", () => {
394
+ expect(clock(0)).toBe("0:00");
395
+ expect(clock(65_000)).toBe("1:05");
396
+ });
397
+ });
398
+
399
+ describe("splitRecording", () => {
400
+ test("plans the segments and reports the duration", async () => {
401
+ const seconds = 200;
402
+ const bytes = seconds * MONO_16K.sampleRate * blockAlign(MONO_16K);
403
+ publishRecording(concat(wavFile(MONO_16K, bytes), new Uint8Array(bytes)));
404
+
405
+ const plan = await splitRecording(UPLOAD_ID);
406
+ expect(plan.format.sampleRate).toBe(MONO_16K.sampleRate);
407
+ expect(plan.segments.length).toBeGreaterThan(1);
408
+ expect(plan.durationMs).toBe(seconds * 1000);
409
+ });
410
+
411
+ test("reads a recording shorter than the header probe", async () => {
412
+ // The window is CLAMPED to the file rather than refused, which is what lets
413
+ // a step ask for 64 KB of a 12 KB recording without knowing its size first.
414
+ const bytes = 8000;
415
+ publishRecording(concat(wavFile(MONO_16K, bytes), new Uint8Array(bytes)));
416
+ const plan = await splitRecording(UPLOAD_ID);
417
+ expect(plan.format.dataStart).toBe(44);
418
+ });
419
+
420
+ test("fails FATALLY on an id that names no upload", async () => {
421
+ publishRecording(new Uint8Array(0));
422
+ // A missing upload does not appear on the fourth attempt.
423
+ await expect(splitRecording("upl_gone")).rejects.toThrow(/No upload with id/);
424
+ });
425
+
426
+ test("fails FATALLY on a recording it cannot cut", async () => {
427
+ // Compressed audio has no frame boundary an offset can find; the run says
428
+ // so by name instead of transcribing nonsense.
429
+ publishRecording(new Uint8Array([0x49, 0x44, 0x33, 0x04, 0, 0, 0, 0, 0, 0]));
430
+ await expect(splitRecording(UPLOAD_ID)).rejects.toThrow();
431
+ });
432
+ });
433
+
434
+ describe("transcribeSegment", () => {
435
+ const FORMAT: WavFormat = { ...MONO_16K, dataStart: 44, dataEnd: 44 + 320_000 };
436
+ const SEGMENT = { index: 0, start: 44, end: 44 + 32_000, startMs: 0, endMs: 1000 };
437
+
438
+ beforeEach(() => {
439
+ // `stepEnv` falls back to the process env when no host has published one,
440
+ // which is exactly the case a spec is: there is no agent env in this
441
+ // process. `unstubEnvs` clears it before the next test.
442
+ vi.stubEnv("ASSEMBLYAI_API_KEY", "sk-test");
443
+ });
444
+
445
+ /**
446
+ * Publishes the recording and answers the sync endpoint.
447
+ *
448
+ * `stubStepFetch`, not `vi.stubGlobal("fetch", …)`: the step calls `stepFetch`,
449
+ * which reaches a published slot rather than the global — see
450
+ * `sdk/step-fetch.ts` for why it has to (HTTP/1.1, so a batch of segments gets
451
+ * a socket each instead of N streams on one connection). Stubbing the global
452
+ * still passes, because an unpublished slot falls back to it, and would be
453
+ * asserting against a path production does not take.
454
+ */
455
+ function stubProvider(
456
+ sync: { status?: number; body?: unknown; headers?: Record<string, string> } = {},
457
+ ) {
458
+ publishRecording(new Uint8Array(FORMAT.dataEnd));
459
+ const stub = stubStepFetch(() => ({
460
+ status: sync.status ?? 200,
461
+ body: sync.body ?? { text: "hello there" },
462
+ ...(sync.headers && { headers: sync.headers }),
463
+ }));
464
+ stubs.push(stub.restore);
465
+ return stub.calls;
466
+ }
467
+
468
+ /** Unpublished per test — a live one answers the next file's steps. */
469
+ const stubs: (() => void)[] = [];
470
+ afterEach(() => {
471
+ for (const restore of stubs.splice(0)) restore();
472
+ });
473
+
474
+ test("sends the segment as a WAV, with the key and the model header", async () => {
475
+ const calls = stubProvider();
476
+ const result = await transcribeSegment(UPLOAD_ID, FORMAT, SEGMENT);
477
+
478
+ expect(result).toEqual({ index: 0, text: "hello there" });
479
+ const sync = calls.find((call) => call.url.startsWith(SYNC_ORIGIN));
480
+ // The raw key: this endpoint takes it unprefixed, and `Bearer ` in front of
481
+ // it is a 401 that reads like a wrong key.
482
+ expect(sync?.headers.Authorization).toBe("sk-test");
483
+ expect(sync?.headers["X-AAI-Model"]).toBe("universal-3-5-pro");
484
+ // BYTES, and multipart — `stepFetch` takes no `FormData`, which is the
485
+ // point: a branded object handed to a fetch from another undici realm goes
486
+ // out as the string `[object FormData]`. `multipartBody` builds the envelope.
487
+ expect(sync?.headers["Content-Type"]).toMatch(/^multipart\/form-data; boundary=/);
488
+ const body = sync?.body;
489
+ expect(body).toBeInstanceOf(Uint8Array);
490
+ const decoded = new TextDecoder().decode(body as Uint8Array);
491
+ expect(decoded).toContain('name="audio"; filename="segment-0.wav"');
492
+ // The WAV really rides in the part, header and all.
493
+ expect(decoded).toContain("RIFF");
494
+ });
495
+
496
+ test("fails FATALLY with no API key rather than retrying five times", async () => {
497
+ vi.stubEnv("ASSEMBLYAI_API_KEY", "");
498
+ stubProvider();
499
+ await expect(transcribeSegment(UPLOAD_ID, FORMAT, SEGMENT)).rejects.toThrow(
500
+ /ASSEMBLYAI_API_KEY/,
501
+ );
502
+ });
503
+
504
+ test("retries a rate limit, honouring the delay the endpoint asked for", async () => {
505
+ // `RetryableError` carrying `retryAfter` is the difference between draining
506
+ // the 429s and re-collecting them `SEGMENT_CONCURRENCY` at a time on a
507
+ // backoff the server did not choose.
508
+ stubProvider({ status: 429, body: { detail: "slow down" }, headers: { "Retry-After": "30" } });
509
+ const failure = await transcribeSegment(UPLOAD_ID, FORMAT, SEGMENT).catch(
510
+ (err: unknown) => err,
511
+ );
512
+ expect(failure).toBeInstanceOf(RetryableError);
513
+ expect(String(failure)).toMatch(/HTTP 429 — slow down/);
514
+ const at = (failure as RetryableError).retryAfter.getTime() - Date.now();
515
+ expect(at).toBeGreaterThan(25_000);
516
+ expect(at).toBeLessThanOrEqual(30_000);
517
+ });
518
+
519
+ test("retries a rate limit that named no delay", async () => {
520
+ stubProvider({ status: 429, body: { detail: "slow down" } });
521
+ await expect(transcribeSegment(UPLOAD_ID, FORMAT, SEGMENT)).rejects.toBeInstanceOf(
522
+ RetryableError,
523
+ );
524
+ });
525
+
526
+ test("fails FATALLY on a rejected request, naming what the endpoint said", async () => {
527
+ stubProvider({ status: 400, body: { error_code: "audio_too_short", message: "too short" } });
528
+ await expect(transcribeSegment(UPLOAD_ID, FORMAT, SEGMENT)).rejects.toThrow(
529
+ /HTTP 400 — too short/,
530
+ );
531
+ });
532
+
533
+ test("retries beyond the default, because a rate limit is expected", () => {
534
+ expect(transcribeSegment.maxRetries).toBeGreaterThan(3);
535
+ });
536
+ });
537
+
538
+ describe("mergeTranscript", () => {
539
+ test("stitches the segments in index order, whatever order they arrive in", async () => {
540
+ publishRecording(new Uint8Array(1));
541
+ const merged = await mergeTranscript(UPLOAD_ID, 12_000, [
542
+ { index: 1, text: "on Friday if the tests pass" },
543
+ { index: 0, text: "we ship on Friday" },
544
+ ]);
545
+ expect(merged.transcript).toBe("we ship on Friday if the tests pass");
546
+ expect(merged.words).toBe(8);
547
+ expect(merged).toMatchObject({ segments: 2, durationMs: 12_000 });
548
+ });
549
+
550
+ test("names the FILE it transcribed, not the id the run carried", async () => {
551
+ publishRecording(new Uint8Array(1), "standup.wav");
552
+ const merged = await mergeTranscript(UPLOAD_ID, 1000, [{ index: 0, text: "hi" }]);
553
+ expect(merged.source).toBe("standup.wav");
554
+ });
555
+ });
556
+
557
+ /** Two byte arrays end to end. */
558
+ function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
559
+ const out = new Uint8Array(a.length + b.length);
560
+ out.set(a);
561
+ out.set(b, a.length);
562
+ return out;
563
+ }