@alexkroman1/aai-cli 6.11.0 → 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 +40 -19
  26. package/dist/scaffold/package.json +5 -3
  27. package/dist/scaffold/server.mjs +1 -1
  28. package/dist/{secret-BuMuFR4B.mjs → secret-CVvSLIDV.mjs} +2 -2
  29. package/dist/{storage-DzRZ-eCw.mjs → storage-BvUrnvM3.mjs} +2 -2
  30. package/dist/{studio-BCNUpDgP.mjs → studio--MUV0cid.mjs} +4 -4
  31. package/dist/templates/call-audit/agent.test.ts +57 -204
  32. package/dist/templates/call-audit/agent.ts +32 -19
  33. package/dist/templates/call-audit/client.tsx +17 -54
  34. package/dist/templates/call-audit/workflows/audit.ts +11 -18
  35. package/dist/templates/call-audit/workflows/ingest.ts +106 -114
  36. package/dist/templates/call-audit/workflows/media.ts +2 -12
  37. package/dist/templates/call-audit/workflows/summarize.ts +47 -52
  38. package/dist/templates/call-audit/workflows/sync-api.ts +7 -7
  39. package/dist/templates/dispatch-center/agent.test.ts +149 -25
  40. package/dist/templates/dispatch-center/client.tsx +239 -129
  41. package/dist/templates/dispatch-center/shared.ts +99 -1
  42. package/dist/templates/dispatch-center/system-prompt.md +3 -1
  43. package/dist/templates/dispatch-center/tools/incident_add_note.ts +16 -13
  44. package/dist/templates/dispatch-center/tools/incident_create.ts +19 -1
  45. package/dist/templates/dispatch-center/tools/incident_escalate.ts +68 -59
  46. package/dist/templates/dispatch-center/tools/incident_triage.ts +57 -43
  47. package/dist/templates/dispatch-center/tools/incident_update_status.ts +49 -40
  48. package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +8 -2
  49. package/dist/templates/dispatch-center/tools/resources_dispatch.ts +69 -52
  50. package/dist/templates/dispatch-center/tools/resources_update_status.ts +45 -41
  51. package/dist/templates/embedded-assets/agent.test.ts +17 -14
  52. package/dist/templates/health-assistant/agent.test.ts +22 -4
  53. package/dist/templates/infocom-adventure/agent.test.ts +4 -6
  54. package/dist/templates/infocom-adventure/client.tsx +246 -164
  55. package/dist/templates/link-digest/agent.test.ts +24 -19
  56. package/dist/templates/link-digest/client.tsx +47 -61
  57. package/dist/templates/link-digest/workflows/digest.ts +19 -31
  58. package/dist/templates/night-owl/agent.test.ts +70 -19
  59. package/dist/templates/night-owl/agent.ts +5 -0
  60. package/dist/templates/night-owl/client.tsx +56 -56
  61. package/dist/templates/night-owl/shared.ts +24 -0
  62. package/dist/templates/night-owl/tools/recommend.ts +18 -6
  63. package/dist/templates/pizza-ordering/agent.test.ts +27 -27
  64. package/dist/templates/pizza-ordering/client.tsx +9 -26
  65. package/dist/templates/plan-and-execute/agent.test.ts +99 -58
  66. package/dist/templates/plan-and-execute/agent.ts +1 -1
  67. package/dist/templates/plan-and-execute/client.tsx +12 -15
  68. package/dist/templates/plan-and-execute/shared.ts +71 -2
  69. package/dist/templates/plan-and-execute/tools/plan_status.ts +18 -6
  70. package/dist/templates/plan-and-execute/tools/revise_plan.ts +18 -10
  71. package/dist/templates/plan-and-execute/tools/start_plan.ts +13 -2
  72. package/dist/templates/plan-and-execute/tools/work_next_step.ts +70 -22
  73. package/dist/templates/podcast-digest/agent.test.ts +746 -0
  74. package/dist/templates/podcast-digest/agent.ts +139 -0
  75. package/dist/templates/podcast-digest/client.tsx +154 -0
  76. package/dist/templates/podcast-digest/workflows/digest.ts +411 -0
  77. package/dist/templates/podcast-digest/workflows/feeds.ts +507 -0
  78. package/dist/templates/podcast-digest/workflows/slack.ts +209 -0
  79. package/dist/templates/recap-workflow/agent.test.ts +97 -94
  80. package/dist/templates/recap-workflow/tools/recap_progress.ts +9 -12
  81. package/dist/templates/recap-workflow/tools/recap_status.ts +8 -9
  82. package/dist/templates/recap-workflow/tools/request_recap.ts +2 -1
  83. package/dist/templates/recap-workflow/workflows/recap.ts +36 -40
  84. package/dist/templates/redline/agent.test.ts +17 -15
  85. package/dist/templates/redline/client.tsx +12 -12
  86. package/dist/templates/redline/workflows/redline.ts +19 -31
  87. package/dist/templates/research-workflow/agent.test.ts +60 -59
  88. package/dist/templates/research-workflow/tools/research_progress.ts +9 -12
  89. package/dist/templates/research-workflow/tools/research_status.ts +9 -11
  90. package/dist/templates/research-workflow/workflows/research.ts +44 -61
  91. package/dist/templates/retail/agent.test.ts +26 -23
  92. package/dist/templates/retail/client.tsx +226 -117
  93. package/dist/templates/retail/registry.test.ts +38 -6
  94. package/dist/templates/retail/store.test.ts +82 -15
  95. package/dist/templates/retail/store.ts +174 -47
  96. package/dist/templates/retail/system-prompt.md +11 -2
  97. package/dist/templates/retail/tools/cancel_pending_order.ts +2 -2
  98. package/dist/templates/retail/tools/exchange_delivered_order_items.ts +2 -2
  99. package/dist/templates/retail/tools/find_user_id_by_email.ts +8 -5
  100. package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +4 -5
  101. package/dist/templates/retail/tools/get_item_details.ts +3 -4
  102. package/dist/templates/retail/tools/get_order_details.ts +3 -4
  103. package/dist/templates/retail/tools/get_product_details.ts +3 -4
  104. package/dist/templates/retail/tools/get_user_details.ts +2 -2
  105. package/dist/templates/retail/tools/list_all_product_types.ts +5 -5
  106. package/dist/templates/retail/tools/modify_pending_order_address.ts +2 -2
  107. package/dist/templates/retail/tools/modify_pending_order_items.ts +2 -3
  108. package/dist/templates/retail/tools/modify_pending_order_payment.ts +2 -2
  109. package/dist/templates/retail/tools/modify_user_address.ts +2 -2
  110. package/dist/templates/retail/tools/return_delivered_order_items.ts +2 -2
  111. package/dist/templates/retail/tools/transfer_to_human_agents.ts +8 -4
  112. package/dist/templates/solo-rpg/agent.test.ts +227 -48
  113. package/dist/templates/solo-rpg/client.tsx +38 -37
  114. package/dist/templates/solo-rpg/shared.ts +145 -19
  115. package/dist/templates/solo-rpg/system-prompt.md +3 -2
  116. package/dist/templates/solo-rpg/tools/action_roll.ts +82 -63
  117. package/dist/templates/solo-rpg/tools/burn_momentum.ts +85 -50
  118. package/dist/templates/solo-rpg/tools/check_state.ts +24 -7
  119. package/dist/templates/solo-rpg/tools/load_game.ts +13 -1
  120. package/dist/templates/solo-rpg/tools/save_game.ts +16 -7
  121. package/dist/templates/solo-rpg/tools/setup_character.ts +22 -2
  122. package/dist/templates/solo-rpg/tools/update_state.ts +113 -100
  123. package/dist/templates/spoken-summary/agent.test.ts +68 -97
  124. package/dist/templates/spoken-summary/agent.ts +30 -17
  125. package/dist/templates/spoken-summary/client.tsx +10 -50
  126. package/dist/templates/spoken-summary/workflows/summarize.ts +17 -22
  127. package/dist/templates/spoken-summary/workflows/transcribe.ts +19 -26
  128. package/dist/templates/support-line/agent.test.ts +11 -16
  129. package/dist/templates/support-line/agent.ts +1 -1
  130. package/dist/templates/support-line/client.tsx +9 -9
  131. package/dist/templates/support-line/nodes.ts +100 -0
  132. package/dist/templates/support-line/procedure.ts +407 -0
  133. package/dist/templates/support-line/tools/answer_question.ts +17 -4
  134. package/dist/templates/transcription-workflow/agent.test.ts +94 -194
  135. package/dist/templates/transcription-workflow/agent.ts +1 -1
  136. package/dist/templates/transcription-workflow/client.tsx +17 -42
  137. package/dist/templates/transcription-workflow/workflows/batch.ts +19 -22
  138. package/dist/templates/transcription-workflow/workflows/normalize.ts +90 -186
  139. package/dist/templates/transcription-workflow/workflows/stitch.ts +0 -11
  140. package/dist/templates/transcription-workflow/workflows/stream.ts +4 -5
  141. package/dist/templates/transcription-workflow/workflows/sync-api.ts +6 -6
  142. package/dist/templates/transcription-workflow/workflows/transcribe.ts +32 -26
  143. package/dist/templates/transcription-workflow/workflows/wav.ts +0 -31
  144. package/dist/templates/travel-concierge/agent.test.ts +64 -33
  145. package/dist/templates/travel-concierge/client.tsx +11 -23
  146. package/dist/templates/travel-concierge/routing.ts +34 -15
  147. package/dist/templates/travel-concierge/shared.ts +70 -3
  148. package/dist/templates/travel-concierge/tools/book_car_rental.ts +2 -2
  149. package/dist/templates/travel-concierge/tools/book_excursion.ts +2 -2
  150. package/dist/templates/travel-concierge/tools/book_hotel.ts +2 -2
  151. package/dist/templates/travel-concierge/tools/cancel_ticket.ts +2 -2
  152. package/dist/templates/travel-concierge/tools/update_ticket.ts +2 -2
  153. package/dist/{worker-bundler-CGD4r8Kc.mjs → worker-bundler-COxnqstQ.mjs} +221 -3
  154. package/dist/worker-bundler.mjs +1 -1
  155. package/dist/{workflow-CFpxOFfQ.mjs → workflow-D2AQf2Pl.mjs} +28 -1
  156. package/dist/workflow-bundler.d.ts +6 -1
  157. package/dist/workflow.d.ts +1 -1
  158. package/package.json +5 -4
  159. package/dist/templates/call-audit/workflows/temp-media.ts +0 -138
  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
  });
@@ -17,32 +17,31 @@
17
17
  * its return value, and audio in one is megabytes replayed on every resume.
18
18
  */
19
19
 
20
- import { stubReporter, stubSpeech, stubStepFetch, stubUploads } from "@alexkroman1/aai/testing";
21
- import { installStubGateway } from "@alexkroman1/aai/testing/vitest";
22
- import { readUpload, uploadInfo } from "@alexkroman1/aai/utils";
23
- import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
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";
24
29
  import { FatalError, RetryableError } from "workflow";
25
30
  import agentDef, { spokenSummary } from "./agent.ts";
26
31
  import { speak, spokenSummaryFlow, summarize } from "./workflows/summarize.ts";
27
- import { countWords, createJob, pollTranscript, uploadToProvider } from "./workflows/transcribe.ts";
32
+ import { createJob, pollTranscript, uploadToProvider } from "./workflows/transcribe.ts";
28
33
 
29
34
  /** The id every spec below uploads under. */
30
35
  const UPLOAD_ID = "upl_test";
31
36
 
32
- /** Slots left published reach the next file, so every one is released here. */
33
- const restores: (() => void)[] = [];
34
- afterEach(() => {
35
- while (restores.length > 0) restores.pop()?.();
36
- });
37
-
38
37
  beforeEach(() => {
39
38
  // WRITABLE, because this app's whole second half stores a file — and it is
40
39
  // opt-in precisely so a step that wrote one nobody meant it to would fail.
41
- restores.push(
42
- stubUploads(
43
- { [UPLOAD_ID]: { bytes: new Uint8Array(64), name: "standup.wav", type: "audio/wav" } },
44
- { writable: true },
45
- ),
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 },
46
45
  );
47
46
  // The step env, which is where `requireStepEnv` and `stepSpeak` read the key.
48
47
  // `vi.stubEnv` rather than an assignment: `unstubEnvs` undoes it before every
@@ -84,40 +83,38 @@ describe("the declaration", () => {
84
83
 
85
84
  describe("transcribing", () => {
86
85
  test("streams the stored recording to the provider and keeps the URL it answered", async () => {
87
- const fetches = stubStepFetch(() => ({ body: { upload_url: "https://cdn/aai/1" } }));
88
- restores.push(fetches.restore, stubReporter().restore);
86
+ const provider = installStubTranscribe({ audioUrl: "https://cdn/aai/1" });
87
+ installStubReporter();
89
88
 
90
89
  await expect(uploadToProvider(UPLOAD_ID)).resolves.toEqual({
91
90
  audioUrl: "https://cdn/aai/1",
92
91
  });
93
- expect(fetches.calls[0]?.url).toBe("https://api.assemblyai.com/v2/upload");
94
- expect(fetches.calls[0]?.headers.Authorization).toBe("test-key");
92
+ expect(provider.calls[0]?.leg).toBe("upload");
95
93
  // The bytes really went, and they went as the file rather than as JSON.
96
- expect(fetches.calls[0]?.body?.length).toBe(64);
94
+ expect(provider.calls[0]?.body?.length).toBe(64);
97
95
  });
98
96
 
99
97
  test("a 429 from the provider is RETRYABLE and a 400 is not", async () => {
100
- const first = stubStepFetch(() => ({ status: 429, body: { error: "slow down" } }));
101
- restores.push(first.restore, stubReporter().restore);
98
+ const first = installStubTranscribe({ failure: { status: 429, message: "slow down" } });
99
+ installStubReporter();
102
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
103
  first.restore();
104
104
 
105
- const second = stubStepFetch(() => ({ status: 400, body: { error: "bad model" } }));
106
- restores.push(second.restore);
105
+ installStubTranscribe({ failure: { status: 400, message: "bad model" } });
107
106
  await expect(createJob("https://cdn/aai/1")).rejects.toBeInstanceOf(FatalError);
108
107
  });
109
108
 
110
109
  test("a job the provider gave up on is FATAL — no number of polls changes it", async () => {
111
- restores.push(
112
- stubStepFetch(() => ({ body: { status: "error", error: "corrupt audio" } })).restore,
113
- );
110
+ installStubTranscribe({ jobError: "corrupt audio" });
114
111
 
115
112
  await expect(pollTranscript(UPLOAD_ID, "t_1")).rejects.toThrow("corrupt audio");
116
113
  await expect(pollTranscript(UPLOAD_ID, "t_1")).rejects.toBeInstanceOf(FatalError);
117
114
  });
118
115
 
119
116
  test("`done` is decided here, so the body never reads a provider's vocabulary", async () => {
120
- restores.push(stubStepFetch(() => ({ body: { status: "queued" } })).restore);
117
+ installStubTranscribe({ pendingPolls: 1 });
121
118
 
122
119
  await expect(pollTranscript(UPLOAD_ID, "t_1")).resolves.toEqual({ done: false });
123
120
  });
@@ -125,26 +122,21 @@ describe("transcribing", () => {
125
122
  test("a finished poll carries the transcript, named by the FILENAME", async () => {
126
123
  // ONE request, not two: this used to poll for a status and then fetch the
127
124
  // identical URL again for the text the poll already had in its hand.
128
- const fetches = stubStepFetch(() => ({
129
- body: { status: "completed", text: " we shipped it ", audio_duration: 12.4 },
130
- }));
131
- restores.push(fetches.restore, stubReporter().restore);
125
+ const provider = installStubTranscribe({ text: " we shipped it ", durationSec: 12.4 });
126
+ installStubReporter();
132
127
 
133
128
  await expect(pollTranscript(UPLOAD_ID, "t_1")).resolves.toEqual({
134
129
  done: true,
135
130
  transcript: { source: "standup.wav", durationMs: 12_400, text: "we shipped it" },
136
131
  });
137
- expect(fetches.calls).toHaveLength(1);
132
+ expect(provider.calls).toHaveLength(1);
138
133
  });
139
134
 
140
135
  test("a recording of silence is FATAL rather than an empty summary", async () => {
141
136
  // The failure this template is most likely to meet: silence transcribes
142
137
  // successfully to nothing, and everything downstream would then be asked to
143
138
  // summarize and speak no words at all.
144
- restores.push(
145
- stubStepFetch(() => ({ body: { status: "completed", text: " ", audio_duration: 3 } }))
146
- .restore,
147
- );
139
+ installStubTranscribe({ text: " ", durationSec: 3 });
148
140
 
149
141
  await expect(pollTranscript(UPLOAD_ID, "t_1")).rejects.toThrow("no speech in that recording");
150
142
  await expect(pollTranscript(UPLOAD_ID, "t_1")).rejects.toBeInstanceOf(FatalError);
@@ -160,7 +152,7 @@ describe("summarizing", () => {
160
152
  spoken: "The launch is on for Tuesday, with two bugs still open.",
161
153
  }),
162
154
  );
163
- restores.push(stubReporter().restore);
155
+ installStubReporter();
164
156
 
165
157
  const summary = await summarize("we ship tuesday");
166
158
 
@@ -181,14 +173,14 @@ describe("summarizing", () => {
181
173
  spoken: "Several things happened.",
182
174
  }),
183
175
  );
184
- restores.push(stubReporter().restore);
176
+ installStubReporter();
185
177
 
186
178
  expect((await summarize("…")).points).toHaveLength(4);
187
179
  });
188
180
 
189
181
  test("a reply with no spoken script FAILS rather than defaulting to silence", async () => {
190
182
  installStubGateway(JSON.stringify({ headline: "Launch is on", points: ["Ship Tuesday"] }));
191
- restores.push(stubReporter().restore);
183
+ installStubReporter();
192
184
 
193
185
  await expect(summarize("…")).rejects.toThrow(/did not match the shape/);
194
186
  });
@@ -196,8 +188,8 @@ describe("summarizing", () => {
196
188
 
197
189
  describe("speaking", () => {
198
190
  test("stores a WAV and returns its ID — never the bytes", async () => {
199
- const speech = stubSpeech({ pcmBytes: 48_000 });
200
- restores.push(speech.restore, stubReporter().restore);
191
+ const speech = installStubSpeech({ pcmBytes: 48_000 });
192
+ installStubReporter();
201
193
 
202
194
  const spoken = await speak("The launch is on for Tuesday.");
203
195
 
@@ -208,7 +200,8 @@ describe("speaking", () => {
208
200
  });
209
201
 
210
202
  test("what it stored is a real WAV, named and typed for the browser", async () => {
211
- restores.push(stubSpeech({ pcmBytes: 4000 }).restore, stubReporter().restore);
203
+ installStubSpeech({ pcmBytes: 4000 });
204
+ installStubReporter();
212
205
 
213
206
  const { audio } = await speak("Hello.");
214
207
 
@@ -225,8 +218,8 @@ describe("speaking", () => {
225
218
  });
226
219
 
227
220
  test("passes a chosen voice through, and omits it entirely when none was chosen", async () => {
228
- const speech = stubSpeech();
229
- restores.push(speech.restore, stubReporter().restore);
221
+ const speech = installStubSpeech();
222
+ installStubReporter();
230
223
 
231
224
  await speak("Hello.", "michael");
232
225
  await speak("Hello.");
@@ -237,13 +230,6 @@ describe("speaking", () => {
237
230
  });
238
231
  });
239
232
 
240
- describe("countWords", () => {
241
- test("counts words rather than characters, and answers 0 for nothing", () => {
242
- expect(countWords("we shipped it on tuesday")).toBe(5);
243
- expect(countWords(" ")).toBe(0);
244
- });
245
- });
246
-
247
233
  describe("the whole run", () => {
248
234
  /**
249
235
  * Answer every leg's HTTP, so the BODY can be driven end to end.
@@ -261,38 +247,35 @@ describe("the whole run", () => {
261
247
  * **The model call goes through this too, not `installStubGateway`.** A
262
248
  * published `stepFetch` is what `stepGenerate` makes its request with, so a
263
249
  * global-fetch stub is never reached once one exists — which is exactly the
264
- * point `stubStepFetch` exists to make.
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.
265
257
  */
266
258
  function stubProvider(reply: { headline: string; points: string[]; spoken: string }) {
267
- return stubStepFetch((request) => {
268
- if (request.url.includes("llm-gateway")) {
269
- return { body: { choices: [{ message: { content: JSON.stringify(reply) } }] } };
270
- }
271
- if (request.url.endsWith("/v2/upload")) return { body: { upload_url: "https://cdn/aai/1" } };
272
- if (request.method === "POST") return { body: { id: "t_1" } };
273
- // One GET, carrying both the status and the text. It used to take two —
274
- // a poll that read a status and threw the transcript away, then a fetch
275
- // of the identical URL for the text it had just discarded.
276
- return {
277
- body: {
278
- status: "completed",
279
- text: "we ship tuesday and two bugs are left",
280
- audio_duration: 42,
281
- },
282
- };
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,
283
268
  });
284
269
  }
285
270
 
286
271
  test("transcribes, summarizes, speaks, and reports the file it made", async () => {
287
- restores.push(
288
- stubProvider({
289
- headline: "Launch is on",
290
- points: ["Ship Tuesday"],
291
- spoken: "The launch is on for Tuesday.",
292
- }).restore,
293
- stubReporter().restore,
294
- stubSpeech().restore,
295
- );
272
+ stubProvider({
273
+ headline: "Launch is on",
274
+ points: ["Ship Tuesday"],
275
+ spoken: "The launch is on for Tuesday.",
276
+ });
277
+ installStubReporter();
278
+ installStubSpeech();
296
279
 
297
280
  const summary = await spokenSummaryFlow({ recording: UPLOAD_ID });
298
281
 
@@ -312,13 +295,9 @@ describe("the whole run", () => {
312
295
  });
313
296
 
314
297
  test("the voice the form chose reaches the synthesizer", async () => {
315
- const speech = stubSpeech();
316
- restores.push(
317
- stubProvider({ headline: "Launch is on", points: ["Ship Tuesday"], spoken: "Spoken." })
318
- .restore,
319
- stubReporter().restore,
320
- speech.restore,
321
- );
298
+ stubProvider({ headline: "Launch is on", points: ["Ship Tuesday"], spoken: "Spoken." });
299
+ installStubReporter();
300
+ const speech = installStubSpeech();
322
301
 
323
302
  await spokenSummaryFlow({ recording: UPLOAD_ID, voice: "michael" });
324
303
 
@@ -326,17 +305,9 @@ describe("the whole run", () => {
326
305
  });
327
306
 
328
307
  test("a recording the provider gave up on fails the run rather than half-summarizing", async () => {
329
- restores.push(
330
- stubStepFetch((request) =>
331
- request.url.endsWith("/v2/upload")
332
- ? { body: { upload_url: "https://cdn/aai/1" } }
333
- : request.method === "POST"
334
- ? { body: { id: "t_1" } }
335
- : { body: { status: "error", error: "corrupt audio" } },
336
- ).restore,
337
- stubReporter().restore,
338
- stubSpeech().restore,
339
- );
308
+ installStubTranscribe({ jobError: "corrupt audio" });
309
+ installStubReporter();
310
+ installStubSpeech();
340
311
 
341
312
  await expect(spokenSummaryFlow({ recording: UPLOAD_ID })).rejects.toThrow("corrupt audio");
342
313
  });