@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
@@ -0,0 +1,717 @@
1
+ // Copyright 2026 the AAI authors. MIT license.
2
+ /**
3
+ * What this template promises, checked without a network.
4
+ *
5
+ * Three kinds of thing are checked, and the workflow BODY is none of them: it is
6
+ * only durable once the Workflow DevKit's build has transformed it, so a unit
7
+ * test of it would exercise a plain async function and prove nothing about
8
+ * replay. What IS here:
9
+ *
10
+ * - The DECLARATION — the config a deploy validates and the schema a `start()`
11
+ * is checked against.
12
+ * - The PURE helpers, pulled out of the flow for exactly this reason.
13
+ * - The STEPS, directly. Imported with no bundler in the path a `"use step"`
14
+ * function is an ordinary async function, so its HTTP handling, its
15
+ * partial-failure policy and its `FatalError` guards are all reachable —
16
+ * `installStubStepFetch` answers the network and `stubGateway` answers the model.
17
+ *
18
+ * The cases worth having are the ones where a mistake is SILENT: a schema that
19
+ * accepts a webhook pointing anywhere, a Slack payload in the shape the other
20
+ * kind of URL wants, feed parsing that quietly drops episodes, and an episode
21
+ * that fails to transcribe taking the whole digest down with it.
22
+ */
23
+
24
+ import { parseSchemaInput, schemaInputIssues } from "@alexkroman1/aai/testing";
25
+ import {
26
+ installStubStepFetch,
27
+ installStubGateway as stubGateway,
28
+ } from "@alexkroman1/aai/testing/vitest";
29
+ import { beforeEach, describe, expect, test, vi } from "vitest";
30
+ import agentDef, { dailyDigest } from "./agent.ts";
31
+ import {
32
+ formatScheduleInterval,
33
+ pollTranscript,
34
+ scheduleIntervalMs,
35
+ submitTranscript,
36
+ summarizeTranscript,
37
+ timestamp,
38
+ } from "./workflows/digest.ts";
39
+ import {
40
+ discoverEpisodes,
41
+ discoverFeedUrl,
42
+ episodeFromItem,
43
+ extractAppleSerializedFeed,
44
+ isApplePodcastUrl,
45
+ isSpotifyShowUrl,
46
+ looksLikePodcastFeed,
47
+ parsePodcastChannels,
48
+ stableEpisodeId,
49
+ titleMatchesSpotify,
50
+ } from "./workflows/feeds.ts";
51
+ import { renderDigestMessage, sendDigestToSlack } from "./workflows/slack.ts";
52
+
53
+ /**
54
+ * Validate through the SDK's reader, as `start()` does.
55
+ *
56
+ * `schemaInputIssues` / `parseSchemaInput` rather than a reach through
57
+ * `["~standard"].validate`: that is the vendor WIRE contract, and whether it
58
+ * answers synchronously or with a promise is the vendor's business — a missing
59
+ * `await` there leaves `.issues` undefined and every rejecting case below passes
60
+ * for the wrong reason.
61
+ */
62
+ const issues = (input: unknown) => schemaInputIssues(dailyDigest.input, input, "dailyDigest");
63
+
64
+ /** A complete, valid input — each case below changes one field of it. */
65
+ const VALID = {
66
+ podcastChannels: "https://example.com/feed.xml",
67
+ slackWebhookUrl: "https://hooks.slack.com/services/T000/B000/abc123",
68
+ };
69
+
70
+ const EPISODE = {
71
+ id: "episode-1",
72
+ feedUrl: "https://example.com/feed.xml",
73
+ podcastTitle: "Example Podcast",
74
+ title: "Example Episode",
75
+ url: "https://example.com/episode",
76
+ audioUrl: "https://example.com/audio.mp3",
77
+ published: "Fri, 21 Aug 2026 00:00:00 GMT",
78
+ transcriptSource: "assemblyai" as const,
79
+ summary: "A concise episode summary.",
80
+ keyPoints: ["First point", "Second point"],
81
+ };
82
+
83
+ const slackInput = (slackWebhookUrl: string) => ({
84
+ slackWebhookUrl,
85
+ slackWorkflowTextParam: "text",
86
+ podcastChannels: "https://example.com/feed.xml",
87
+ episodes: [EPISODE],
88
+ digestNumber: 1,
89
+ totalDigests: 2,
90
+ });
91
+
92
+ describe("the declaration", () => {
93
+ test("is a workflow app with one workflow and a static page", () => {
94
+ expect(agentDef.name).toBe("Podcast Digest");
95
+ expect(agentDef.page).toBe("static");
96
+ expect(Object.keys(agentDef.workflows ?? {})).toEqual(["dailyDigest"]);
97
+ expect(agentDef.workflows?.dailyDigest).toBe(dailyDigest);
98
+ });
99
+
100
+ test("names the one credential its steps read", () => {
101
+ // A workflow app declares no providers, so nothing else in the config can
102
+ // name this — which is what makes a deploy able to check for it.
103
+ expect(agentDef.requiredEnv).toEqual(["ASSEMBLYAI_API_KEY"]);
104
+ });
105
+ });
106
+
107
+ describe("input schema", () => {
108
+ test("accepts a minimal input and applies every default", async () => {
109
+ // `parseSchemaInput` throws naming every issue, so the failure arm needs no
110
+ // hand-written narrowing and a refusal reports WHAT was refused.
111
+ //
112
+ // These defaults are no longer restated anywhere: `DigestInput` is
113
+ // `WorkflowInputOf<typeof dailyDigest>`, the schema's OUTPUT, so the body
114
+ // reads them already applied instead of re-implementing each one with `??`.
115
+ const parsed = await parseSchemaInput(dailyDigest.input, VALID, "dailyDigest");
116
+ expect(parsed).toMatchObject({
117
+ slackWorkflowTextParam: "text",
118
+ maxEpisodesPerDigest: 5,
119
+ intervalEvery: 1,
120
+ intervalUnit: "days",
121
+ daysToRun: 7,
122
+ });
123
+ });
124
+
125
+ test("trims the webhook before validating it", async () => {
126
+ expect(
127
+ await issues({
128
+ ...VALID,
129
+ slackWebhookUrl: " https://hooks.slack.com/services/T000/B000/abc123 ",
130
+ }),
131
+ ).toBeUndefined();
132
+ });
133
+
134
+ test.each([
135
+ ["a workflow trigger", "https://hooks.slack.com/triggers/T000/B000/abc"],
136
+ ["the gov host", "https://hooks.slack-gov.com/services/T000/B000/abc"],
137
+ ])("accepts %s", async (_label, slackWebhookUrl) => {
138
+ expect(await issues({ ...VALID, slackWebhookUrl })).toBeUndefined();
139
+ });
140
+
141
+ /**
142
+ * The one that matters. This value becomes the target of a POST carrying
143
+ * summarized content, so a schema accepting any URL is an exfiltration
144
+ * endpoint somebody can type into a form.
145
+ */
146
+ test.each([
147
+ ["a non-Slack host", "https://example.com/services/T000/B000/abc"],
148
+ ["a lookalike host", "https://hooks.slack.com.evil.test/services/abc"],
149
+ ["plain http", "http://hooks.slack.com/services/T000/B000/abc"],
150
+ ["no path", "https://hooks.slack.com"],
151
+ ])("rejects %s as a webhook", async (_label, slackWebhookUrl) => {
152
+ expect(await issues({ ...VALID, slackWebhookUrl })).toBeDefined();
153
+ });
154
+
155
+ test.each([
156
+ ["a non-URL channel", { podcastChannels: "not a url" }],
157
+ ["one bad entry among good ones", { podcastChannels: "https://a.test/f.xml, nope" }],
158
+ ["an empty channel list", { podcastChannels: "" }],
159
+ ["a variable name that is not an identifier", { slackWorkflowTextParam: "2 text!" }],
160
+ ["more episodes than allowed", { maxEpisodesPerDigest: 21 }],
161
+ ["a fractional episode count", { maxEpisodesPerDigest: 1.5 }],
162
+ ["more digests than allowed", { daysToRun: 31 }],
163
+ ["an unknown interval unit", { intervalUnit: "fortnights" }],
164
+ ])("rejects %s", async (_label, patch) => {
165
+ expect(await issues({ ...VALID, ...patch })).toBeDefined();
166
+ });
167
+ });
168
+
169
+ describe("podcast links", () => {
170
+ test("splits, trims, and keeps the first of any duplicate", () => {
171
+ expect(
172
+ parsePodcastChannels(" https://one.test/rss,https://two.test/feed, https://one.test/rss "),
173
+ ).toEqual(["https://one.test/rss", "https://two.test/feed"]);
174
+ });
175
+
176
+ test.each([
177
+ ["https://podcasts.apple.com/us/podcast/example/id1473872585", true],
178
+ ["https://podcasts.apple.com/us/podcast/example", false],
179
+ ["https://example.com/us/podcast/id123", false],
180
+ // A host that merely ENDS with the domain is a different host.
181
+ ["https://evilpodcasts.apple.com/us/podcast/id123", false],
182
+ ["not a url", false],
183
+ ])("isApplePodcastUrl(%s) === %s", (url, expected) => {
184
+ expect(isApplePodcastUrl(url)).toBe(expected);
185
+ });
186
+
187
+ /** The replacement for the `spotify-uri` dependency — see `feeds.ts`. */
188
+ test.each([
189
+ ["https://open.spotify.com/show/4rOoJ6Egrf8K2IrywzwOMk", true],
190
+ ["https://open.spotify.com/show/4rOoJ6Egrf8K2IrywzwOMk?si=abc", true],
191
+ ["https://open.spotify.com/episode/4rOoJ6Egrf8K2IrywzwOMk", false],
192
+ ["https://open.spotify.com/show/", false],
193
+ ["https://notspotify.com/show/abc", false],
194
+ ["not a url", false],
195
+ ])("isSpotifyShowUrl(%s) === %s", (url, expected) => {
196
+ expect(isSpotifyShowUrl(url)).toBe(expected);
197
+ });
198
+ });
199
+
200
+ describe("reading a feed", () => {
201
+ const ITEM =
202
+ "<item><title>An Episode</title><link>https://example.com/ep</link>" +
203
+ '<guid isPermaLink="false">guid-1</guid><pubDate>Fri, 21 Aug 2026 00:00:00 GMT</pubDate>' +
204
+ '<enclosure url="https://example.com/a.mp3" type="audio/mpeg"/></item>';
205
+
206
+ test("reads the fields a digest needs off an item", () => {
207
+ expect(episodeFromItem(ITEM, "https://example.com/feed.xml", "Show", 0)).toMatchObject({
208
+ title: "An Episode",
209
+ url: "https://example.com/ep",
210
+ audioUrl: "https://example.com/a.mp3",
211
+ published: "Fri, 21 Aug 2026 00:00:00 GMT",
212
+ });
213
+ });
214
+
215
+ /** A feed legitimately mixes text posts in; there is nothing to transcribe. */
216
+ test("drops an item with no audio rather than inventing a URL", () => {
217
+ const noAudio = "<item><title>A Post</title><link>https://example.com/p</link></item>";
218
+ expect(episodeFromItem(noAudio, "https://example.com/feed.xml", "Show", 0)).toBeUndefined();
219
+ });
220
+
221
+ test("gives the same episode the same id on every run", () => {
222
+ // Replay depends on this: a fresh id per run would remount every card.
223
+ const first = episodeFromItem(ITEM, "https://example.com/feed.xml", "Show", 0);
224
+ const again = episodeFromItem(ITEM, "https://example.com/feed.xml", "Show", 0);
225
+ expect(first?.id).toBe(again?.id);
226
+ expect(stableEpisodeId("a", "b")).not.toBe(stableEpisodeId("a", "c"));
227
+ });
228
+
229
+ test("requires BOTH an rss root and an enclosure to call it a podcast feed", () => {
230
+ expect(looksLikePodcastFeed('<rss><enclosure url="https://a.test/a.mp3"/></rss>')).toBe(true);
231
+ // A blog feed: RSS, but nothing to transcribe.
232
+ expect(looksLikePodcastFeed("<rss><item><title>Post</title></item></rss>")).toBe(false);
233
+ expect(looksLikePodcastFeed("<html></html>")).toBe(false);
234
+ });
235
+
236
+ test("finds an advertised feed in either attribute order, and resolves it", () => {
237
+ const hrefFirst = '<link href="/feed.xml" type="application/rss+xml">';
238
+ const typeFirst = '<link type="application/rss+xml" href="/feed.xml">';
239
+ for (const html of [hrefFirst, typeFirst]) {
240
+ expect(discoverFeedUrl(html, "https://example.com/show")).toBe(
241
+ "https://example.com/feed.xml",
242
+ );
243
+ }
244
+ expect(discoverFeedUrl("<html></html>", "https://example.com/show")).toBeUndefined();
245
+ });
246
+
247
+ test("extracts an Apple feed URL, and only for the show that was asked for", () => {
248
+ const html =
249
+ '<script id="serialized-server-data">' +
250
+ '{"data":[{"model":{"adamId":"999","feedUrl":"https://other.test/feed"}},' +
251
+ '{"model":{"adamId":"1473872585","title":"Apple News Today","feedUrl":"https://apple.test/feed"}}]}' +
252
+ "</script>";
253
+ expect(extractAppleSerializedFeed(html, "1473872585")).toEqual({
254
+ feedUrl: "https://apple.test/feed",
255
+ title: "Apple News Today",
256
+ });
257
+ expect(extractAppleSerializedFeed(html, "0000")).toBeUndefined();
258
+ });
259
+
260
+ test("survives a page with no serialized blob, or an unparseable one", () => {
261
+ expect(extractAppleSerializedFeed("<html></html>", "1")).toBeUndefined();
262
+ expect(
263
+ extractAppleSerializedFeed('<script id="serialized-server-data">{not json</script>', "1"),
264
+ ).toBeUndefined();
265
+ });
266
+
267
+ test("refuses a Spotify match that is merely a near neighbour", () => {
268
+ expect(titleMatchesSpotify("The Daily", "The New York Times", { title: "The Daily" })).toBe(
269
+ true,
270
+ );
271
+ expect(
272
+ titleMatchesSpotify("The Daily", "The New York Times", {
273
+ title: "The Daily",
274
+ artist: "The New York Times",
275
+ }),
276
+ ).toBe(true);
277
+ // A different show whose title merely resembles it.
278
+ expect(
279
+ titleMatchesSpotify("The Daily", "The New York Times", { title: "Daily Tech News" }),
280
+ ).toBe(false);
281
+ expect(titleMatchesSpotify("The Daily", "", {})).toBe(false);
282
+ });
283
+ });
284
+
285
+ describe("the digest as a channel message", () => {
286
+ /**
287
+ * What is left to test here after the channel concept landed: the MESSAGE,
288
+ * not the payload. Slack's two webhook shapes, the Block Kit assembly, the
289
+ * mrkdwn escaping and the advice each refusal deserves are
290
+ * `@alexkroman1/aai/channels`' and are covered by its own specs — a template
291
+ * asserting them again would pin the SDK's rendering from the outside, which
292
+ * is exactly the duplication moving them was for.
293
+ */
294
+ test("carries every episode as its own section, linked and attributed", () => {
295
+ const message = renderDigestMessage(slackInput("https://hooks.slack.com/services/T/B/a"));
296
+
297
+ expect(message.heading).toBe("Podcast digest 1/2");
298
+ expect(message.subtitle).toContain("Feeds:");
299
+ expect(message.sections).toHaveLength(1);
300
+ expect(message.sections?.[0]).toMatchObject({
301
+ title: "Example Episode",
302
+ subtitle: "Example Podcast",
303
+ body: "A concise episode summary.",
304
+ bullets: ["First point", "Second point"],
305
+ });
306
+ expect(message.sections?.[0]?.url).toContain("http");
307
+ });
308
+
309
+ /**
310
+ * `text` is the notification line, and on a Slack workflow trigger it is the
311
+ * WHOLE message — so it has to stand on its own rather than repeat the
312
+ * heading a trigger will never render.
313
+ */
314
+ test("says how much arrived in the notification line", () => {
315
+ const message = renderDigestMessage(slackInput("https://hooks.slack.com/triggers/T/B/a"));
316
+
317
+ expect(message.text).toBe("Podcast digest 1/2: 1 episode summaries");
318
+ });
319
+ });
320
+
321
+ describe("the repeat schedule", () => {
322
+ test("prints an interval a person can read, singular at one", () => {
323
+ expect(formatScheduleInterval(1, "hours")).toBe("1 hour");
324
+ expect(formatScheduleInterval(15, "minutes")).toBe("15 minutes");
325
+ expect(formatScheduleInterval(2, "days")).toBe("2 days");
326
+ });
327
+
328
+ test("converts an interval to the milliseconds a durable sleep takes", () => {
329
+ expect(scheduleIntervalMs(15, "minutes")).toBe(900_000);
330
+ expect(scheduleIntervalMs(6, "hours")).toBe(21_600_000);
331
+ expect(scheduleIntervalMs(2, "days")).toBe(172_800_000);
332
+ });
333
+ });
334
+
335
+ // ---- The steps ---------------------------------------------------------------
336
+
337
+ /**
338
+ * A feed body. `looksLikePodcastFeed` wants BOTH an `<rss` root and an
339
+ * `<enclosure url=`, so these fixtures carry both unless a case is about not.
340
+ */
341
+ const feedXml = (items: string, title = "Example Show") =>
342
+ `<?xml version="1.0"?><rss><channel><title>${title}</title>${items}</channel></rss>`;
343
+
344
+ const itemXml = (title: string, pubDate: string, audio = `https://cdn.test/${title}.mp3`) =>
345
+ `<item><title>${title}</title><link>https://show.test/${title}</link>` +
346
+ `<guid>${title}</guid><pubDate>${pubDate}</pubDate>` +
347
+ `<enclosure url="${audio}" type="audio/mpeg"/></item>`;
348
+
349
+ const THREE_EPISODES = feedXml(
350
+ itemXml("oldest", "Tue, 01 Jan 2030 00:00:00 GMT") +
351
+ itemXml("newest", "Thu, 03 Jan 2030 00:00:00 GMT") +
352
+ itemXml("middle", "Wed, 02 Jan 2030 00:00:00 GMT"),
353
+ );
354
+
355
+ /**
356
+ * Route by URL, so one stub can answer a whole resolution chain.
357
+ *
358
+ * `installStubStepFetch` unpublishes on `onTestFinished`, which is why no
359
+ * describe block below keeps a `restore` of its own — a hand-kept registry is
360
+ * the thing that forgets, and a step fetch left published reaches the next file.
361
+ */
362
+ function stubRoutes(routes: Record<string, { status?: number; body?: unknown }>) {
363
+ return installStubStepFetch((request) => {
364
+ for (const [fragment, answer] of Object.entries(routes)) {
365
+ if (request.url.includes(fragment)) return answer;
366
+ }
367
+ return { status: 404, body: `no route for ${request.url}` };
368
+ });
369
+ }
370
+
371
+ describe("discoverEpisodes", () => {
372
+ const stub = stubRoutes;
373
+
374
+ test("reads a feed URL directly and returns the newest episodes first", async () => {
375
+ const stubbed = stub({ "show.test/feed.xml": { body: THREE_EPISODES } });
376
+
377
+ const episodes = await discoverEpisodes("https://show.test/feed.xml", 10);
378
+
379
+ // Once. Deciding it is a feed and reading it are the same download.
380
+ expect(stubbed.calls).toHaveLength(1);
381
+
382
+ expect(episodes.map((episode) => episode.title)).toEqual(["newest", "middle", "oldest"]);
383
+ expect(episodes[0]?.podcastTitle).toBe("Example Show");
384
+ expect(episodes[0]?.audioUrl).toBe("https://cdn.test/newest.mp3");
385
+ });
386
+
387
+ test("keeps only maxEpisodes, counted after the sort", async () => {
388
+ stub({ "show.test/feed.xml": { body: THREE_EPISODES } });
389
+
390
+ const episodes = await discoverEpisodes("https://show.test/feed.xml", 2);
391
+
392
+ // The NEWEST two — a slice taken before sorting would give feed order.
393
+ expect(episodes.map((episode) => episode.title)).toEqual(["newest", "middle"]);
394
+ });
395
+
396
+ test("merges several feeds and deduplicates the links first", async () => {
397
+ stub({
398
+ "one.test/feed.xml": { body: feedXml(itemXml("a", "Tue, 01 Jan 2030 00:00:00 GMT"), "One") },
399
+ "two.test/feed.xml": { body: feedXml(itemXml("b", "Wed, 02 Jan 2030 00:00:00 GMT"), "Two") },
400
+ });
401
+
402
+ const episodes = await discoverEpisodes(
403
+ "https://one.test/feed.xml, https://two.test/feed.xml, https://one.test/feed.xml",
404
+ 10,
405
+ );
406
+
407
+ expect(episodes.map((episode) => episode.podcastTitle)).toEqual(["Two", "One"]);
408
+ });
409
+
410
+ test("follows a page that advertises a feed, and verifies what it finds", async () => {
411
+ const stubbed = stub({
412
+ "show.test/home": { body: '<html><link type="application/rss+xml" href="/feed.xml"></html>' },
413
+ "show.test/feed.xml": { body: THREE_EPISODES },
414
+ });
415
+
416
+ expect(await discoverEpisodes("https://show.test/home", 1)).toHaveLength(1);
417
+ // Verified, not trusted — the advertised URL is fetched before it is used.
418
+ // And fetched ONCE: the verified body is carried into the reader rather
419
+ // than the identical document being downloaded again.
420
+ expect(stubbed.calls.map((call) => call.url)).toEqual([
421
+ "https://show.test/home",
422
+ "https://show.test/feed.xml",
423
+ ]);
424
+ });
425
+
426
+ test("refuses a page that advertises a feed with no audio in it", async () => {
427
+ // Plenty of pages point `application/rss+xml` at a blog feed.
428
+ stub({
429
+ "show.test/home": { body: '<html><link type="application/rss+xml" href="/blog.xml"></html>' },
430
+ "show.test/blog.xml": {
431
+ body: "<rss><channel><item><title>Post</title></item></channel></rss>",
432
+ },
433
+ });
434
+
435
+ await expect(discoverEpisodes("https://show.test/home", 1)).rejects.toThrow(
436
+ /does not look like a podcast RSS feed/,
437
+ );
438
+ });
439
+
440
+ test("says what to paste when a page advertises nothing", async () => {
441
+ stub({ "show.test/home": { body: "<html><body>hello</body></html>" } });
442
+
443
+ await expect(discoverEpisodes("https://show.test/home", 1)).rejects.toThrow(
444
+ /Could not find a podcast RSS feed/,
445
+ );
446
+ });
447
+
448
+ test("resolves an Apple Podcasts link through the lookup API", async () => {
449
+ stub({
450
+ "itunes.apple.com/lookup": {
451
+ body: {
452
+ results: [{ feedUrl: "https://show.test/feed.xml", collectionName: "Example Show" }],
453
+ },
454
+ },
455
+ "show.test/feed.xml": { body: THREE_EPISODES },
456
+ });
457
+
458
+ const episodes = await discoverEpisodes("https://podcasts.apple.com/us/podcast/x/id123", 1);
459
+
460
+ expect(episodes[0]?.podcastTitle).toBe("Example Show");
461
+ });
462
+
463
+ test("falls back to Apple's own page when the lookup omits the feed", async () => {
464
+ stub({
465
+ "itunes.apple.com/lookup": { body: { results: [{ collectionName: "Example Show" }] } },
466
+ "podcasts.apple.com": {
467
+ body:
468
+ '<script id="serialized-server-data">' +
469
+ '{"d":[{"model":{"adamId":"123","title":"Example Show","feedUrl":"https://show.test/feed.xml"}}]}' +
470
+ "</script>",
471
+ },
472
+ "show.test/feed.xml": { body: THREE_EPISODES },
473
+ });
474
+
475
+ expect(await discoverEpisodes("https://podcasts.apple.com/us/podcast/x/id123", 1)).toHaveLength(
476
+ 1,
477
+ );
478
+ });
479
+
480
+ test("gives up on an Apple show with no public feed anywhere", async () => {
481
+ stub({
482
+ "itunes.apple.com/lookup": { body: { results: [] } },
483
+ "podcasts.apple.com": { body: "<html></html>" },
484
+ });
485
+
486
+ await expect(
487
+ discoverEpisodes("https://podcasts.apple.com/us/podcast/x/id123", 1),
488
+ ).rejects.toThrow(/did not expose a public RSS feed/);
489
+ });
490
+
491
+ test("maps a Spotify show to a feed through Apple's search", async () => {
492
+ stub({
493
+ "open.spotify.com": {
494
+ body:
495
+ '<html><meta property="og:title" content="Example Show">' +
496
+ '<meta property="og:description" content="A show about examples"></html>',
497
+ },
498
+ "itunes.apple.com/search": {
499
+ body: {
500
+ results: [{ feedUrl: "https://show.test/feed.xml", collectionName: "Example Show" }],
501
+ },
502
+ },
503
+ "show.test/feed.xml": { body: THREE_EPISODES },
504
+ });
505
+
506
+ expect(await discoverEpisodes("https://open.spotify.com/show/abc", 1)).toHaveLength(1);
507
+ });
508
+
509
+ /** Silently digesting a neighbouring show is the failure nobody reports. */
510
+ test("refuses a Spotify search hit that is a different show", async () => {
511
+ stub({
512
+ "open.spotify.com": {
513
+ body: '<html><meta property="og:title" content="Example Show"></html>',
514
+ },
515
+ "itunes.apple.com/search": {
516
+ body: {
517
+ results: [{ feedUrl: "https://other.test/feed.xml", collectionName: "Something Else" }],
518
+ },
519
+ },
520
+ });
521
+
522
+ await expect(discoverEpisodes("https://open.spotify.com/show/abc", 1)).rejects.toThrow(
523
+ /Could not map the Spotify show/,
524
+ );
525
+ });
526
+
527
+ test("asks for a link rather than failing obscurely on an empty list", async () => {
528
+ stub({});
529
+ await expect(discoverEpisodes(" ", 1)).rejects.toThrow(/Add at least one podcast link/);
530
+ });
531
+
532
+ test("names the four accepted shapes when a feed carries no episode audio", async () => {
533
+ // An `<enclosure>` outside any `<item>`: it looks like a podcast feed and
534
+ // yields nothing, which is the case a bare "no episodes" would not explain.
535
+ stub({
536
+ "show.test/feed.xml": {
537
+ body:
538
+ '<rss><channel><title>T</title><enclosure url="https://cdn.test/x.mp3"/>' +
539
+ "<item><title>No audio</title></item></channel></rss>",
540
+ },
541
+ });
542
+
543
+ await expect(discoverEpisodes("https://show.test/feed.xml", 5)).rejects.toThrow(
544
+ /Apple Podcasts show link, a Spotify show link, an RSS feed URL, or a podcast homepage/,
545
+ );
546
+ });
547
+
548
+ test("propagates a feed that will not load, so the run can retry it", async () => {
549
+ stub({ "show.test/feed.xml": { status: 503, body: "busy" } });
550
+ await expect(discoverEpisodes("https://show.test/feed.xml", 1)).rejects.toThrow(/503/);
551
+ });
552
+ });
553
+
554
+ describe("transcribing one episode", () => {
555
+ beforeEach(() => vi.stubEnv("ASSEMBLYAI_API_KEY", "test-key"));
556
+
557
+ const stub = stubRoutes;
558
+
559
+ const EPISODE_IN = {
560
+ id: "episode-1",
561
+ feedUrl: "https://show.test/feed.xml",
562
+ podcastTitle: "Example Show",
563
+ title: "An Episode",
564
+ url: "https://show.test/ep",
565
+ audioUrl: "https://cdn.test/a.mp3",
566
+ published: "Thu, 03 Jan 2030 00:00:00 GMT",
567
+ };
568
+
569
+ test("submits the audio and keeps the id the rest of the run polls", async () => {
570
+ stub({ "/v2/transcript": { body: { id: "t-1" } } });
571
+
572
+ expect(await submitTranscript(EPISODE_IN)).toMatchObject({
573
+ transcriptStatus: "submitted",
574
+ transcriptId: "t-1",
575
+ });
576
+ });
577
+
578
+ /** The partial-failure policy: this-episode-is-broken degrades, it does not throw. */
579
+ test("degrades a terminal submit failure to an unavailable episode", async () => {
580
+ stub({ "/v2/transcript": { status: 400, body: { error: "audio_url is not reachable" } } });
581
+
582
+ const job = await submitTranscript(EPISODE_IN);
583
+
584
+ expect(job.transcriptStatus).toBe("unavailable");
585
+ expect(job).toMatchObject({ reason: expect.stringContaining("audio_url") });
586
+ });
587
+
588
+ /** The other half: a transport problem is the DevKit's to retry. */
589
+ test("re-throws a retryable submit failure instead of degrading it", async () => {
590
+ stub({ "/v2/transcript": { status: 503, body: "busy" } });
591
+ await expect(submitTranscript(EPISODE_IN)).rejects.toThrow();
592
+ });
593
+
594
+ test("returns the job unchanged while the provider is still working", async () => {
595
+ stub({ "/v2/transcript/t-1": { body: { status: "processing" } } });
596
+ const job = { ...EPISODE_IN, transcriptStatus: "submitted" as const, transcriptId: "t-1" };
597
+
598
+ // Unchanged, so `waitForTranscripts` can keep it in `pending` without a
599
+ // second vocabulary for "still going".
600
+ expect(await pollTranscript(job)).toBe(job);
601
+ });
602
+
603
+ test("reads the transcript once the provider is done", async () => {
604
+ stub({
605
+ "/v2/transcript/t-1": {
606
+ body: { status: "completed", text: "Otters use tools.", audio_duration: 61 },
607
+ },
608
+ });
609
+
610
+ expect(
611
+ await pollTranscript({ ...EPISODE_IN, transcriptStatus: "submitted", transcriptId: "t-1" }),
612
+ ).toMatchObject({ transcriptStatus: "done", transcript: "Otters use tools." });
613
+ });
614
+
615
+ test("degrades a job the provider gave up on", async () => {
616
+ stub({ "/v2/transcript/t-1": { body: { status: "error", error: "corrupt media" } } });
617
+
618
+ expect(
619
+ await pollTranscript({ ...EPISODE_IN, transcriptStatus: "submitted", transcriptId: "t-1" }),
620
+ ).toMatchObject({
621
+ transcriptStatus: "unavailable",
622
+ reason: expect.stringContaining("corrupt"),
623
+ });
624
+ });
625
+
626
+ test("passes an already-unavailable episode straight through without a request", async () => {
627
+ const stubbed = stub({});
628
+ const job = { ...EPISODE_IN, transcriptStatus: "unavailable" as const, reason: "no audio" };
629
+
630
+ expect(await pollTranscript(job)).toBe(job);
631
+ expect(stubbed.calls).toHaveLength(0);
632
+ });
633
+
634
+ test("summarizes a finished transcript into the digest entry", async () => {
635
+ stubGateway('{"summary":"Otters are clever.","keyPoints":["They use tools","They float"]}');
636
+
637
+ expect(
638
+ await summarizeTranscript({
639
+ ...EPISODE_IN,
640
+ transcriptStatus: "done",
641
+ transcriptId: "t-1",
642
+ transcript: "Otters use tools.",
643
+ durationMs: 61_000,
644
+ }),
645
+ ).toMatchObject({
646
+ transcriptSource: "assemblyai",
647
+ summary: "Otters are clever.",
648
+ keyPoints: ["They use tools", "They float"],
649
+ });
650
+ });
651
+
652
+ /** One bad episode must not sink a digest of five. */
653
+ test("still produces an entry for an episode that could not be transcribed", async () => {
654
+ const digest = await summarizeTranscript({
655
+ ...EPISODE_IN,
656
+ transcriptStatus: "unavailable",
657
+ reason: "corrupt media",
658
+ });
659
+
660
+ expect(digest.transcriptSource).toBe("unavailable");
661
+ // The reason reaches the reader, rather than the episode vanishing.
662
+ expect(digest.summary).toContain("corrupt media");
663
+ expect(digest.keyPoints).toHaveLength(1);
664
+ });
665
+
666
+ test("reads the clock in a step, so a replay sees the same timestamp", async () => {
667
+ expect(Date.parse(await timestamp())).not.toBeNaN();
668
+ });
669
+ });
670
+
671
+ describe("posting the digest", () => {
672
+ const stub = (answer: { status?: number; body?: unknown }) => installStubStepFetch(() => answer);
673
+
674
+ const INPUT = slackInput("https://hooks.slack.com/services/T/B/a");
675
+
676
+ test("posts the rendered payload and answers with what Slack said", async () => {
677
+ const stubbed = stub({ body: "ok" });
678
+
679
+ expect(await sendDigestToSlack(INPUT)).toBe("ok");
680
+ expect(stubbed.calls[0]?.method).toBe("POST");
681
+ expect(String(stubbed.calls[0]?.body)).toContain("Podcast digest 1/2");
682
+ });
683
+
684
+ test("treats an empty 200 as success rather than an empty status", async () => {
685
+ stub({ body: "" });
686
+ expect(await sendDigestToSlack(INPUT)).toBe("ok");
687
+ });
688
+
689
+ /**
690
+ * The 4xx/5xx split is the reason this is not a one-line `stepFetchOk`: a
691
+ * revoked webhook answers 4xx identically on every retry, so retrying it
692
+ * burns the DevKit's attempts and delays the real error by minutes.
693
+ */
694
+ test("makes a 4xx FATAL, with advice a person can act on", async () => {
695
+ stub({ status: 403, body: { error: "invalid_token" } });
696
+
697
+ await expect(sendDigestToSlack(INPUT)).rejects.toThrow(/revoked/);
698
+ });
699
+
700
+ test("names the unpublished workflow behind a trigger URL", async () => {
701
+ stub({ status: 400, body: { error: "workflow_not_published" } });
702
+
703
+ await expect(
704
+ sendDigestToSlack(slackInput("https://hooks.slack.com/triggers/T/B/a")),
705
+ ).rejects.toThrow(/not published/);
706
+ });
707
+
708
+ test("leaves a 5xx retryable, because that is Slack having a bad minute", async () => {
709
+ stub({ status: 503, body: "busy" });
710
+
711
+ const err = await sendDigestToSlack(INPUT).catch((thrown: unknown) => thrown);
712
+
713
+ expect((err as Error).message).toContain("503");
714
+ // NOT fatal — a `FatalError` here would drop a digest over a blip.
715
+ expect((err as Error).name).not.toBe("FatalError");
716
+ });
717
+ });