@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,762 @@
1
+ // Copyright 2026 the AAI authors. MIT license.
2
+ /// <reference types="vite/client" />
3
+
4
+ /**
5
+ * Specs for the recap desk — the template that ports the Temporal workflow
6
+ * patterns onto a voice call.
7
+ *
8
+ * Three tiers, and the line between them is what this file is careful about:
9
+ *
10
+ * - **The tools**, against a stubbed `ctx.workflows`. That is the only honest
11
+ * way to unit-test them (the real client needs a Workflow DevKit world), and
12
+ * it is enough: what the agent half promises is that the handoff passes a
13
+ * correlation key, that a second request finds the live run instead of paying
14
+ * for a second transcription, and that a cancel says out loud what cancelling
15
+ * does not do.
16
+ * - **The steps**, directly. Imported through vitest with no bundler in the
17
+ * path a `"use step"` function is an ordinary async function, so its HTTP
18
+ * handling, its retryable/fatal split and its JSON contract with the model are
19
+ * all testable.
20
+ * - **The body's two helpers** — the poll loop and the compensation unwind —
21
+ * with `sleep` stubbed. What that asserts is ORDERING and BRANCHING, which is
22
+ * ordinary logic and worth pinning; it asserts nothing about durability,
23
+ * replay or suspension, and could not. `recapFlow` itself is deliberately not
24
+ * driven here for exactly that reason — a body test dressed up as a durability
25
+ * test would be the worse failure. `aai-cli`'s
26
+ * `dev-workflow.scenario.test.ts` is the tier that builds a project and
27
+ * runs a real one.
28
+ */
29
+
30
+ import type { ToolContext, WorkflowClient, WorkflowRunSnapshot } from "@alexkroman1/aai";
31
+ import {
32
+ createProgressStream,
33
+ createRunSnapshot,
34
+ createStubWorkflows,
35
+ createToolContext,
36
+ runTool,
37
+ withDiscoveredTools,
38
+ } from "@alexkroman1/aai/testing";
39
+ import { installStubGateway as stubGateway } from "@alexkroman1/aai/testing/vitest";
40
+ import { beforeEach, describe, expect, test, vi } from "vitest";
41
+ import { createHook, type Hook, sleep } from "workflow";
42
+ import authoredAgent from "./agent.ts";
43
+ import { recap } from "./shared.ts";
44
+ import {
45
+ askWhetherToKeep,
46
+ awaitTranscript,
47
+ checkTranscript,
48
+ compensate,
49
+ discardTranscript,
50
+ submitRecording,
51
+ summarize,
52
+ type TranscriptState,
53
+ } from "./workflows/recap.ts";
54
+ import { retentionToken } from "./workflows/tokens.ts";
55
+
56
+ /**
57
+ * The DevKit, with only its timer replaced.
58
+ *
59
+ * `sleep` throws outside a run, so the poll loop cannot be exercised at all
60
+ * without this — and everything else the module imports from `workflow`
61
+ * (`FatalError`, `RetryableError`) is a real class the steps' own specs assert
62
+ * against, so it is `importActual` plus one override rather than a fake module.
63
+ */
64
+ vi.mock("workflow", async (importActual) => ({
65
+ ...(await importActual<typeof import("workflow")>()),
66
+ sleep: vi.fn(async () => undefined),
67
+ createHook: vi.fn(),
68
+ }));
69
+
70
+ /**
71
+ * The def a DEPLOYED agent runs: authored, plus what `tools/` declares.
72
+ *
73
+ * The glob is written HERE rather than reached for from a shared helper because
74
+ * this file SHIPS: it is what a scaffolded project runs, so it may not import
75
+ * anything outside its own template, and `import.meta.glob` is expanded against
76
+ * the file containing it either way. This is the pattern a user writes.
77
+ */
78
+ const agentDef = withDiscoveredTools(
79
+ authoredAgent,
80
+ import.meta.glob("./tools/*.ts", { eager: true }),
81
+ );
82
+
83
+ /** Every tool here is driven through the agent's own table, by the name the model calls. */
84
+ const run = (name: string, args: Record<string, unknown>, ctx: ToolContext): Promise<unknown> =>
85
+ runTool(agentDef, name, args, ctx);
86
+
87
+ /** A `ctx.workflows` that records `start` and answers `find` from a fixture. */
88
+ function stubWorkflows(runs: WorkflowRunSnapshot[] = []): WorkflowClient {
89
+ return createStubWorkflows({
90
+ start: vi.fn(async () => "wrun_stub"),
91
+ get: vi.fn(async () => runs[0]),
92
+ find: vi.fn(async () => runs),
93
+ recent: vi.fn(async () => runs),
94
+ cancel: vi.fn(async () => true),
95
+ wakeUp: vi.fn(async () => 0),
96
+ // A tail of 0 means "one line written", which is the case the tools read.
97
+ streamTail: vi.fn(async () => 0),
98
+ stream: vi.fn(async () => createProgressStream([])),
99
+ listing: () => [{ name: "recap" }],
100
+ });
101
+ }
102
+
103
+ /** A finished recap, as the workflow's output reaches the tools. */
104
+ function finishedOutput(over: { kept?: boolean; answered?: boolean } = {}) {
105
+ return {
106
+ url: "https://assembly.ai/wildfires.mp3",
107
+ headline: "Smoke reaches the east coast",
108
+ points: ["a", "b", "c"],
109
+ spoken: "Wildfire smoke drifted east and pushed air quality into the unhealthy range.",
110
+ minutes: 4,
111
+ kept: true,
112
+ answered: true,
113
+ requestedBy: "s_1",
114
+ ...over,
115
+ };
116
+ }
117
+
118
+ describe("the agent declares its workflow", () => {
119
+ test("under the name ctx.workflows.start resolves it by", () => {
120
+ expect(Object.keys(agentDef.workflows ?? {})).toEqual(["recap"]);
121
+ expect(agentDef.workflows?.recap).toBe(recap);
122
+ });
123
+
124
+ test("with an input schema, so a bad URL fails at the call site", async () => {
125
+ const ok = await recap.input?.["~standard"].validate({
126
+ url: "https://example.com/a.mp3",
127
+ requestedBy: "s",
128
+ });
129
+ expect(ok?.issues).toBeUndefined();
130
+ const bad = await recap.input?.["~standard"].validate({ url: "not a url", requestedBy: "s" });
131
+ expect(bad?.issues).toBeDefined();
132
+ });
133
+
134
+ test("and names the credential its steps read, so a deploy checks for it", () => {
135
+ // The steps reach the key with `requireStepEnv`, which no part of the agent
136
+ // config would otherwise mention.
137
+ expect(agentDef.requiredEnv).toContain("ASSEMBLYAI_API_KEY");
138
+ });
139
+
140
+ test("as a VOICE agent, not a workflow app", () => {
141
+ // The distinction this template exists on: a caller is on the line, so the
142
+ // run is a handoff. `link-digest` is the other shape.
143
+ expect(agentDef.page).toBeUndefined();
144
+ // Discovered, not declared: every name here is a file in `tools/`.
145
+ expect(Object.keys(agentDef.tools).sort()).toEqual([
146
+ "cancel_recap",
147
+ "keep_transcript",
148
+ "recap_progress",
149
+ "recap_status",
150
+ "request_recap",
151
+ ]);
152
+ });
153
+ });
154
+
155
+ describe("request_recap", () => {
156
+ test("starts a run keyed by the session, so a later turn can find it", async () => {
157
+ const workflows = stubWorkflows();
158
+ const ctx = createToolContext({ workflows });
159
+ const result = await run("request_recap", {}, ctx);
160
+
161
+ expect(workflows.start).toHaveBeenCalledWith(
162
+ recap,
163
+ { url: expect.stringContaining("http"), requestedBy: ctx.sessionId },
164
+ // `key` is the DURABLE handle — a later call finds the run by it — and
165
+ // `notify` is the live one: this session is told when the run lands.
166
+ { key: ctx.sessionId, notify: expect.stringContaining("one-sentence") },
167
+ );
168
+ expect(result).toMatchObject({ started: true, runId: "wrun_stub" });
169
+ });
170
+
171
+ test("passes the definition rather than its name", async () => {
172
+ const workflows = stubWorkflows();
173
+ await run("request_recap", {}, createToolContext({ workflows }));
174
+ // The def overload is what types the input and turns a rename into a compile
175
+ // error; a string would still work at runtime and lose both.
176
+ expect(vi.mocked(workflows.start).mock.calls[0]?.[0]).toBe(recap);
177
+ });
178
+
179
+ test("uses the caller's recording when they named one", async () => {
180
+ const workflows = stubWorkflows();
181
+ await run(
182
+ "request_recap",
183
+ { url: "https://example.com/board.mp3" },
184
+ createToolContext({ workflows }),
185
+ );
186
+ expect(vi.mocked(workflows.start).mock.calls[0]?.[1]).toMatchObject({
187
+ url: "https://example.com/board.mp3",
188
+ });
189
+ });
190
+
191
+ test("REFUSES a second run while one is live, and hands back the one that exists", async () => {
192
+ // Temporal's workflow-id reuse policy, spelled with what this SDK has. The
193
+ // failure it prevents is not tidiness: a caller who asks twice would
194
+ // otherwise pay for the same recording being transcribed twice.
195
+ const workflows = stubWorkflows([createRunSnapshot({ workflow: "recap", status: "running" })]);
196
+ const result = await run("request_recap", {}, createToolContext({ workflows }));
197
+ expect(result).toMatchObject({ started: false, runId: "wrun_1" });
198
+ expect(workflows.start).not.toHaveBeenCalled();
199
+ });
200
+
201
+ test("starts a fresh run once the previous one is terminal", async () => {
202
+ const workflows = stubWorkflows([
203
+ createRunSnapshot({ workflow: "recap", status: "completed", output: finishedOutput() }),
204
+ ]);
205
+ const result = await run("request_recap", {}, createToolContext({ workflows }));
206
+ expect(result).toMatchObject({ started: true });
207
+ expect(workflows.start).toHaveBeenCalledTimes(1);
208
+ });
209
+ });
210
+
211
+ describe("recap_status", () => {
212
+ test("says nothing was started when the key has no runs", async () => {
213
+ const ctx = createToolContext({ workflows: stubWorkflows([]) });
214
+ const result = await run("recap_status", {}, ctx);
215
+ expect(result).toMatchObject({ runs: [], note: "Nothing started yet." });
216
+ });
217
+
218
+ test("reads back the ONE-SENTENCE version of a finished recap", async () => {
219
+ // The output carries a `spoken` field for exactly this: the headline and
220
+ // three points are for an eye, and this tool answers an ear.
221
+ const workflows = stubWorkflows([
222
+ createRunSnapshot({ workflow: "recap", status: "completed", output: finishedOutput() }),
223
+ ]);
224
+ const result = (await run("recap_status", {}, createToolContext({ workflows }))) as {
225
+ runs: string[];
226
+ };
227
+ expect(result.runs[0]).toContain("air quality");
228
+ });
229
+
230
+ test("names the transcript's fate, so an unanswered gate is not silent", async () => {
231
+ // The caller who never got round to answering should hear that the
232
+ // transcript is gone, not just the recap.
233
+ const runs = [
234
+ createRunSnapshot({
235
+ workflow: "recap",
236
+ status: "completed",
237
+ output: finishedOutput({ kept: false, answered: false }),
238
+ }),
239
+ ];
240
+ const result = (await run(
241
+ "recap_status",
242
+ {},
243
+ createToolContext({ workflows: stubWorkflows(runs) }),
244
+ )) as { runs: string[] };
245
+ expect(result.runs[0]).toContain("transcript deleted");
246
+ });
247
+
248
+ test("reports a live run as still working rather than as empty", async () => {
249
+ const ctx = createToolContext({
250
+ workflows: stubWorkflows([createRunSnapshot({ workflow: "recap", status: "running" })]),
251
+ });
252
+ const result = (await run("recap_status", {}, ctx)) as { runs: string[] };
253
+ expect(result.runs[0]).toContain("Still working");
254
+ });
255
+
256
+ test("says a failed run was ROLLED BACK, because it was", async () => {
257
+ // The saga's whole point, said out loud: the run compensated before it
258
+ // failed, so there is nothing left on the account and nothing for the caller
259
+ // to chase.
260
+ const runs = [
261
+ createRunSnapshot({ workflow: "recap", status: "failed", error: "provider unavailable" }),
262
+ ];
263
+ const result = (await run(
264
+ "recap_status",
265
+ {},
266
+ createToolContext({ workflows: stubWorkflows(runs) }),
267
+ )) as { runs: string[] };
268
+ expect(result.runs[0]).toContain("rolled back");
269
+ expect(result.runs[0]).toContain("provider unavailable");
270
+ });
271
+
272
+ test("bounds how many past runs it reads aloud", async () => {
273
+ const workflows = stubWorkflows([]);
274
+ const ctx = createToolContext({ workflows });
275
+ await run("recap_status", {}, ctx);
276
+ // A voice reply cannot be a list of twenty runs.
277
+ expect(workflows.find).toHaveBeenCalledWith(recap, ctx.sessionId, { limit: 3 });
278
+ });
279
+ });
280
+
281
+ describe("recap_progress", () => {
282
+ test("reads the run's own progress line rather than its status", async () => {
283
+ const workflows = stubWorkflows([createRunSnapshot({ workflow: "recap", status: "running" })]);
284
+ vi.mocked(workflows.stream).mockResolvedValue(createProgressStream(["Transcript processing."]));
285
+ const result = await run("recap_progress", {}, createToolContext({ workflows }));
286
+ expect(result).toMatchObject({ progress: "Transcript processing." });
287
+ });
288
+
289
+ test("asks for the LAST line, not the whole log", async () => {
290
+ // Every poll narrates, so a twenty-minute run's whole log is eighty lines.
291
+ const workflows = stubWorkflows([createRunSnapshot({ workflow: "recap", status: "running" })]);
292
+ vi.mocked(workflows.stream).mockResolvedValue(createProgressStream(["a"]));
293
+ await run("recap_progress", {}, createToolContext({ workflows }));
294
+ expect(workflows.stream).toHaveBeenCalledWith("wrun_1", { startIndex: -1 });
295
+ });
296
+
297
+ test("a run that has written nothing yet says so WITHOUT opening the stream", async () => {
298
+ // Not a shortcut: an empty progress channel is never closed, so reading one
299
+ // waits for a line that arrives whenever the next step writes — i.e. the
300
+ // tool hangs instead of answering.
301
+ const workflows = stubWorkflows([createRunSnapshot({ workflow: "recap", status: "running" })]);
302
+ vi.mocked(workflows.streamTail).mockResolvedValue(-1);
303
+ const result = await run("recap_progress", {}, createToolContext({ workflows }));
304
+ expect(result).toMatchObject({ note: expect.stringContaining("nothing to report") });
305
+ expect(workflows.stream).not.toHaveBeenCalled();
306
+ });
307
+ });
308
+
309
+ describe("keep_transcript — the signal", () => {
310
+ test("signals the run's retention hook on the token BOTH sides derive", async () => {
311
+ // The token is the contract. `workflows/tokens.ts` is the one place it is
312
+ // spelled, which is what stops the body waiting on a string the tool never
313
+ // sends — a drift whose only symptom is `signal` answering false.
314
+ const workflows = stubWorkflows();
315
+ const signal = vi.fn(async () => true);
316
+ const ctx = createToolContext({ workflows: { ...workflows, signal } });
317
+ const result = await run("keep_transcript", { keep: true }, ctx);
318
+
319
+ expect(signal).toHaveBeenCalledWith(retentionToken(ctx.sessionId), { keep: true });
320
+ expect(result).toMatchObject({ answered: true, keep: true });
321
+ });
322
+
323
+ test("carries a DECLINE, not just an approval", async () => {
324
+ // Three outcomes, and this is the one a boolean gate loses if the tool only
325
+ // ever signals on yes: "delete it" has to reach the run before the window
326
+ // closes, or the caller waits two minutes for something they already said.
327
+ const signal = vi.fn(async () => true);
328
+ const ctx = createToolContext({ workflows: { ...stubWorkflows(), signal } });
329
+ await run("keep_transcript", { keep: false }, ctx);
330
+ expect(signal).toHaveBeenCalledWith(expect.any(String), { keep: false });
331
+ });
332
+
333
+ test("a token nobody holds is reported as SETTLED, not as a failure", async () => {
334
+ // The ordinary case: the window closed, or the caller answered a question
335
+ // nobody asked.
336
+ const signal = vi.fn(async () => false);
337
+ const ctx = createToolContext({ workflows: { ...stubWorkflows(), signal } });
338
+ const result = await run("keep_transcript", { keep: true }, ctx);
339
+ expect(result).toMatchObject({ answered: false, note: expect.stringContaining("settled") });
340
+ });
341
+ });
342
+
343
+ describe("cancel_recap", () => {
344
+ test("cancels the live run", async () => {
345
+ const workflows = stubWorkflows([createRunSnapshot({ workflow: "recap", status: "running" })]);
346
+ const result = await run("cancel_recap", {}, createToolContext({ workflows }));
347
+ expect(workflows.cancel).toHaveBeenCalledWith("wrun_1");
348
+ expect(result).toMatchObject({ cancelled: true });
349
+ });
350
+
351
+ test("says the transcript is LEFT BEHIND, because cancellation is not cooperative here", async () => {
352
+ // The one Temporal behaviour that did not port. Temporal delivers
353
+ // cancellation into the workflow, so the saga's catch runs; `cancel` here
354
+ // stops replaying the run, so the compensations never fire. A template that
355
+ // implied otherwise would be teaching the wrong thing.
356
+ const workflows = stubWorkflows([createRunSnapshot({ workflow: "recap", status: "running" })]);
357
+ const result = (await run("cancel_recap", {}, createToolContext({ workflows }))) as {
358
+ note: string;
359
+ };
360
+ expect(result.note).toContain("left behind");
361
+ });
362
+
363
+ test("a run that had already finished is reported honestly, not as a failure", async () => {
364
+ const workflows = stubWorkflows([
365
+ createRunSnapshot({ workflow: "recap", status: "completed", output: finishedOutput() }),
366
+ ]);
367
+ vi.mocked(workflows.cancel).mockResolvedValue(false);
368
+ const result = await run("cancel_recap", {}, createToolContext({ workflows }));
369
+ expect(result).toMatchObject({ cancelled: false, note: "That one had already finished." });
370
+ });
371
+
372
+ test("says nothing was started when the key has no runs", async () => {
373
+ const workflows = stubWorkflows([]);
374
+ const result = await run("cancel_recap", {}, createToolContext({ workflows }));
375
+ expect(result).toMatchObject({ cancelled: false, note: "Nothing started yet." });
376
+ expect(workflows.cancel).not.toHaveBeenCalled();
377
+ });
378
+ });
379
+
380
+ // ---- The steps --------------------------------------------------------------
381
+
382
+ /** A provider answering `body` with `status`, recording what it was asked. */
383
+ function stubProvider(body: unknown, status = 200) {
384
+ const calls: { url: string; init: RequestInit }[] = [];
385
+ vi.stubGlobal(
386
+ "fetch",
387
+ vi.fn(async (url: string, init: RequestInit = {}) => {
388
+ calls.push({ url, init });
389
+ return new Response(typeof body === "string" ? body : JSON.stringify(body), {
390
+ status,
391
+ headers: { "Content-Type": "application/json" },
392
+ });
393
+ }),
394
+ );
395
+ return calls;
396
+ }
397
+
398
+ describe("submitRecording", () => {
399
+ beforeEach(() => {
400
+ // `stepEnv` falls back to the process env when no host has published one,
401
+ // which is exactly the case a spec is. `unstubEnvs` clears it per test.
402
+ vi.stubEnv("ASSEMBLYAI_API_KEY", "sk-test");
403
+ });
404
+
405
+ test("posts the recording and returns the job id", async () => {
406
+ const calls = stubProvider({ id: "t_1", status: "queued" });
407
+ expect(await submitRecording("https://example.com/a.mp3")).toEqual({ id: "t_1" });
408
+
409
+ const call = calls[0];
410
+ expect(call?.init.method).toBe("POST");
411
+ expect(JSON.parse(String(call?.init.body))).toMatchObject({
412
+ audio_url: "https://example.com/a.mp3",
413
+ });
414
+ // AssemblyAI's `authorization` takes the key RAW — no `Bearer` prefix,
415
+ // unlike the OpenAI-compatible LLM gateway `summarize` calls.
416
+ const headers = call?.init.headers as Record<string, string> | undefined;
417
+ expect(headers?.authorization).toBe("sk-test");
418
+ });
419
+
420
+ test("fails FATALLY on a bad key and plainly on a rate limit", async () => {
421
+ // The retry policy in one assertion: a 401 answers the same way on the
422
+ // fourth attempt, a 429 is what retries are for.
423
+ stubProvider({ error: "nope" }, 401);
424
+ await expect(submitRecording("https://example.com/a.mp3")).rejects.toThrow(/HTTP 401/);
425
+ stubProvider({ error: "slow down" }, 429);
426
+ await expect(submitRecording("https://example.com/a.mp3")).rejects.toThrow(/HTTP 429/);
427
+ });
428
+
429
+ test("refuses a response that names no transcript id", async () => {
430
+ // Nothing downstream can poll without one, and a run that discovers that at
431
+ // the first poll has already lost the id it needed to compensate with.
432
+ stubProvider({ status: "queued" });
433
+ await expect(submitRecording("https://example.com/a.mp3")).rejects.toThrow(/transcript id/);
434
+ });
435
+ });
436
+
437
+ describe("checkTranscript", () => {
438
+ beforeEach(() => {
439
+ vi.stubEnv("ASSEMBLYAI_API_KEY", "sk-test");
440
+ });
441
+
442
+ test("reports a completed job with its text and duration", async () => {
443
+ stubProvider({ status: "completed", text: "Hello there.", audio_duration: 254 });
444
+ expect(await checkTranscript("t_1")).toEqual({
445
+ status: "completed",
446
+ text: "Hello there.",
447
+ audioDuration: 254,
448
+ });
449
+ });
450
+
451
+ test("carries the provider's own failure message through", async () => {
452
+ stubProvider({ status: "error", error: "Transcoding failed" });
453
+ expect(await checkTranscript("t_1")).toMatchObject({
454
+ status: "error",
455
+ error: "Transcoding failed",
456
+ });
457
+ });
458
+
459
+ test("omits absent fields rather than setting them to undefined", async () => {
460
+ // `exactOptionalPropertyTypes` makes those different types, and the result
461
+ // crosses a queue — a key set to `undefined` does not survive the trip.
462
+ stubProvider({ status: "processing" });
463
+ expect(Object.keys(await checkTranscript("t_1"))).toEqual(["status"]);
464
+ });
465
+
466
+ test("refuses a status it does not recognise instead of polling forever", async () => {
467
+ // The loop's exit conditions are `completed` and `error`; an unknown status
468
+ // is neither, so it would poll to the bound and then fail with the wrong
469
+ // reason.
470
+ stubProvider({ status: "transcribing" });
471
+ await expect(checkTranscript("t_1")).rejects.toThrow(/unknown transcript status/);
472
+ });
473
+ });
474
+
475
+ describe("discardTranscript — the compensation", () => {
476
+ beforeEach(() => {
477
+ vi.stubEnv("ASSEMBLYAI_API_KEY", "sk-test");
478
+ });
479
+
480
+ test("deletes the transcript this run created", async () => {
481
+ const calls = stubProvider({ id: "t_1" });
482
+ await expect(discardTranscript("t_1")).resolves.toBeUndefined();
483
+ expect(calls[0]?.init.method).toBe("DELETE");
484
+ expect(calls[0]?.url).toContain("/t_1");
485
+ });
486
+
487
+ test("treats a 404 as success, because an undo must be safe to run twice", async () => {
488
+ // The property every compensation needs: a replay re-enters a world where
489
+ // the undo may already have happened, and an undo that fails there would
490
+ // fail the unwind for having succeeded.
491
+ stubProvider("", 404);
492
+ await expect(discardTranscript("t_1")).resolves.toBeUndefined();
493
+ });
494
+
495
+ test("still fails on a real error, so the DevKit retries it", async () => {
496
+ stubProvider("", 503);
497
+ await expect(discardTranscript("t_1")).rejects.toThrow(/HTTP 503/);
498
+ });
499
+ });
500
+
501
+ describe("summarize", () => {
502
+ /** A finished transcript, typed rather than cast — the shape a step really gets. */
503
+ function transcript(over: Partial<TranscriptState> = {}): TranscriptState {
504
+ return { status: "completed", text: "We talked about smoke.", audioDuration: 254, ...over };
505
+ }
506
+
507
+ beforeEach(() => {
508
+ vi.stubEnv("ASSEMBLYAI_API_KEY", "sk-test");
509
+ });
510
+
511
+ test("returns the recap the model produced, with the recording's length in minutes", async () => {
512
+ stubGateway('{"headline":"Smoke","points":["a","b","c"],"spoken":"Smoke drifted east."}');
513
+ expect(await summarize("https://example.com/a.mp3", transcript())).toEqual({
514
+ url: "https://example.com/a.mp3",
515
+ headline: "Smoke",
516
+ points: ["a", "b", "c"],
517
+ spoken: "Smoke drifted east.",
518
+ // Rounded from the provider's seconds — a voice reply says "four minutes",
519
+ // never "254 seconds".
520
+ minutes: 4,
521
+ });
522
+ });
523
+
524
+ test("unwraps a fenced reply rather than failing on it", async () => {
525
+ stubGateway('```json\n{"headline":"H","points":["a"],"spoken":"S."}\n```');
526
+ expect((await summarize("https://x/a.mp3", transcript())).headline).toBe("H");
527
+ });
528
+
529
+ test("throws PLAINLY when the model answered with prose, so the step retries", async () => {
530
+ // The distinction that is the whole retry policy: a model that ignored the
531
+ // format may well obey on the next attempt, where a 401 will not.
532
+ stubGateway("Here is a recap of the recording.");
533
+ // The SDK's message, not this template's: `stepGenerateJson` owns the
534
+ // unwrap/parse/validate chain now, and a plain throw is what the DevKit
535
+ // retries.
536
+ await expect(summarize("https://x/a.mp3", transcript())).rejects.toThrow(/Expected JSON/);
537
+ });
538
+
539
+ test("rejects a reply missing the spoken sentence as firmly as no JSON at all", async () => {
540
+ // Without it the announced turn has nothing to read, which is the one field
541
+ // this template's output exists for.
542
+ stubGateway('{"headline":"H","points":["a"]}');
543
+ await expect(summarize("https://x/a.mp3", transcript())).rejects.toThrow(/did not match/);
544
+ });
545
+
546
+ test("fails FATALLY on a transcript with no speech in it", async () => {
547
+ // A completed transcript holds the same nothing on every attempt — silence,
548
+ // or a file with no speech. Retrying it five times buys nothing.
549
+ stubGateway('{"headline":"H","points":["a"],"spoken":"S."}');
550
+ await expect(summarize("https://x/a.mp3", transcript({ text: " " }))).rejects.toThrow(
551
+ /no speech/,
552
+ );
553
+ });
554
+
555
+ test("fails FATALLY with no API key rather than retrying five times", async () => {
556
+ vi.stubEnv("ASSEMBLYAI_API_KEY", "");
557
+ stubGateway('{"headline":"H","points":["a"],"spoken":"S."}');
558
+ await expect(summarize("https://x/a.mp3", transcript())).rejects.toThrow(/ASSEMBLYAI_API_KEY/);
559
+ });
560
+
561
+ test("retries beyond the default, because a rate limit and a bad format both happen", () => {
562
+ expect(summarize.maxRetries).toBeGreaterThan(3);
563
+ });
564
+ });
565
+
566
+ // ---- The body's helpers -----------------------------------------------------
567
+
568
+ describe("awaitTranscript — the polling port", () => {
569
+ beforeEach(() => {
570
+ vi.stubEnv("ASSEMBLYAI_API_KEY", "sk-test");
571
+ });
572
+
573
+ /** A provider whose status endpoint answers `statuses` in order. */
574
+ function stubStatuses(statuses: readonly Record<string, unknown>[]) {
575
+ let call = 0;
576
+ vi.stubGlobal(
577
+ "fetch",
578
+ vi.fn(async () => {
579
+ const body = statuses[Math.min(call, statuses.length - 1)];
580
+ call += 1;
581
+ return new Response(JSON.stringify(body), {
582
+ status: 200,
583
+ headers: { "Content-Type": "application/json" },
584
+ });
585
+ }),
586
+ );
587
+ return () => call;
588
+ }
589
+
590
+ test("keeps polling while the job is queued or processing", async () => {
591
+ const polls = stubStatuses([
592
+ { status: "queued" },
593
+ { status: "processing" },
594
+ { status: "completed", text: "Done.", audio_duration: 60 },
595
+ ]);
596
+ const state = await awaitTranscript("t_1");
597
+ expect(state).toMatchObject({ status: "completed", text: "Done." });
598
+ expect(polls()).toBe(3);
599
+ });
600
+
601
+ test("stops on the provider's own terminal failure instead of waiting it out", async () => {
602
+ // `error` is terminal: polling a failed job to the bound would spend twenty
603
+ // minutes learning what the first answer already said.
604
+ const polls = stubStatuses([{ status: "error", error: "Transcoding failed" }]);
605
+ await expect(awaitTranscript("t_1")).rejects.toThrow(/Transcoding failed/);
606
+ expect(polls()).toBe(1);
607
+ });
608
+
609
+ test("gives up at the bound rather than polling a stuck job forever", async () => {
610
+ stubStatuses([{ status: "processing" }]);
611
+ await expect(awaitTranscript("t_1")).rejects.toThrow(/Gave up/);
612
+ });
613
+ });
614
+
615
+ describe("askWhetherToKeep — the expense port", () => {
616
+ beforeEach(() => {
617
+ vi.stubEnv("ASSEMBLYAI_API_KEY", "sk-test");
618
+ // The window has NOT elapsed unless a test says so. A `sleep` that resolves
619
+ // would race the hook's own answer, and which one won would come down to
620
+ // microtask order rather than to the branch under test.
621
+ vi.mocked(sleep).mockReturnValue(new Promise<void>(() => undefined));
622
+ vi.mocked(createHook).mockReturnValue(hookAnswering(undefined));
623
+ });
624
+
625
+ /**
626
+ * A hook that resolves with `payload`, or never — which is what the timeout
627
+ * branch has to see.
628
+ *
629
+ * Built by hanging the hook's own members on a REAL promise rather than by
630
+ * writing a `then` property: a `Hook` is a thenable, and a hand-written one
631
+ * is both a lint finding and a worse model of the thing.
632
+ *
633
+ * `createHook()` throws outside a run, so this is the only way to reach the
634
+ * gate's branching at all. What it pins is the three OUTCOMES and the safe
635
+ * default; suspension, token registration and replay are not testable here
636
+ * and are not claimed to be.
637
+ */
638
+ function hookAnswering(
639
+ payload: { keep: boolean } | undefined,
640
+ onClaim: () => void = () => undefined,
641
+ ): Hook<{ keep: boolean }> {
642
+ const settled: Promise<{ keep: boolean }> =
643
+ payload === undefined ? new Promise(() => undefined) : Promise.resolve(payload);
644
+ return Object.assign(settled, {
645
+ token: "retention:stub",
646
+ getConflict: async () => {
647
+ onClaim();
648
+ return null;
649
+ },
650
+ dispose: () => undefined,
651
+ [Symbol.dispose]: () => undefined,
652
+ [Symbol.asyncIterator]: async function* () {
653
+ yield await settled;
654
+ },
655
+ });
656
+ }
657
+
658
+ test("keeps the transcript when the caller says to, and deletes nothing", async () => {
659
+ vi.mocked(createHook).mockReturnValue(hookAnswering({ keep: true }));
660
+ const fetchSpy = vi.fn();
661
+ vi.stubGlobal("fetch", fetchSpy);
662
+
663
+ const compensations = [{ label: "transcript t_1", undo: async () => undefined }];
664
+ expect(await askWhetherToKeep("s_1", "t_1", compensations)).toEqual({
665
+ kept: true,
666
+ answered: true,
667
+ });
668
+ expect(fetchSpy).not.toHaveBeenCalled();
669
+ // The undo stays on the stack: a later failure still has something to reverse.
670
+ expect(compensations).toHaveLength(1);
671
+ });
672
+
673
+ test("deletes on a DECLINE, and drops the undo it just performed", async () => {
674
+ vi.mocked(createHook).mockReturnValue(hookAnswering({ keep: false }));
675
+ const calls = stubProvider({ id: "t_1" });
676
+
677
+ const compensations = [{ label: "transcript t_1", undo: async () => undefined }];
678
+ expect(await askWhetherToKeep("s_1", "t_1", compensations)).toEqual({
679
+ kept: false,
680
+ answered: true,
681
+ });
682
+ expect(calls[0]?.init.method).toBe("DELETE");
683
+ // Leaving it would be harmless — the undo tolerates a 404 — and would still
684
+ // narrate an unwind that reverses something already gone.
685
+ expect(compensations).toHaveLength(0);
686
+ });
687
+
688
+ test("DELETES when nobody answers, which is what makes the window mean anything", async () => {
689
+ // The safe default, and the whole reason the gate is a gate: a no-answer
690
+ // branch that kept the data would be a prompt with a grace period.
691
+ vi.mocked(sleep).mockResolvedValue(undefined);
692
+ const calls = stubProvider({ id: "t_1" });
693
+
694
+ expect(await askWhetherToKeep("s_1", "t_1", [])).toEqual({ kept: false, answered: false });
695
+ expect(calls[0]?.init.method).toBe("DELETE");
696
+ });
697
+
698
+ test("claims the token BEFORE the caller is asked to answer it", async () => {
699
+ // `createHook()` registers nothing until the workflow suspends, so an answer
700
+ // sent before the claim lands is answered "nobody is listening" — which is
701
+ // indistinguishable from being late.
702
+ const order: string[] = [];
703
+ vi.mocked(createHook).mockReturnValue(
704
+ hookAnswering({ keep: true }, () => order.push("claimed")),
705
+ );
706
+ vi.stubGlobal(
707
+ "fetch",
708
+ vi.fn(async () => {
709
+ order.push("asked");
710
+ return new Response("{}", { status: 200 });
711
+ }),
712
+ );
713
+
714
+ await askWhetherToKeep("s_1", "t_1", []);
715
+ expect(order[0]).toBe("claimed");
716
+ });
717
+ });
718
+
719
+ describe("compensate — the saga port", () => {
720
+ test("unwinds newest-first, which is the order acquisitions were stacked in", async () => {
721
+ // `recapFlow` pushes with `unshift`, so the list is already newest-first and
722
+ // this walks it forwards. A dependency acquired later has to come off first.
723
+ const order: string[] = [];
724
+ await compensate(
725
+ [
726
+ { label: "second", undo: async () => void order.push("second") },
727
+ { label: "first", undo: async () => void order.push("first") },
728
+ ],
729
+ "because",
730
+ );
731
+ expect(order).toEqual(["second", "first"]);
732
+ });
733
+
734
+ test("a failing undo does not stop the ones behind it", async () => {
735
+ // Ported deliberately from Temporal's `compensate`, which swallows: the run
736
+ // already failed for a reason the caller needs, and a second-order undo
737
+ // failure must not replace it or strand the rest of the stack.
738
+ const order: string[] = [];
739
+ await expect(
740
+ compensate(
741
+ [
742
+ {
743
+ label: "broken",
744
+ undo: async () => {
745
+ throw new Error("the provider declined the delete");
746
+ },
747
+ },
748
+ { label: "fine", undo: async () => void order.push("fine") },
749
+ ],
750
+ "because",
751
+ ),
752
+ ).resolves.toBeUndefined();
753
+ expect(order).toEqual(["fine"]);
754
+ });
755
+
756
+ test("does nothing at all when nothing was acquired", async () => {
757
+ // The case where the FIRST step failed: there is nothing to reverse, and a
758
+ // run that narrated an unwind it did not perform would be lying to the
759
+ // caller reading its progress.
760
+ await expect(compensate([], "nothing was acquired")).resolves.toBeUndefined();
761
+ });
762
+ });