@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,15 +1,19 @@
1
- import type { Db, ToolContext } from "@alexkroman1/aai";
2
- import { createToolContext } from "@alexkroman1/aai/testing";
1
+ import type { Db, ToolContext, ToolDef, ToolInputSchema } from "@alexkroman1/aai";
2
+ import { isToolFailure } from "@alexkroman1/aai";
3
+ import { createToolContext, ok } from "@alexkroman1/aai/testing";
3
4
  import { describe, expect, test, vi } from "vitest";
4
5
  import {
5
6
  applyConsequences,
6
7
  DEFAULT_STATE,
7
8
  type GameState,
8
9
  gameSlot,
10
+ inCrisis,
11
+ isGameOver,
9
12
  MAX_NPCS,
10
13
  MIN_MOMENTUM,
11
14
  makeNpc,
12
15
  rollAction,
16
+ storyFlow,
13
17
  } from "./shared.ts";
14
18
  import actionRoll from "./tools/action_roll.ts";
15
19
  import burnMomentum from "./tools/burn_momentum.ts";
@@ -76,7 +80,6 @@ const SETUP_ARGS = {
76
80
  function playingState(): GameState {
77
81
  const state = structuredClone(DEFAULT_STATE);
78
82
  state.initialized = true;
79
- state.phase = "playing";
80
83
  state.sceneCount = 3;
81
84
  state.npcs.push(makeNpc({ id: "npc_1", name: "Mira", disposition: "neutral" }));
82
85
  state.clocks.push({
@@ -90,6 +93,39 @@ function playingState(): GameState {
90
93
  return state;
91
94
  }
92
95
 
96
+ /**
97
+ * Seed a context with a mid-game campaign AND the matching flow position.
98
+ *
99
+ * Writing the slot alone is no longer enough: `action_roll`, `update_state`,
100
+ * `burn_momentum` and `save_game` gate on `storyFlow`, so a campaign installed
101
+ * behind the machine's back leaves every one of them refusing. That is the point
102
+ * of the gate, and it is what this helper exists to satisfy honestly — through
103
+ * the flow's own event, not by writing its snapshot.
104
+ */
105
+ function seedPlaying(ctx: ToolContext, state: GameState = playingState()): GameState {
106
+ gameSlot.set(ctx, state);
107
+ storyFlow.send(ctx, { type: "SETUP" });
108
+ return state;
109
+ }
110
+
111
+ /**
112
+ * Call a tool that declares no `inputSchema`.
113
+ *
114
+ * `ToolDef["execute"]`'s first parameter is derived from the schema, so a tool
115
+ * with none types it as a shape no object literal satisfies — and nine call
116
+ * sites here had each cast their way past it. This is the one narrowing they all
117
+ * wanted: `check_state` and `burn_momentum` really do take no arguments, and a
118
+ * helper says that once where a cast per call site said nothing.
119
+ *
120
+ * It is generic in `R` because the three tool builders thread their result type
121
+ * out now, so the ARGUMENT is the only thing here that needs laundering — where
122
+ * every call site used to follow this one with a second cast of its own to
123
+ * recover a return type the erasure had thrown away.
124
+ */
125
+ function callNoArgs<R>(def: ToolDef<ToolInputSchema, R>, ctx: ToolContext): Promise<Awaited<R>> {
126
+ return Promise.resolve((def.execute as (args: unknown, c: ToolContext) => R)({}, ctx));
127
+ }
128
+
93
129
  // ── setup_character ──────────────────────────────────────────────────────────
94
130
 
95
131
  describe("setup_character", () => {
@@ -147,9 +183,7 @@ describe("setup_character", () => {
147
183
  // session ids would prove nothing extra, and `sessionSlot` could stop
148
184
  // keying by session with this still passing.
149
185
  await setupCharacter.execute(SETUP_ARGS, makeCtx());
150
- const other = (await checkState.execute({} as never, makeCtx())) as {
151
- initialized: boolean;
152
- };
186
+ const other = await callNoArgs(checkState, makeCtx());
153
187
  expect(other.initialized).toBe(false);
154
188
  });
155
189
  });
@@ -200,15 +234,20 @@ describe("applyConsequences MISS matrix", () => {
200
234
  expect(state.spirit).toBe(4);
201
235
  });
202
236
 
203
- test("filling the threat clock emits its trigger event and crisis flags fire at 0 health", () => {
237
+ test("filling the threat clock emits its trigger event, and 0 health is a crisis", () => {
204
238
  const state = playingState();
205
239
  state.clocks[0]!.filled = 3; // one tick from full
206
240
  state.health = 2;
207
241
  const { clockEvents } = applyConsequences(state, miss, "risky", "standard", null);
208
242
  expect(clockEvents).toEqual([{ clock: "Doom", trigger: "The doom arrives" }]);
209
243
  expect(state.health).toBe(0);
210
- expect(state.crisisMode).toBe(true);
211
- expect(state.gameOver).toBe(false);
244
+ // The two flags are DERIVED now, not written here: `applyConsequences` used
245
+ // to end with `updateCrisisFlags(game)`, one of three hand-placed calls
246
+ // that are `gameSlot`'s `after` hook instead. So this asserts the rule
247
+ // rather than the write — the flags on a stored game are checked through
248
+ // the tools below, which is where a stale one would actually bite.
249
+ expect(inCrisis(state)).toBe(true);
250
+ expect(isGameOver(state)).toBe(false);
212
251
  });
213
252
 
214
253
  test("strong hit with great effect gains +3 momentum and shifts disposition on compel", () => {
@@ -264,14 +303,19 @@ describe("burn_momentum", () => {
264
303
  clockTicks: 1,
265
304
  },
266
305
  };
267
- gameSlot.set(ctx, state);
306
+ seedPlaying(ctx, state);
307
+ // The campaign records a roll, so the FLOW has to say one is standing —
308
+ // `playing.rollResolved` is the state `burn_momentum` gates on. Sent as an
309
+ // event rather than written into the snapshot, so the seed goes through the
310
+ // same door `action_roll` does.
311
+ storyFlow.send(ctx, { type: "ROLLED" });
268
312
  }
269
313
 
270
314
  test("a legal burn reverts the miss's consequences, upgrades, and resets momentum", async () => {
271
315
  const ctx = makeCtx();
272
316
  seedRolledState(8, ctx); // 8 beats both dice (3, 5)
273
317
 
274
- const result = (await burnMomentum.execute({} as never, ctx)) as Record<string, unknown>;
318
+ const result = ok<Record<string, unknown>>(await callNoArgs(burnMomentum, ctx));
275
319
  expect(result.burned).toBe(true);
276
320
  expect(result.newResultCode).toBe("STRONG_HIT");
277
321
 
@@ -285,34 +329,42 @@ describe("burn_momentum", () => {
285
329
  test("momentum beating only one die upgrades a MISS to WEAK_HIT", async () => {
286
330
  const ctx = makeCtx();
287
331
  seedRolledState(4, ctx); // beats 3, not 5
288
- const result = (await burnMomentum.execute({} as never, ctx)) as Record<string, unknown>;
332
+ const result = ok<{ newResultCode: string }>(await callNoArgs(burnMomentum, ctx));
289
333
  expect(result.newResultCode).toBe("WEAK_HIT");
290
334
  });
291
335
 
292
- test("burn is refused with no stored roll, insufficient momentum, or a strong hit", async () => {
336
+ test("burn is refused with no roll standing, insufficient momentum, or a strong hit", async () => {
293
337
  const ctx = makeCtx();
294
338
 
295
- // No roll yet
296
- let result = (await burnMomentum.execute({} as never, ctx)) as Record<string, unknown>;
297
- expect(result.error).toMatch(/No recent action roll/);
298
-
299
- // Momentum too low to beat either die
339
+ // No roll yet — and this refusal is now the FLOW's rather than a null check
340
+ // inside the body: nothing has rolled, so the game is in
341
+ // `playing.awaitingRoll` and this tool is not available there. The message
342
+ // names the position and quotes what that state expects.
343
+ seedPlaying(ctx);
344
+ // The flow's refusal, so it arrives as a `ToolFailure` rather than under
345
+ // the position envelope — which the narrowing now says out loud.
346
+ let result = await callNoArgs(burnMomentum, ctx);
347
+ expect(isToolFailure(result) && result.error).toMatch(/awaitingRoll/);
348
+ expect(isToolFailure(result) && result.error).toMatch(/action_roll/);
349
+
350
+ // Momentum too low to beat either die — a DATA rule, so it stays in the
351
+ // body and the tool still runs.
300
352
  seedRolledState(2, ctx);
301
- result = (await burnMomentum.execute({} as never, ctx)) as Record<string, unknown>;
302
- expect(result.error).toMatch(/not high enough/);
353
+ result = await callNoArgs(burnMomentum, ctx);
354
+ expect(isToolFailure(result) && result.error).toMatch(/not high enough/);
303
355
 
304
356
  // Strong hits cannot be upgraded
305
357
  seedRolledState(8, ctx);
306
358
  gameSlot.update(ctx, (state) => {
307
359
  state.lastRoll!.result = "STRONG_HIT";
308
360
  });
309
- result = (await burnMomentum.execute({} as never, ctx)) as Record<string, unknown>;
310
- expect(result.error).toMatch(/already a Strong Hit/);
361
+ result = await callNoArgs(burnMomentum, ctx);
362
+ expect(isToolFailure(result) && result.error).toMatch(/already a Strong Hit/);
311
363
  });
312
364
 
313
365
  test("action_roll persists the roll so burn needs no dice arguments", async () => {
314
366
  const ctx = makeCtx();
315
- gameSlot.set(ctx, playingState());
367
+ seedPlaying(ctx);
316
368
  await actionRoll.execute(
317
369
  { move: "clash", stat: "iron", position: "risky", effect: "standard", purpose: "attack" },
318
370
  ctx,
@@ -385,7 +437,7 @@ describe("oracle", () => {
385
437
  const ctx = makeCtx();
386
438
  const state = playingState();
387
439
  state.chaosFactor = 9; // threshold 6 — a roll of 1 lands
388
- gameSlot.set(ctx, state);
440
+ seedPlaying(ctx, state);
389
441
  mockRoll(1, 10);
390
442
 
391
443
  const result = (await oracle.execute({ type: "chaos_check" }, ctx)) as {
@@ -405,7 +457,7 @@ describe("oracle", () => {
405
457
  const ctx = makeCtx();
406
458
  const state = playingState();
407
459
  state.chaosFactor = 3; // threshold 0 — `checkChaosInterrupt` returns early
408
- gameSlot.set(ctx, state);
460
+ seedPlaying(ctx, state);
409
461
 
410
462
  const result = (await oracle.execute({ type: "chaos_check" }, ctx)) as {
411
463
  interrupted: boolean;
@@ -419,7 +471,7 @@ describe("oracle", () => {
419
471
  const ctx = makeCtx();
420
472
  const state = playingState();
421
473
  state.chaosFactor = 5; // threshold 2
422
- gameSlot.set(ctx, state);
474
+ seedPlaying(ctx, state);
423
475
  mockRoll(10, 10); // past the threshold
424
476
 
425
477
  const result = (await oracle.execute({ type: "chaos_check" }, ctx)) as {
@@ -460,7 +512,7 @@ describe("oracle", () => {
460
512
 
461
513
  test("the four inspiration branches answer without touching the game", async () => {
462
514
  const ctx = makeCtx();
463
- gameSlot.set(ctx, playingState());
515
+ seedPlaying(ctx);
464
516
  const before = structuredClone(gameSlot.get(ctx));
465
517
 
466
518
  const reaction = (await oracle.execute({ type: "npc_reaction" }, ctx)) as { reaction: string };
@@ -485,7 +537,7 @@ describe("oracle", () => {
485
537
  describe("update_state", () => {
486
538
  test("clock ids never collide after a removal (max-scan, not length+1)", async () => {
487
539
  const ctx = makeCtx();
488
- gameSlot.set(ctx, playingState()); // has clock_1
540
+ seedPlaying(ctx); // has clock_1
489
541
 
490
542
  await updateState.execute({ addClockName: "Second" }, ctx); // clock_2
491
543
  await updateState.execute({ removeClockName: "Doom" }, ctx); // removes clock_1
@@ -501,11 +553,11 @@ describe("update_state", () => {
501
553
  const ctx = makeCtx();
502
554
  const state = playingState();
503
555
  state.clocks[0]!.filled = 3; // 3 of 4
504
- gameSlot.set(ctx, state);
556
+ seedPlaying(ctx, state);
505
557
 
506
- const result = (await updateState.execute({ advanceClockName: "Doom" }, ctx)) as {
507
- clockEvents: { clock: string; trigger: string }[];
508
- };
558
+ const result = ok<{ clockEvents: { clock: string; trigger: string }[] }>(
559
+ await updateState.execute({ advanceClockName: "Doom" }, ctx),
560
+ );
509
561
  expect(result.clockEvents).toEqual([{ clock: "Doom", trigger: "The doom arrives" }]);
510
562
  });
511
563
 
@@ -515,11 +567,11 @@ describe("update_state", () => {
515
567
  while (state.npcs.length < MAX_NPCS) {
516
568
  state.npcs.push(makeNpc({ id: `npc_${state.npcs.length + 1}`, name: "Extra" }));
517
569
  }
518
- gameSlot.set(ctx, state);
570
+ seedPlaying(ctx, state);
519
571
 
520
- const result = (await updateState.execute({ addNpcName: "One Too Many" }, ctx)) as {
521
- warnings?: string[];
522
- };
572
+ const result = ok<{ warnings?: string[] }>(
573
+ await updateState.execute({ addNpcName: "One Too Many" }, ctx),
574
+ );
523
575
  expect(result.warnings?.[0]).toMatch(/NPC limit/);
524
576
  const after = gameSlot.get(ctx);
525
577
  expect(after.npcs).toHaveLength(MAX_NPCS);
@@ -555,21 +607,21 @@ describe("save_game / load_game", () => {
555
607
  const played = playingState();
556
608
  played.playerName = "Kael";
557
609
  played.sceneCount = 7;
558
- gameSlot.set(sessionA, played);
559
- const saved = (await saveGame.execute({ slot: "chapter-2" }, sessionA)) as Record<
560
- string,
561
- unknown
562
- >;
610
+ seedPlaying(sessionA, played);
611
+ const saved = ok<{ saved: boolean; slot: string }>(
612
+ await saveGame.execute({ slot: "chapter-2" }, sessionA),
613
+ );
563
614
  expect(saved.saved).toBe(true);
564
615
  expect(saved.slot).toBe("chapter-2");
565
616
  expect(rows.get("save:chapter-2")).toMatchObject({ playerName: "Kael", sceneCount: 7 });
566
617
 
567
618
  // Session B (a fresh game slot, the same app db) resumes it.
568
619
  const sessionB = makeCtx(db);
569
- const loaded = (await loadGame.execute({ slot: "chapter-2" }, sessionB)) as Record<
570
- string,
571
- unknown
572
- >;
620
+ // `load_game` is a plain `tool()`, so its result is the union its body
621
+ // writes rather than a position envelope — the guard is what picks the
622
+ // arm, where the old `as Record<string, unknown>` picked neither.
623
+ const loaded = await loadGame.execute({ slot: "chapter-2" }, sessionB);
624
+ if (isToolFailure(loaded)) throw new Error(`load refused: ${loaded.error}`);
573
625
  expect(loaded.loaded).toBe(true);
574
626
  expect(loaded.playerName).toBe("Kael");
575
627
  expect(loaded.sceneCount).toBe(7);
@@ -578,14 +630,14 @@ describe("save_game / load_game", () => {
578
630
 
579
631
  test("loading a missing slot reports an error instead of resetting the game", async () => {
580
632
  const ctx = makeCtx();
581
- const result = (await loadGame.execute({ slot: "nope" }, ctx)) as { error?: string };
582
- expect(result.error).toMatch(/No save found/);
633
+ const result = await loadGame.execute({ slot: "nope" }, ctx);
634
+ expect(isToolFailure(result) && result.error).toMatch(/No save found/);
583
635
  });
584
636
 
585
637
  test("saving twice to one slot upserts — the newer save wins", async () => {
586
638
  const { db, rows } = makeDb();
587
639
  const ctx = makeCtx(db);
588
- gameSlot.set(ctx, playingState());
640
+ seedPlaying(ctx);
589
641
  await saveGame.execute({}, ctx); // autosave
590
642
  gameSlot.update(ctx, (game) => {
591
643
  game.sceneCount = 9;
@@ -595,3 +647,130 @@ describe("save_game / load_game", () => {
595
647
  expect(rows.get("save:autosave")).toMatchObject({ sceneCount: 9 });
596
648
  });
597
649
  });
650
+
651
+ // ── the story flow ───────────────────────────────────────────────────────────
652
+
653
+ describe("the story flow", () => {
654
+ test("a fresh session is awaiting setup, and the play tools refuse there", async () => {
655
+ const ctx = makeCtx();
656
+ expect(storyFlow.position(ctx).state).toBe("awaitingSetup");
657
+
658
+ // All three of these used to RUN before a character existed: `action_roll`
659
+ // rolled 2d6 against the stats of nobody and applied consequences to a game
660
+ // that was not there, and `save_game` wrote an empty campaign to a slot
661
+ // `load_game` would later restore over a real one.
662
+ for (const call of [
663
+ actionRoll.execute(
664
+ { move: "clash", stat: "iron", position: "risky", effect: "standard", purpose: "swing" },
665
+ ctx,
666
+ ),
667
+ updateState.execute({ location: "Nowhere" }, ctx),
668
+ callNoArgs(burnMomentum, ctx),
669
+ saveGame.execute({}, ctx),
670
+ ]) {
671
+ const refusal = await call;
672
+ expect(isToolFailure(refusal)).toBe(true);
673
+ expect(isToolFailure(refusal) && refusal.error).toMatch(/awaitingSetup/);
674
+ expect(isToolFailure(refusal) && refusal.error).toMatch(/setup_character/);
675
+ }
676
+
677
+ // And nothing ran.
678
+ expect(gameSlot.get(ctx).initialized).toBe(false);
679
+ });
680
+
681
+ test("setup opens play, a roll leaves one standing, and settling closes the window", async () => {
682
+ const ctx = makeCtx();
683
+ // `state`/`instruction`, not `at`/`next`: the ungated tools spread the
684
+ // `DialogPosition` verbatim now, so they report their position under the
685
+ // same keys every gated tool's result carries — which is what the system
686
+ // prompt already claimed.
687
+ const created = await setupCharacter.execute(SETUP_ARGS, ctx);
688
+ expect(created.state).toBe("playing.awaitingRoll");
689
+ expect(created.instruction).toMatch(/action_roll/);
690
+
691
+ ok(
692
+ await actionRoll.execute(
693
+ { move: "clash", stat: "iron", position: "risky", effect: "standard", purpose: "swing" },
694
+ ctx,
695
+ ),
696
+ );
697
+ expect(storyFlow.position(ctx).state).toBe("playing.rollResolved");
698
+
699
+ // Moving the scene on SPENDS the roll: the burn window is closed.
700
+ ok(await updateState.execute({ location: "The Bridge" }, ctx));
701
+ expect(storyFlow.position(ctx).state).toBe("playing.awaitingRoll");
702
+ expect(isToolFailure(await callNoArgs(burnMomentum, ctx))).toBe(true);
703
+ });
704
+
705
+ test("check_state reports the position and is legal before setup", async () => {
706
+ const ctx = makeCtx();
707
+ const before = await callNoArgs(checkState, ctx);
708
+ expect(before.state).toBe("awaitingSetup");
709
+ expect(before.instruction).toMatch(/setup_character/);
710
+ expect(before.done).toBe(false);
711
+ expect(before.initialized).toBe(false);
712
+ });
713
+
714
+ test("a game over is terminal: nothing rolls, and setup starts a new story", async () => {
715
+ const ctx = makeCtx();
716
+ const dead = playingState();
717
+ dead.health = 0;
718
+ dead.spirit = 0;
719
+ seedPlaying(ctx, dead);
720
+
721
+ // `isGameOver` is what the result reports and the tool's `sendFrom` is what
722
+ // turns it into a position — the flag used to be read by nobody who could
723
+ // act on it, so a player could keep rolling after both tracks emptied. The
724
+ // WRITE is `gameSlot`'s `after` hook; this tool no longer calls it, which
725
+ // is the point of moving it there.
726
+ ok(await updateState.execute({ health: 0, spirit: 0 }, ctx));
727
+ const at = storyFlow.position(ctx);
728
+ expect(at.state).toBe("gameOver");
729
+ expect(at.done).toBe(true);
730
+
731
+ const refused = await actionRoll.execute(
732
+ { move: "clash", stat: "iron", position: "risky", effect: "standard", purpose: "swing" },
733
+ ctx,
734
+ );
735
+ expect(isToolFailure(refused)).toBe(true);
736
+ expect(isToolFailure(refused) && refused.error).toMatch(/gameOver/);
737
+
738
+ // Starting over is legal from anywhere, the ending included.
739
+ const restarted = await setupCharacter.execute(SETUP_ARGS, ctx);
740
+ expect(restarted.state).toBe("playing.awaitingRoll");
741
+ });
742
+
743
+ test("a loaded game resumes in play rather than awaiting setup", async () => {
744
+ const { db } = makeDb();
745
+ const sessionA = makeCtx(db);
746
+ seedPlaying(sessionA);
747
+ ok(await saveGame.execute({ slot: "resume" }, sessionA));
748
+
749
+ const sessionB = makeCtx(db);
750
+ expect(storyFlow.position(sessionB).state).toBe("awaitingSetup");
751
+ const loaded = await loadGame.execute({ slot: "resume" }, sessionB);
752
+ if (isToolFailure(loaded)) throw new Error(`load refused: ${loaded.error}`);
753
+ expect(loaded.state).toBe("playing.awaitingRoll");
754
+
755
+ // And the play tools are available in the resumed session.
756
+ ok(await updateState.execute({ location: "Back at the Docks" }, sessionB));
757
+ });
758
+
759
+ test("a save whose game was over resumes as over", async () => {
760
+ const { db } = makeDb();
761
+ const sessionA = makeCtx(db);
762
+ const dead = playingState();
763
+ dead.health = 0;
764
+ dead.spirit = 0;
765
+ dead.gameOver = true;
766
+ dead.crisisMode = true;
767
+ seedPlaying(sessionA, dead);
768
+ ok(await saveGame.execute({ slot: "ended" }, sessionA));
769
+
770
+ const sessionB = makeCtx(db);
771
+ const loaded = await loadGame.execute({ slot: "ended" }, sessionB);
772
+ if (isToolFailure(loaded)) throw new Error(`load refused: ${loaded.error}`);
773
+ expect(loaded.state).toBe("gameOver");
774
+ expect(storyFlow.position(sessionB).done).toBe(true);
775
+ });
776
+ });
@@ -1,11 +1,10 @@
1
1
  import "@alexkroman1/aai-ui/styles.css";
2
2
  import type { DeepReadonly } from "@alexkroman1/aai";
3
- import { ChatView, client, SidebarLayout, StartScreen, useAgentState } from "@alexkroman1/aai-ui";
3
+ import { client, useAgentState } from "@alexkroman1/aai-ui";
4
4
  import type { ReactNode } from "react";
5
5
  import {
6
6
  type Clock,
7
7
  type Disposition,
8
- type GameState,
9
8
  GENRES,
10
9
  gameProjection,
11
10
  MAX_RESOURCE,
@@ -444,7 +443,22 @@ function StoryArc({ story }: { story: DeepReadonly<StoryBlueprint> }) {
444
443
 
445
444
  // ── Sidebar ──────────────────────────────────────────────────────────────────
446
445
 
447
- function Sidebar({ game }: { game: DeepReadonly<GameState> }) {
446
+ /**
447
+ * The character sheet.
448
+ *
449
+ * It reads the projection ITSELF rather than taking it as a prop. That prop
450
+ * existed only because a 27-line `component:` wrapper above it was already
451
+ * calling `useAgentState` — and the wrapper existed only to say four things
452
+ * `ClientConfig` could not: `icon`, `subtitle`, `buttonText` and
453
+ * `sidebarPosition`. All four are config fields now, so the wrapper is gone and
454
+ * the subscription lives where the data is used.
455
+ *
456
+ * `gameProjection` carries both halves: the campaign's readonly type — which is
457
+ * what the four components below take — and the frame a session that has run no
458
+ * tool renders.
459
+ */
460
+ function Sidebar() {
461
+ const game = useAgentState(gameProjection);
448
462
  return (
449
463
  <div
450
464
  style={{
@@ -768,39 +782,19 @@ function Sidebar({ game }: { game: DeepReadonly<GameState> }) {
768
782
 
769
783
  // ── App ──────────────────────────────────────────────────────────────────────
770
784
 
771
- function SoloRPGApp() {
772
- // The agent's own session state, projected by `syncState` and pushed after
773
- // every tool call — no per-tool `ctx.send`, and nothing to keep in step
774
- // when a new tool starts mutating the game.
775
- // `gameProjection` carries both halves: the campaign's readonly type — which
776
- // is what the four components below take — and the frame a session that has
777
- // run no tool renders.
778
- const game = useAgentState(gameProjection);
779
-
780
- return (
781
- <StartScreen
782
- icon={
783
- <span style={{ fontSize: "28px", color: C.accent }}>
784
- {/* biome-ignore lint/style/useConsistentCurlyBraces: unicode escape */}
785
- {"\u2726"}
786
- </span>
787
- }
788
- title="Solo RPG"
789
- subtitle="A Narrative Solo-RPG Engine"
790
- buttonText="Begin Your Story"
791
- >
792
- <SidebarLayout sidebar={<Sidebar game={game} />} sidebarWidth="260px" sidebarPosition="right">
793
- <ChatView />
794
- </SidebarLayout>
795
- </StartScreen>
796
- );
797
- }
798
-
799
- const SESSION_KEY = "solo-rpg:sessionId";
800
- const savedSessionId = localStorage.getItem(SESSION_KEY);
801
-
802
785
  client({
803
- component: SoloRPGApp,
786
+ name: "Solo RPG",
787
+ icon: (
788
+ <span style={{ fontSize: "28px", color: C.accent }}>
789
+ {/* biome-ignore lint/style/useConsistentCurlyBraces: unicode escape */}
790
+ {"\u2726"}
791
+ </span>
792
+ ),
793
+ subtitle: "A Narrative Solo-RPG Engine",
794
+ buttonText: "Begin Your Story",
795
+ sidebar: Sidebar,
796
+ sidebarWidth: "260px",
797
+ sidebarPosition: "right",
804
798
  theme: {
805
799
  bg: C.bg,
806
800
  primary: C.accent,
@@ -808,6 +802,13 @@ client({
808
802
  surface: C.surface,
809
803
  border: C.border,
810
804
  },
811
- onSessionId: (id: string) => localStorage.setItem(SESSION_KEY, id),
812
- ...(savedSessionId ? { resumeSessionId: savedSessionId } : {}),
813
805
  });
806
+
807
+ // No `onSessionId`/`resumeSessionId` here any more, and their removal is a FIX
808
+ // rather than a simplification. `createSessionCore` remembers the id in
809
+ // `sessionStorage` by default now — see `session-resume-store.ts`, whose doc
810
+ // names this template as the one client of fourteen that had wired it by hand.
811
+ // What it had wired was `localStorage`, which is the wrong store for a pointer
812
+ // into a live call: it survives a new tab and a visit tomorrow, and presenting
813
+ // a day-old id suppresses the greeting and rejoins a conversation whose context
814
+ // is gone. This client was OVERRIDING the correct default with the wrong one.