@alexkroman1/aai-cli 6.10.1 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/dist/{_agent-DpH2pBJd.mjs → _agent-BTwZJu4P.mjs} +2 -2
  2. package/dist/{_bundler-C2BDwU7r.mjs → _bundler-B4RqNF5Z.mjs} +2 -2
  3. package/dist/_dev-env.d.ts +22 -2
  4. package/dist/{_dev-server-LLLWnFBs.mjs → _dev-server-CiKFH9qw.mjs} +38 -47
  5. package/dist/_dev-server.d.ts +0 -17
  6. package/dist/{_init-C57bWAQ8.mjs → _init-CE4HKdgV.mjs} +3 -2
  7. package/dist/{_preflight-BtfaYtbE.mjs → _preflight-Co1G9Jww.mjs} +1 -1
  8. package/dist/_preflight.d.ts +1 -1
  9. package/dist/{_slug-api-BxrsJXVI.mjs → _slug-api-nRFaBEPJ.mjs} +2 -2
  10. package/dist/{_studio-U3jhKlA3.mjs → _studio-DR5yzZ1T.mjs} +2 -1
  11. package/dist/{_templates-Dxb_P2Wz.mjs → _templates-FI3xPEMj.mjs} +1 -1
  12. package/dist/_workflow-scan.d.ts +108 -0
  13. package/dist/{build-Dfza2pRI.mjs → build-6jfaakgc.mjs} +5 -2
  14. package/dist/cli.mjs +30 -25
  15. package/dist/{client-bundler-BEIqgOtd.mjs → client-bundler-CWnG42cU.mjs} +2 -1
  16. package/dist/client-bundler.mjs +1 -1
  17. package/dist/{delete-vbpjGzqs.mjs → delete-Dl_LSfvI.mjs} +3 -3
  18. package/dist/{deploy-B8lubiRT.mjs → deploy-DvYeD8ia.mjs} +5 -5
  19. package/dist/{dev-Uq5ujP8a.mjs → dev-B59JIfWO.mjs} +1 -1
  20. package/dist/{eject-31gjtaHF.mjs → eject-C9WJyyr2.mjs} +1 -1
  21. package/dist/{init-l6xfU4xX.mjs → init-CpJVjEg0.mjs} +3 -3
  22. package/dist/{login-C71-qz8F.mjs → login-BBuM1sxH.mjs} +3 -4
  23. package/dist/{logs-BWVGSjLR.mjs → logs-9q-psa4q.mjs} +2 -2
  24. package/dist/project-config.mjs +1 -1
  25. package/dist/scaffold/CLAUDE.md +93 -14
  26. package/dist/scaffold/package.json +5 -3
  27. package/dist/scaffold/server.mjs +13 -4
  28. package/dist/scaffold/vite.config.ts +1 -1
  29. package/dist/{secret-BuMuFR4B.mjs → secret-CVvSLIDV.mjs} +2 -2
  30. package/dist/{storage-DzRZ-eCw.mjs → storage-BvUrnvM3.mjs} +2 -2
  31. package/dist/{studio-BCNUpDgP.mjs → studio--MUV0cid.mjs} +4 -4
  32. package/dist/templates/call-audit/agent.test.ts +818 -0
  33. package/dist/templates/call-audit/agent.ts +171 -0
  34. package/dist/templates/call-audit/client.tsx +198 -0
  35. package/dist/templates/call-audit/workflows/audit.ts +298 -0
  36. package/dist/templates/call-audit/workflows/ingest.ts +251 -0
  37. package/dist/templates/call-audit/workflows/media.ts +637 -0
  38. package/dist/templates/call-audit/workflows/summarize.ts +201 -0
  39. package/dist/templates/call-audit/workflows/sync-api.ts +44 -0
  40. package/dist/templates/dispatch-center/agent.test.ts +149 -25
  41. package/dist/templates/dispatch-center/client.tsx +239 -129
  42. package/dist/templates/dispatch-center/shared.ts +99 -1
  43. package/dist/templates/dispatch-center/system-prompt.md +3 -1
  44. package/dist/templates/dispatch-center/tools/incident_add_note.ts +16 -13
  45. package/dist/templates/dispatch-center/tools/incident_create.ts +19 -1
  46. package/dist/templates/dispatch-center/tools/incident_escalate.ts +68 -59
  47. package/dist/templates/dispatch-center/tools/incident_triage.ts +57 -43
  48. package/dist/templates/dispatch-center/tools/incident_update_status.ts +49 -40
  49. package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +8 -2
  50. package/dist/templates/dispatch-center/tools/resources_dispatch.ts +69 -52
  51. package/dist/templates/dispatch-center/tools/resources_update_status.ts +45 -41
  52. package/dist/templates/embedded-assets/agent.test.ts +17 -14
  53. package/dist/templates/health-assistant/agent.test.ts +22 -4
  54. package/dist/templates/infocom-adventure/agent.test.ts +4 -6
  55. package/dist/templates/infocom-adventure/client.tsx +246 -164
  56. package/dist/templates/link-digest/agent.test.ts +24 -19
  57. package/dist/templates/link-digest/client.tsx +47 -61
  58. package/dist/templates/link-digest/workflows/digest.ts +19 -31
  59. package/dist/templates/night-owl/agent.test.ts +70 -19
  60. package/dist/templates/night-owl/agent.ts +5 -0
  61. package/dist/templates/night-owl/client.tsx +56 -56
  62. package/dist/templates/night-owl/shared.ts +24 -0
  63. package/dist/templates/night-owl/tools/recommend.ts +18 -6
  64. package/dist/templates/pizza-ordering/agent.test.ts +27 -27
  65. package/dist/templates/pizza-ordering/client.tsx +9 -26
  66. package/dist/templates/plan-and-execute/agent.test.ts +99 -58
  67. package/dist/templates/plan-and-execute/agent.ts +1 -1
  68. package/dist/templates/plan-and-execute/client.tsx +12 -15
  69. package/dist/templates/plan-and-execute/shared.ts +71 -2
  70. package/dist/templates/plan-and-execute/tools/plan_status.ts +18 -6
  71. package/dist/templates/plan-and-execute/tools/revise_plan.ts +18 -10
  72. package/dist/templates/plan-and-execute/tools/start_plan.ts +13 -2
  73. package/dist/templates/plan-and-execute/tools/work_next_step.ts +70 -22
  74. package/dist/templates/podcast-digest/agent.test.ts +746 -0
  75. package/dist/templates/podcast-digest/agent.ts +139 -0
  76. package/dist/templates/podcast-digest/client.tsx +154 -0
  77. package/dist/templates/podcast-digest/workflows/digest.ts +411 -0
  78. package/dist/templates/podcast-digest/workflows/feeds.ts +507 -0
  79. package/dist/templates/podcast-digest/workflows/slack.ts +209 -0
  80. package/dist/templates/recap-workflow/agent.test.ts +107 -96
  81. package/dist/templates/recap-workflow/tools/recap_progress.ts +9 -12
  82. package/dist/templates/recap-workflow/tools/recap_status.ts +8 -9
  83. package/dist/templates/recap-workflow/tools/request_recap.ts +2 -1
  84. package/dist/templates/recap-workflow/workflows/recap.ts +48 -41
  85. package/dist/templates/redline/agent.test.ts +17 -15
  86. package/dist/templates/redline/client.tsx +12 -12
  87. package/dist/templates/redline/workflows/redline.ts +19 -31
  88. package/dist/templates/research-workflow/agent.test.ts +60 -59
  89. package/dist/templates/research-workflow/tools/research_progress.ts +9 -12
  90. package/dist/templates/research-workflow/tools/research_status.ts +9 -11
  91. package/dist/templates/research-workflow/workflows/research.ts +44 -61
  92. package/dist/templates/retail/agent.test.ts +26 -23
  93. package/dist/templates/retail/client.tsx +226 -117
  94. package/dist/templates/retail/registry.test.ts +38 -6
  95. package/dist/templates/retail/store.test.ts +82 -15
  96. package/dist/templates/retail/store.ts +174 -47
  97. package/dist/templates/retail/system-prompt.md +11 -2
  98. package/dist/templates/retail/tools/cancel_pending_order.ts +2 -2
  99. package/dist/templates/retail/tools/exchange_delivered_order_items.ts +2 -2
  100. package/dist/templates/retail/tools/find_user_id_by_email.ts +8 -5
  101. package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +4 -5
  102. package/dist/templates/retail/tools/get_item_details.ts +3 -4
  103. package/dist/templates/retail/tools/get_order_details.ts +3 -4
  104. package/dist/templates/retail/tools/get_product_details.ts +3 -4
  105. package/dist/templates/retail/tools/get_user_details.ts +2 -2
  106. package/dist/templates/retail/tools/list_all_product_types.ts +5 -5
  107. package/dist/templates/retail/tools/modify_pending_order_address.ts +2 -2
  108. package/dist/templates/retail/tools/modify_pending_order_items.ts +2 -3
  109. package/dist/templates/retail/tools/modify_pending_order_payment.ts +2 -2
  110. package/dist/templates/retail/tools/modify_user_address.ts +2 -2
  111. package/dist/templates/retail/tools/return_delivered_order_items.ts +2 -2
  112. package/dist/templates/retail/tools/transfer_to_human_agents.ts +8 -4
  113. package/dist/templates/solo-rpg/agent.test.ts +227 -48
  114. package/dist/templates/solo-rpg/client.tsx +38 -37
  115. package/dist/templates/solo-rpg/shared.ts +145 -19
  116. package/dist/templates/solo-rpg/system-prompt.md +3 -2
  117. package/dist/templates/solo-rpg/tools/action_roll.ts +82 -63
  118. package/dist/templates/solo-rpg/tools/burn_momentum.ts +85 -50
  119. package/dist/templates/solo-rpg/tools/check_state.ts +24 -7
  120. package/dist/templates/solo-rpg/tools/load_game.ts +13 -1
  121. package/dist/templates/solo-rpg/tools/save_game.ts +16 -7
  122. package/dist/templates/solo-rpg/tools/setup_character.ts +22 -2
  123. package/dist/templates/solo-rpg/tools/update_state.ts +113 -100
  124. package/dist/templates/spoken-summary/agent.test.ts +314 -0
  125. package/dist/templates/spoken-summary/agent.ts +155 -0
  126. package/dist/templates/spoken-summary/client.tsx +185 -0
  127. package/dist/templates/spoken-summary/workflows/summarize.ts +237 -0
  128. package/dist/templates/spoken-summary/workflows/transcribe.ts +138 -0
  129. package/dist/templates/support-line/agent.test.ts +11 -16
  130. package/dist/templates/support-line/agent.ts +1 -1
  131. package/dist/templates/support-line/client.tsx +9 -9
  132. package/dist/templates/support-line/nodes.ts +100 -0
  133. package/dist/templates/support-line/procedure.ts +407 -0
  134. package/dist/templates/support-line/tools/answer_question.ts +17 -4
  135. package/dist/templates/transcription-workflow/agent.test.ts +246 -123
  136. package/dist/templates/transcription-workflow/agent.ts +21 -7
  137. package/dist/templates/transcription-workflow/client.tsx +17 -42
  138. package/dist/templates/transcription-workflow/workflows/batch.ts +79 -180
  139. package/dist/templates/transcription-workflow/workflows/normalize.ts +247 -0
  140. package/dist/templates/transcription-workflow/workflows/stitch.ts +0 -11
  141. package/dist/templates/transcription-workflow/workflows/stream.ts +10 -9
  142. package/dist/templates/transcription-workflow/workflows/sync-api.ts +26 -94
  143. package/dist/templates/transcription-workflow/workflows/transcribe.ts +55 -40
  144. package/dist/templates/transcription-workflow/workflows/wav.ts +31 -31
  145. package/dist/templates/travel-concierge/agent.test.ts +64 -33
  146. package/dist/templates/travel-concierge/client.tsx +11 -23
  147. package/dist/templates/travel-concierge/routing.ts +34 -15
  148. package/dist/templates/travel-concierge/shared.ts +70 -3
  149. package/dist/templates/travel-concierge/tools/book_car_rental.ts +2 -2
  150. package/dist/templates/travel-concierge/tools/book_excursion.ts +2 -2
  151. package/dist/templates/travel-concierge/tools/book_hotel.ts +2 -2
  152. package/dist/templates/travel-concierge/tools/cancel_ticket.ts +2 -2
  153. package/dist/templates/travel-concierge/tools/update_ticket.ts +2 -2
  154. package/dist/{worker-bundler-CGD4r8Kc.mjs → worker-bundler-COxnqstQ.mjs} +221 -3
  155. package/dist/worker-bundler.mjs +1 -1
  156. package/dist/{workflow-CFpxOFfQ.mjs → workflow-D2AQf2Pl.mjs} +28 -1
  157. package/dist/workflow-bundler.d.ts +6 -1
  158. package/dist/workflow.d.ts +1 -1
  159. package/package.json +5 -4
  160. package/dist/templates/support-line/graph.ts +0 -224
  161. package/dist/{_config-D_s09e7g.mjs → _config-CmJOFsAP.mjs} +1 -1
  162. /package/dist/templates/plan-and-execute/{graph.ts → procedure.ts} +0 -0
@@ -1,17 +1,26 @@
1
1
  import { z } from "zod";
2
- import { gameSlot, saveSlotKey, saveSlotParam, saveState } from "../shared.ts";
2
+ import { gameSlot, saveSlotKey, saveSlotParam, saveState, storyFlow } from "../shared.ts";
3
3
 
4
4
  // Requires storage — `aai storage enable` (or DATABASE_URL in .env under
5
5
  // `aai dev`); the rest of the game works without it.
6
6
  //
7
- // `gameSlot.tool` even though the body AWAITS: the reading half places no
8
- // constraint on the body (only `updateTool` must be synchronous, because its
9
- // draft is stored when it returns). What it does place is the frozen value, and
10
- // a save is the purest read there is.
11
- export default gameSlot.tool({
7
+ /**
8
+ * Gated on `playing` or `gameOver` i.e. anything but `awaitingSetup`.
9
+ *
10
+ * Saving before a character exists writes an empty campaign under a slot name,
11
+ * and `load_game` would then cheerfully restore it over a real game. That is a
12
+ * position, so it is a `when`.
13
+ *
14
+ * The body AWAITS, which a flow tool allows: only `slot.updateTool` must be
15
+ * synchronous, because its draft is stored when it returns. What a save needs is
16
+ * the frozen value, and `gameSlot.get` is what hands it over.
17
+ */
18
+ export default storyFlow.tool({
12
19
  description: "Save current game to persistent storage.",
13
20
  inputSchema: z.object({ slot: saveSlotParam }),
14
- async execute(args, game, ctx) {
21
+ when: ["playing", "gameOver"],
22
+ async execute(args, ctx) {
23
+ const game = gameSlot.get(ctx);
15
24
  await saveState(ctx, saveSlotKey(args.slot), game);
16
25
  return {
17
26
  saved: true,
@@ -14,10 +14,24 @@ import {
14
14
  makeNpc,
15
15
  shuffle,
16
16
  stateSummary,
17
+ storyFlow,
17
18
  TIME_PHASES,
18
19
  TONES,
19
20
  } from "../shared.ts";
20
21
 
22
+ /**
23
+ * **Not a `storyFlow.tool`, deliberately.** Setting up is legal in every state —
24
+ * a player may start over at any point, an ended story included — so a `when`
25
+ * listing all of them would be a gate that gates nothing. It drives the flow
26
+ * itself, which is what `dialog.reset` and `dialog.send` are public for, and reports
27
+ * the position it landed in so the narrator reads what the opening turn expects.
28
+ *
29
+ * **`reset` before `send`, because a restart is not a transition.** This body
30
+ * replaces the campaign with a pristine `DEFAULT_STATE`; resetting the flow is
31
+ * the same statement on the other side, and it is what makes starting over work
32
+ * from `gameOver` — a final state delivers no events, so a `SETUP` transition
33
+ * out of it would be dead config.
34
+ */
21
35
  export default tool({
22
36
  description:
23
37
  "Set up the entire game in one call. Starts a completely fresh game (any previous unsaved game is replaced), generates stats, initializes state, and marks the game as ready. After this returns, just narrate the opening scene. No need to call update_state — everything is already done.",
@@ -186,12 +200,16 @@ export default tool({
186
200
  storyComplete: false,
187
201
  };
188
202
 
189
- // Mark initialized
203
+ // Mark initialized. `initialized` is the CLIENT's render flag; the flow is
204
+ // what gates the tools, and `SETUP` is what puts it in `playing` — so the
205
+ // roll tools become available in the same call that gives them stats to
206
+ // roll against.
190
207
  state.initialized = true;
191
- state.phase = "playing";
192
208
  state.sceneCount = 1;
193
209
 
194
210
  gameSlot.set(ctx, state);
211
+ storyFlow.reset(ctx);
212
+ const at = storyFlow.send(ctx, { type: "SETUP" });
195
213
 
196
214
  return {
197
215
  success: true,
@@ -201,6 +219,8 @@ export default tool({
201
219
  archetypeLabel: ARCHETYPES[args.archetype as keyof typeof ARCHETYPES] || args.archetype,
202
220
  openingSituation: args.openingSituation,
203
221
  creativitySeed: creativitySeed(),
222
+ // The position spread verbatim — the same keys every gated tool reports.
223
+ ...at,
204
224
  // The real saved state — never hardcoded values
205
225
  ...stateSummary(state),
206
226
  };
@@ -15,13 +15,23 @@ import {
15
15
  makeNpc,
16
16
  nextSeqId,
17
17
  stateSummary,
18
+ storyFlow,
18
19
  TIME_PHASES,
19
- updateCrisisFlags,
20
20
  } from "../shared.ts";
21
21
 
22
- export default gameSlot.updateTool({
22
+ /**
23
+ * Gated on `playing`: there is nothing to sync before a character exists (that
24
+ * is `setup_character`'s whole job, and the prompt says not to call this after
25
+ * it), and nothing to sync after the story has ended.
26
+ *
27
+ * It sends `SETTLED`, because moving the scene on is what SPENDS a standing
28
+ * roll: once the narrator has changed the location or logged the scene, the
29
+ * player's window to burn momentum on the last roll has closed.
30
+ */
31
+ export default storyFlow.tool({
23
32
  description:
24
33
  "Lightweight state sync for during gameplay. Handles location changes, NPC additions, clock additions, time changes, and session log entries. Resource changes (health/spirit/supply/momentum) are auto-applied by action_roll — only use those fields here for manual adjustments like resting or trading. Pass only what changed.",
34
+ when: "playing",
25
35
  inputSchema: z.object({
26
36
  location: z.string().max(200).describe("New location name").optional(),
27
37
  locationDesc: z.string().max(500).describe("Short location description").optional(),
@@ -54,117 +64,120 @@ export default gameSlot.updateTool({
54
64
  storyComplete: z.boolean().describe("Mark story as complete").optional(),
55
65
  logEntry: z.string().max(500).describe("Short log entry for this scene").optional(),
56
66
  }),
57
- execute(args, state) {
58
- const warnings: string[] = [];
59
- const clockEvents: { clock: string; trigger: string }[] = [];
67
+ execute: (args, ctx) =>
68
+ gameSlot.update(ctx, (state) => {
69
+ const warnings: string[] = [];
70
+ const clockEvents: { clock: string; trigger: string }[] = [];
60
71
 
61
- // Resources
62
- if (args.health !== undefined) state.health = Math.max(0, Math.min(MAX_RESOURCE, args.health));
63
- if (args.spirit !== undefined) state.spirit = Math.max(0, Math.min(MAX_RESOURCE, args.spirit));
64
- if (args.supply !== undefined) state.supply = Math.max(0, Math.min(MAX_RESOURCE, args.supply));
65
- if (args.momentum !== undefined)
66
- state.momentum = Math.max(MIN_MOMENTUM, Math.min(state.maxMomentum, args.momentum));
72
+ // Resources
73
+ if (args.health !== undefined)
74
+ state.health = Math.max(0, Math.min(MAX_RESOURCE, args.health));
75
+ if (args.spirit !== undefined)
76
+ state.spirit = Math.max(0, Math.min(MAX_RESOURCE, args.spirit));
77
+ if (args.supply !== undefined)
78
+ state.supply = Math.max(0, Math.min(MAX_RESOURCE, args.supply));
79
+ if (args.momentum !== undefined)
80
+ state.momentum = Math.max(MIN_MOMENTUM, Math.min(state.maxMomentum, args.momentum));
67
81
 
68
- // Location
69
- if (args.location !== undefined) state.currentLocation = args.location;
70
- if (args.locationDesc !== undefined) state.currentSceneContext = args.locationDesc;
71
- if (args.timeOfDay !== undefined) state.timeOfDay = args.timeOfDay;
82
+ // Location
83
+ if (args.location !== undefined) state.currentLocation = args.location;
84
+ if (args.locationDesc !== undefined) state.currentSceneContext = args.locationDesc;
85
+ if (args.timeOfDay !== undefined) state.timeOfDay = args.timeOfDay;
72
86
 
73
- // Add NPC
74
- if (args.addNpcName) {
75
- if (state.npcs.length >= MAX_NPCS) {
76
- warnings.push(
77
- `NPC limit of ${MAX_NPCS} reached — mark an existing NPC deceased or background instead.`,
78
- );
79
- } else {
80
- state.npcs.push(
81
- makeNpc({
82
- id: nextSeqId(state.npcs, "npc"),
83
- name: args.addNpcName,
84
- description: args.addNpcDesc,
85
- disposition: args.addNpcDisposition,
86
- agenda: args.addNpcAgenda,
87
- }),
88
- );
87
+ // Add NPC
88
+ if (args.addNpcName) {
89
+ if (state.npcs.length >= MAX_NPCS) {
90
+ warnings.push(
91
+ `NPC limit of ${MAX_NPCS} reached — mark an existing NPC deceased or background instead.`,
92
+ );
93
+ } else {
94
+ state.npcs.push(
95
+ makeNpc({
96
+ id: nextSeqId(state.npcs, "npc"),
97
+ name: args.addNpcName,
98
+ description: args.addNpcDesc,
99
+ disposition: args.addNpcDisposition,
100
+ agenda: args.addNpcAgenda,
101
+ }),
102
+ );
103
+ }
89
104
  }
90
- }
91
105
 
92
- // Update NPC
93
- if (args.updateNpcId) {
94
- const npc = state.npcs.find((n) => n.id === args.updateNpcId);
95
- if (npc) {
96
- if (args.updateNpcDisposition !== undefined) npc.disposition = args.updateNpcDisposition;
97
- if (args.updateNpcBond !== undefined) npc.bond = args.updateNpcBond;
98
- if (args.updateNpcStatus !== undefined) npc.status = args.updateNpcStatus;
99
- } else {
100
- warnings.push(`No NPC with id ${args.updateNpcId}.`);
106
+ // Update NPC
107
+ if (args.updateNpcId) {
108
+ const npc = state.npcs.find((n) => n.id === args.updateNpcId);
109
+ if (npc) {
110
+ if (args.updateNpcDisposition !== undefined) npc.disposition = args.updateNpcDisposition;
111
+ if (args.updateNpcBond !== undefined) npc.bond = args.updateNpcBond;
112
+ if (args.updateNpcStatus !== undefined) npc.status = args.updateNpcStatus;
113
+ } else {
114
+ warnings.push(`No NPC with id ${args.updateNpcId}.`);
115
+ }
101
116
  }
102
- }
103
117
 
104
- // Add clock
105
- if (args.addClockName) {
106
- if (state.clocks.length >= MAX_CLOCKS) {
107
- warnings.push(`Clock limit of ${MAX_CLOCKS} reached — remove a finished clock first.`);
108
- } else {
109
- state.clocks.push({
110
- id: nextSeqId(state.clocks, "clock"),
111
- name: args.addClockName,
112
- clockType: args.addClockType ?? "threat",
113
- segments: args.addClockSegments ?? DEFAULT_CLOCK_SEGMENTS,
114
- filled: 0,
115
- triggerDescription: args.addClockTrigger ?? "",
116
- });
118
+ // Add clock
119
+ if (args.addClockName) {
120
+ if (state.clocks.length >= MAX_CLOCKS) {
121
+ warnings.push(`Clock limit of ${MAX_CLOCKS} reached — remove a finished clock first.`);
122
+ } else {
123
+ state.clocks.push({
124
+ id: nextSeqId(state.clocks, "clock"),
125
+ name: args.addClockName,
126
+ clockType: args.addClockType ?? "threat",
127
+ segments: args.addClockSegments ?? DEFAULT_CLOCK_SEGMENTS,
128
+ filled: 0,
129
+ triggerDescription: args.addClockTrigger ?? "",
130
+ });
131
+ }
117
132
  }
118
- }
119
133
 
120
- // Advance clock
121
- if (args.advanceClockName) {
122
- const clock = state.clocks.find((c) => c.name === args.advanceClockName);
123
- if (clock && clock.filled < clock.segments) {
124
- clock.filled = Math.min(clock.segments, clock.filled + 1);
125
- if (clock.filled >= clock.segments) {
126
- clockEvents.push({ clock: clock.name, trigger: clock.triggerDescription });
134
+ // Advance clock
135
+ if (args.advanceClockName) {
136
+ const clock = state.clocks.find((c) => c.name === args.advanceClockName);
137
+ if (clock && clock.filled < clock.segments) {
138
+ clock.filled = Math.min(clock.segments, clock.filled + 1);
139
+ if (clock.filled >= clock.segments) {
140
+ clockEvents.push({ clock: clock.name, trigger: clock.triggerDescription });
141
+ }
127
142
  }
128
143
  }
129
- }
130
-
131
- // Remove clock
132
- if (args.removeClockName) {
133
- state.clocks = state.clocks.filter((c) => c.name !== args.removeClockName);
134
- }
135
144
 
136
- // Story arc
137
- if (args.advanceAct && state.storyBlueprint) {
138
- state.storyBlueprint.currentAct = Math.min(
139
- state.storyBlueprint.acts.length,
140
- state.storyBlueprint.currentAct + 1,
141
- );
142
- }
143
- if (args.storyComplete && state.storyBlueprint) {
144
- state.storyBlueprint.storyComplete = true;
145
- }
145
+ // Remove clock
146
+ if (args.removeClockName) {
147
+ state.clocks = state.clocks.filter((c) => c.name !== args.removeClockName);
148
+ }
146
149
 
147
- // Session log
148
- if (args.logEntry) {
149
- pushCapped(
150
- state.sessionLog,
151
- {
152
- scene: state.sceneCount,
153
- summary: args.logEntry,
154
- location: state.currentLocation,
155
- },
156
- MAX_SESSION_LOG,
157
- );
158
- }
150
+ // Story arc
151
+ if (args.advanceAct && state.storyBlueprint) {
152
+ state.storyBlueprint.currentAct = Math.min(
153
+ state.storyBlueprint.acts.length,
154
+ state.storyBlueprint.currentAct + 1,
155
+ );
156
+ }
157
+ if (args.storyComplete && state.storyBlueprint) {
158
+ state.storyBlueprint.storyComplete = true;
159
+ }
159
160
 
160
- // Crisis check
161
- updateCrisisFlags(state);
161
+ // Session log
162
+ if (args.logEntry) {
163
+ pushCapped(
164
+ state.sessionLog,
165
+ {
166
+ scene: state.sceneCount,
167
+ summary: args.logEntry,
168
+ location: state.currentLocation,
169
+ },
170
+ MAX_SESSION_LOG,
171
+ );
172
+ }
162
173
 
163
- return {
164
- success: true,
165
- ...(warnings.length > 0 ? { warnings } : {}),
166
- clockEvents,
167
- ...stateSummary(state),
168
- };
169
- },
174
+ return {
175
+ success: true,
176
+ ...(warnings.length > 0 ? { warnings } : {}),
177
+ clockEvents,
178
+ ...stateSummary(state),
179
+ };
180
+ }),
181
+ sendFrom: (result) =>
182
+ result.gameOver ? { type: "DOWNED" as const } : { type: "SETTLED" as const },
170
183
  });
@@ -0,0 +1,314 @@
1
+ // Copyright 2026 the AAI authors. MIT license.
2
+ /**
3
+ * Specs for the spoken-summary app's declaration and its four legs.
4
+ *
5
+ * **The body itself is not driven here**, and that is a property of what a
6
+ * workflow template demonstrates rather than a gap: imported through vitest
7
+ * with no bundler in the path, a `"use step"` function is an ordinary async
8
+ * function — so its HTTP handling, its fatal/retryable classification and what
9
+ * it returns are all testable, while durability, suspension and replay are not.
10
+ * A body test that looked like a durability test would be the worse failure;
11
+ * the real thing is exercised end to end by `aai-cli`'s
12
+ * `dev-workflow.scenario.test.ts`.
13
+ *
14
+ * The two legs worth their own sections are the ones the SDK grew for this
15
+ * template. `speak` is where a step SPEAKS and STORES, and the assertion that
16
+ * matters is that it returns an id rather than bytes — a step is journaled by
17
+ * its return value, and audio in one is megabytes replayed on every resume.
18
+ */
19
+
20
+ import { readUpload, uploadInfo } from "@alexkroman1/aai/step";
21
+ import {
22
+ installStubGateway,
23
+ installStubReporter,
24
+ installStubSpeech,
25
+ installStubTranscribe,
26
+ installStubUploads,
27
+ } from "@alexkroman1/aai/testing/vitest";
28
+ import { beforeEach, describe, expect, test, vi } from "vitest";
29
+ import { FatalError, RetryableError } from "workflow";
30
+ import agentDef, { spokenSummary } from "./agent.ts";
31
+ import { speak, spokenSummaryFlow, summarize } from "./workflows/summarize.ts";
32
+ import { createJob, pollTranscript, uploadToProvider } from "./workflows/transcribe.ts";
33
+
34
+ /** The id every spec below uploads under. */
35
+ const UPLOAD_ID = "upl_test";
36
+
37
+ beforeEach(() => {
38
+ // WRITABLE, because this app's whole second half stores a file — and it is
39
+ // opt-in precisely so a step that wrote one nobody meant it to would fail.
40
+ // `install*` rather than `stub*`: the fake registers its own `onTestFinished`,
41
+ // which is what replaced the hand-kept restore registry this file used to hold.
42
+ installStubUploads(
43
+ { [UPLOAD_ID]: { bytes: new Uint8Array(64), name: "standup.wav", type: "audio/wav" } },
44
+ { writable: true },
45
+ );
46
+ // The step env, which is where `requireStepEnv` and `stepSpeak` read the key.
47
+ // `vi.stubEnv` rather than an assignment: `unstubEnvs` undoes it before every
48
+ // test, so nothing here has to remember to put it back.
49
+ vi.stubEnv("ASSEMBLYAI_API_KEY", "test-key");
50
+ });
51
+
52
+ describe("the declaration", () => {
53
+ test("is a workflow app with the one workflow the page starts by name", () => {
54
+ // The page calls `api.start("spokenSummary", …)`, so a rename here is a
55
+ // runtime 400 rather than a compile error. This is what pins it.
56
+ expect(Object.keys(agentDef.workflows ?? {})).toEqual(["spokenSummary"]);
57
+ expect(agentDef.page).toBe("static");
58
+ });
59
+
60
+ test("declares no providers and exactly the one credential its steps read", () => {
61
+ // A workflow app has no session, so nothing else in its config could name
62
+ // one — and one AssemblyAI key covers transcription, the model and the voice.
63
+ expect(agentDef.requiredEnv).toEqual(["ASSEMBLYAI_API_KEY"]);
64
+ });
65
+
66
+ test("takes the recording as an UPLOAD, which is what makes the form a file picker", () => {
67
+ expect(spokenSummary.uploads).toEqual(["recording"]);
68
+ });
69
+
70
+ test("offers real voice ids, so the synthesis cannot fail silently in band", async () => {
71
+ const parsed = spokenSummary.input?.["~standard"].validate({
72
+ recording: UPLOAD_ID,
73
+ voice: "not-a-voice",
74
+ });
75
+
76
+ expect((await parsed)?.issues).toBeTruthy();
77
+ expect(
78
+ (await spokenSummary.input?.["~standard"].validate({ recording: UPLOAD_ID, voice: "jane" }))
79
+ ?.issues,
80
+ ).toBeUndefined();
81
+ });
82
+ });
83
+
84
+ describe("transcribing", () => {
85
+ test("streams the stored recording to the provider and keeps the URL it answered", async () => {
86
+ const provider = installStubTranscribe({ audioUrl: "https://cdn/aai/1" });
87
+ installStubReporter();
88
+
89
+ await expect(uploadToProvider(UPLOAD_ID)).resolves.toEqual({
90
+ audioUrl: "https://cdn/aai/1",
91
+ });
92
+ expect(provider.calls[0]?.leg).toBe("upload");
93
+ // The bytes really went, and they went as the file rather than as JSON.
94
+ expect(provider.calls[0]?.body?.length).toBe(64);
95
+ });
96
+
97
+ test("a 429 from the provider is RETRYABLE and a 400 is not", async () => {
98
+ const first = installStubTranscribe({ failure: { status: 429, message: "slow down" } });
99
+ installStubReporter();
100
+ await expect(createJob("https://cdn/aai/1")).rejects.toBeInstanceOf(RetryableError);
101
+ // Unpublished by hand: this is a boundary WITHIN one test, which the
102
+ // per-test auto-restore cannot give.
103
+ first.restore();
104
+
105
+ installStubTranscribe({ failure: { status: 400, message: "bad model" } });
106
+ await expect(createJob("https://cdn/aai/1")).rejects.toBeInstanceOf(FatalError);
107
+ });
108
+
109
+ test("a job the provider gave up on is FATAL — no number of polls changes it", async () => {
110
+ installStubTranscribe({ jobError: "corrupt audio" });
111
+
112
+ await expect(pollTranscript(UPLOAD_ID, "t_1")).rejects.toThrow("corrupt audio");
113
+ await expect(pollTranscript(UPLOAD_ID, "t_1")).rejects.toBeInstanceOf(FatalError);
114
+ });
115
+
116
+ test("`done` is decided here, so the body never reads a provider's vocabulary", async () => {
117
+ installStubTranscribe({ pendingPolls: 1 });
118
+
119
+ await expect(pollTranscript(UPLOAD_ID, "t_1")).resolves.toEqual({ done: false });
120
+ });
121
+
122
+ test("a finished poll carries the transcript, named by the FILENAME", async () => {
123
+ // ONE request, not two: this used to poll for a status and then fetch the
124
+ // identical URL again for the text the poll already had in its hand.
125
+ const provider = installStubTranscribe({ text: " we shipped it ", durationSec: 12.4 });
126
+ installStubReporter();
127
+
128
+ await expect(pollTranscript(UPLOAD_ID, "t_1")).resolves.toEqual({
129
+ done: true,
130
+ transcript: { source: "standup.wav", durationMs: 12_400, text: "we shipped it" },
131
+ });
132
+ expect(provider.calls).toHaveLength(1);
133
+ });
134
+
135
+ test("a recording of silence is FATAL rather than an empty summary", async () => {
136
+ // The failure this template is most likely to meet: silence transcribes
137
+ // successfully to nothing, and everything downstream would then be asked to
138
+ // summarize and speak no words at all.
139
+ installStubTranscribe({ text: " ", durationSec: 3 });
140
+
141
+ await expect(pollTranscript(UPLOAD_ID, "t_1")).rejects.toThrow("no speech in that recording");
142
+ await expect(pollTranscript(UPLOAD_ID, "t_1")).rejects.toBeInstanceOf(FatalError);
143
+ });
144
+ });
145
+
146
+ describe("summarizing", () => {
147
+ test("asks the model for a spoken script as well as points, and keeps both", async () => {
148
+ const calls = installStubGateway(
149
+ JSON.stringify({
150
+ headline: "Launch is on",
151
+ points: ["Ship Tuesday", "Two bugs left"],
152
+ spoken: "The launch is on for Tuesday, with two bugs still open.",
153
+ }),
154
+ );
155
+ installStubReporter();
156
+
157
+ const summary = await summarize("we ship tuesday");
158
+
159
+ expect(summary.headline).toBe("Launch is on");
160
+ expect(summary.points).toEqual(["Ship Tuesday", "Two bugs left"]);
161
+ expect(summary.spoken).toBe("The launch is on for Tuesday, with two bugs still open.");
162
+ // The prompt really carries the transcript, and really asks for the two
163
+ // shapes — a page that got bullets read aloud is the failure this prevents.
164
+ expect(calls[0]?.prompt).toContain("we ship tuesday");
165
+ expect(calls[0]?.prompt).toContain("READ ALOUD");
166
+ });
167
+
168
+ test("caps the points at what the schema promises the page", async () => {
169
+ installStubGateway(
170
+ JSON.stringify({
171
+ headline: "Many things",
172
+ points: ["a", "b", "c", "d", "e", "f"],
173
+ spoken: "Several things happened.",
174
+ }),
175
+ );
176
+ installStubReporter();
177
+
178
+ expect((await summarize("…")).points).toHaveLength(4);
179
+ });
180
+
181
+ test("a reply with no spoken script FAILS rather than defaulting to silence", async () => {
182
+ installStubGateway(JSON.stringify({ headline: "Launch is on", points: ["Ship Tuesday"] }));
183
+ installStubReporter();
184
+
185
+ await expect(summarize("…")).rejects.toThrow(/did not match the shape/);
186
+ });
187
+ });
188
+
189
+ describe("speaking", () => {
190
+ test("stores a WAV and returns its ID — never the bytes", async () => {
191
+ const speech = installStubSpeech({ pcmBytes: 48_000 });
192
+ installStubReporter();
193
+
194
+ const spoken = await speak("The launch is on for Tuesday.");
195
+
196
+ // An id, because a step is journaled by its return value: audio in one is
197
+ // megabytes replayed on every resume.
198
+ expect(spoken).toEqual({ audio: "upl_stub_1", durationMs: 1000 });
199
+ expect(speech.calls[0]?.text).toBe("The launch is on for Tuesday.");
200
+ });
201
+
202
+ test("what it stored is a real WAV, named and typed for the browser", async () => {
203
+ installStubSpeech({ pcmBytes: 4000 });
204
+ installStubReporter();
205
+
206
+ const { audio } = await speak("Hello.");
207
+
208
+ await expect(uploadInfo(audio)).resolves.toMatchObject({
209
+ name: "summary.wav",
210
+ // The byte route serves this as `Content-Type`, and a browser will not
211
+ // play inline a file it was handed as octet-stream.
212
+ type: "audio/wav",
213
+ size: 44 + 4000,
214
+ });
215
+ const { bytes } = await readUpload(audio, { end: 12 });
216
+ expect(String.fromCharCode(...bytes.subarray(0, 4))).toBe("RIFF");
217
+ expect(String.fromCharCode(...bytes.subarray(8, 12))).toBe("WAVE");
218
+ });
219
+
220
+ test("passes a chosen voice through, and omits it entirely when none was chosen", async () => {
221
+ const speech = installStubSpeech();
222
+ installStubReporter();
223
+
224
+ await speak("Hello.", "michael");
225
+ await speak("Hello.");
226
+
227
+ expect(speech.calls[0]?.voice).toBe("michael");
228
+ // The SDK's own default, not one this template restates.
229
+ expect(speech.calls[1]?.voice).toBe("jane");
230
+ });
231
+ });
232
+
233
+ describe("the whole run", () => {
234
+ /**
235
+ * Answer every leg's HTTP, so the BODY can be driven end to end.
236
+ *
237
+ * Imported through vitest with no bundler in the path, a `"use workflow"`
238
+ * function is an ordinary async function — so what this exercises is the
239
+ * ORDER the legs are wired in and the shape they hand each other, which is
240
+ * the one thing the per-leg specs above cannot see. Durability, suspension
241
+ * and replay are not testable here and are not what this claims.
242
+ *
243
+ * The job answers `completed` on its FIRST poll, deliberately: a second poll
244
+ * would reach the durable `sleep`, which outside a real run is not a wait
245
+ * this spec should be taking.
246
+ *
247
+ * **The model call goes through this too, not `installStubGateway`.** A
248
+ * published `stepFetch` is what `stepGenerate` makes its request with, so a
249
+ * global-fetch stub is never reached once one exists — which is exactly the
250
+ * point a published `stepFetch` exists to make, and exactly why
251
+ * `stubTranscribe` takes an `otherwise`: publishing REPLACES, so a flow that
252
+ * transcribes AND calls a model cannot install two fakes.
253
+ *
254
+ * The three transcription legs are the SDK's fake rather than this file's
255
+ * hand-typed wire — it routes them off the SDK's own endpoint constants, so a
256
+ * spec cannot pass because the fake and the step agree on a typo.
257
+ */
258
+ function stubProvider(reply: { headline: string; points: string[]; spoken: string }) {
259
+ return installStubTranscribe({
260
+ audioUrl: "https://cdn/aai/1",
261
+ jobIdPrefix: "t_",
262
+ text: "we ship tuesday and two bugs are left",
263
+ durationSec: 42,
264
+ otherwise: (request) =>
265
+ request.url.includes("llm-gateway")
266
+ ? { body: { choices: [{ message: { content: JSON.stringify(reply) } }] } }
267
+ : undefined,
268
+ });
269
+ }
270
+
271
+ test("transcribes, summarizes, speaks, and reports the file it made", async () => {
272
+ stubProvider({
273
+ headline: "Launch is on",
274
+ points: ["Ship Tuesday"],
275
+ spoken: "The launch is on for Tuesday.",
276
+ });
277
+ installStubReporter();
278
+ installStubSpeech();
279
+
280
+ const summary = await spokenSummaryFlow({ recording: UPLOAD_ID });
281
+
282
+ expect(summary).toEqual({
283
+ source: "standup.wav",
284
+ durationMs: 42_000,
285
+ words: 8,
286
+ headline: "Launch is on",
287
+ points: ["Ship Tuesday"],
288
+ spoken: "The launch is on for Tuesday.",
289
+ transcript: "we ship tuesday and two bugs are left",
290
+ // The output carries an ID, never the audio — the rule the whole
291
+ // template exists to demonstrate.
292
+ audio: "upl_stub_1",
293
+ audioDurationMs: 250,
294
+ });
295
+ });
296
+
297
+ test("the voice the form chose reaches the synthesizer", async () => {
298
+ stubProvider({ headline: "Launch is on", points: ["Ship Tuesday"], spoken: "Spoken." });
299
+ installStubReporter();
300
+ const speech = installStubSpeech();
301
+
302
+ await spokenSummaryFlow({ recording: UPLOAD_ID, voice: "michael" });
303
+
304
+ expect(speech.calls[0]).toMatchObject({ text: "Spoken.", voice: "michael" });
305
+ });
306
+
307
+ test("a recording the provider gave up on fails the run rather than half-summarizing", async () => {
308
+ installStubTranscribe({ jobError: "corrupt audio" });
309
+ installStubReporter();
310
+ installStubSpeech();
311
+
312
+ await expect(spokenSummaryFlow({ recording: UPLOAD_ID })).rejects.toThrow("corrupt audio");
313
+ });
314
+ });