@alexkroman1/aai-cli 6.11.0 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. package/README.md +1 -0
  2. package/dist/{_agent-DpH2pBJd.mjs → _agent-BTwZJu4P.mjs} +2 -2
  3. package/dist/{_bundler-C2BDwU7r.mjs → _bundler-CDuYl5Gb.mjs} +2 -2
  4. package/dist/_dev-env.d.ts +22 -2
  5. package/dist/{_dev-server-LLLWnFBs.mjs → _dev-server-Me06wgeF.mjs} +38 -47
  6. package/dist/_dev-server.d.ts +0 -17
  7. package/dist/{_init-C57bWAQ8.mjs → _init-BhEfQ6Yi.mjs} +3 -2
  8. package/dist/{_preflight-BtfaYtbE.mjs → _preflight-Co1G9Jww.mjs} +1 -1
  9. package/dist/_preflight.d.ts +1 -1
  10. package/dist/_resource-commands.d.ts +23 -0
  11. package/dist/{_slug-api-BxrsJXVI.mjs → _slug-api-z-USxPtL.mjs} +16 -12
  12. package/dist/_slug-api.d.ts +5 -2
  13. package/dist/{_studio-U3jhKlA3.mjs → _studio-DR5yzZ1T.mjs} +2 -1
  14. package/dist/{_templates-Dxb_P2Wz.mjs → _templates-4WcKOjS5.mjs} +5 -6
  15. package/dist/_templates.d.ts +3 -4
  16. package/dist/_workflow-scan.d.ts +108 -0
  17. package/dist/{build-Dfza2pRI.mjs → build-BBxf8vom.mjs} +6 -3
  18. package/dist/cli.mjs +346 -349
  19. package/dist/{client-bundler-BEIqgOtd.mjs → client-bundler-CWnG42cU.mjs} +2 -1
  20. package/dist/client-bundler.mjs +1 -1
  21. package/dist/{delete-vbpjGzqs.mjs → delete-Dl_LSfvI.mjs} +3 -3
  22. package/dist/{deploy-B8lubiRT.mjs → deploy-DRC8jWr8.mjs} +5 -5
  23. package/dist/{dev-Uq5ujP8a.mjs → dev-BvcctCn2.mjs} +1 -1
  24. package/dist/eval-DfQ8cm5p.mjs +56 -0
  25. package/dist/eval.d.ts +43 -0
  26. package/dist/{init-l6xfU4xX.mjs → init-CUo87gcI.mjs} +3 -3
  27. package/dist/{login-C71-qz8F.mjs → login-BBuM1sxH.mjs} +3 -4
  28. package/dist/{logs-BWVGSjLR.mjs → logs-CQ2T46dr.mjs} +2 -2
  29. package/dist/project-config.mjs +1 -1
  30. package/dist/scaffold/CLAUDE.md +442 -80
  31. package/dist/scaffold/package.json +6 -3
  32. package/dist/scaffold/server.mjs +11 -4
  33. package/dist/scaffold/tsconfig.json +0 -1
  34. package/dist/{secret-BuMuFR4B.mjs → secret-Ct34kdgU.mjs} +2 -2
  35. package/dist/{storage-DzRZ-eCw.mjs → storage-i_79ZwAR.mjs} +2 -2
  36. package/dist/{studio-BCNUpDgP.mjs → studio-CzTzPqe1.mjs} +4 -4
  37. package/dist/templates/briefing-desk/agent.test.ts +350 -0
  38. package/dist/templates/briefing-desk/agent.ts +30 -0
  39. package/dist/templates/briefing-desk/shared.ts +200 -0
  40. package/dist/templates/briefing-desk/system-prompt.md +39 -0
  41. package/dist/templates/briefing-desk/tools/briefing_so_far.ts +35 -0
  42. package/dist/templates/briefing-desk/tools/research_topic.ts +88 -0
  43. package/dist/templates/briefing-desk/tools/verify_claim.ts +77 -0
  44. package/dist/templates/call-audit/agent.eval.test.ts +474 -0
  45. package/dist/templates/call-audit/agent.test.ts +57 -204
  46. package/dist/templates/call-audit/agent.ts +32 -19
  47. package/dist/templates/call-audit/client.tsx +17 -54
  48. package/dist/templates/call-audit/workflows/audit.ts +11 -18
  49. package/dist/templates/call-audit/workflows/ingest.ts +106 -114
  50. package/dist/templates/call-audit/workflows/media.ts +2 -12
  51. package/dist/templates/call-audit/workflows/summarize.ts +47 -52
  52. package/dist/templates/call-audit/workflows/sync-api.ts +7 -7
  53. package/dist/templates/code-interpreter/agent.eval.test.ts +165 -0
  54. package/dist/templates/dispatch-center/agent.eval.test.ts +349 -0
  55. package/dist/templates/dispatch-center/agent.test.ts +149 -25
  56. package/dist/templates/dispatch-center/client.tsx +239 -129
  57. package/dist/templates/dispatch-center/shared.ts +99 -1
  58. package/dist/templates/dispatch-center/system-prompt.md +3 -1
  59. package/dist/templates/dispatch-center/tools/incident_add_note.ts +16 -13
  60. package/dist/templates/dispatch-center/tools/incident_create.ts +19 -1
  61. package/dist/templates/dispatch-center/tools/incident_escalate.ts +68 -59
  62. package/dist/templates/dispatch-center/tools/incident_triage.ts +57 -43
  63. package/dist/templates/dispatch-center/tools/incident_update_status.ts +49 -40
  64. package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +8 -2
  65. package/dist/templates/dispatch-center/tools/resources_dispatch.ts +69 -52
  66. package/dist/templates/dispatch-center/tools/resources_update_status.ts +45 -41
  67. package/dist/templates/embedded-assets/agent.eval.test.ts +138 -0
  68. package/dist/templates/embedded-assets/agent.test.ts +17 -14
  69. package/dist/templates/health-assistant/agent.eval.test.ts +167 -0
  70. package/dist/templates/health-assistant/agent.test.ts +22 -4
  71. package/dist/templates/infocom-adventure/agent.eval.test.ts +158 -0
  72. package/dist/templates/infocom-adventure/agent.test.ts +53 -24
  73. package/dist/templates/infocom-adventure/agent.ts +21 -0
  74. package/dist/templates/infocom-adventure/client.tsx +246 -164
  75. package/dist/templates/infocom-adventure/shared.ts +20 -9
  76. package/dist/templates/infocom-adventure/system-prompt.md +1 -1
  77. package/dist/templates/infocom-adventure/tools/game_state_move.ts +3 -2
  78. package/dist/templates/link-digest/agent.eval.test.ts +195 -0
  79. package/dist/templates/link-digest/agent.test.ts +24 -19
  80. package/dist/templates/link-digest/client.tsx +47 -61
  81. package/dist/templates/link-digest/workflows/digest.ts +19 -31
  82. package/dist/templates/math-buddy/agent.eval.test.ts +146 -0
  83. package/dist/templates/night-owl/agent.eval.test.ts +272 -0
  84. package/dist/templates/night-owl/agent.test.ts +70 -19
  85. package/dist/templates/night-owl/agent.ts +5 -0
  86. package/dist/templates/night-owl/client.tsx +56 -56
  87. package/dist/templates/night-owl/shared.ts +24 -0
  88. package/dist/templates/night-owl/tools/recommend.ts +18 -6
  89. package/dist/templates/personal-finance/agent.eval.test.ts +168 -0
  90. package/dist/templates/personal-finance/system-prompt.md +4 -0
  91. package/dist/templates/pipeline-simple/agent.eval.test.ts +78 -0
  92. package/dist/templates/pipeline-simple/agent.ts +2 -2
  93. package/dist/templates/pizza-ordering/agent.eval.test.ts +171 -0
  94. package/dist/templates/pizza-ordering/agent.test.ts +42 -27
  95. package/dist/templates/pizza-ordering/client.tsx +9 -26
  96. package/dist/templates/pizza-ordering/shared.ts +20 -1
  97. package/dist/templates/pizza-ordering/system-prompt.md +6 -0
  98. package/dist/templates/plan-and-execute/agent.eval.test.ts +209 -0
  99. package/dist/templates/plan-and-execute/agent.test.ts +99 -58
  100. package/dist/templates/plan-and-execute/agent.ts +1 -1
  101. package/dist/templates/plan-and-execute/client.tsx +12 -15
  102. package/dist/templates/plan-and-execute/shared.ts +71 -2
  103. package/dist/templates/plan-and-execute/tools/plan_status.ts +18 -6
  104. package/dist/templates/plan-and-execute/tools/revise_plan.ts +18 -10
  105. package/dist/templates/plan-and-execute/tools/start_plan.ts +13 -2
  106. package/dist/templates/plan-and-execute/tools/work_next_step.ts +70 -22
  107. package/dist/templates/podcast-digest/agent.eval.test.ts +411 -0
  108. package/dist/templates/podcast-digest/agent.test.ts +717 -0
  109. package/dist/templates/podcast-digest/agent.ts +139 -0
  110. package/dist/templates/podcast-digest/client.tsx +154 -0
  111. package/dist/templates/podcast-digest/workflows/digest.ts +444 -0
  112. package/dist/templates/podcast-digest/workflows/feeds.ts +507 -0
  113. package/dist/templates/podcast-digest/workflows/slack.ts +88 -0
  114. package/dist/templates/recap-workflow/agent.eval.test.ts +366 -0
  115. package/dist/templates/recap-workflow/agent.test.ts +97 -94
  116. package/dist/templates/recap-workflow/tools/recap_progress.ts +9 -12
  117. package/dist/templates/recap-workflow/tools/recap_status.ts +8 -9
  118. package/dist/templates/recap-workflow/tools/request_recap.ts +2 -1
  119. package/dist/templates/recap-workflow/workflows/recap.ts +36 -40
  120. package/dist/templates/redline/agent.eval.test.ts +209 -0
  121. package/dist/templates/redline/agent.test.ts +17 -15
  122. package/dist/templates/redline/client.tsx +12 -12
  123. package/dist/templates/redline/workflows/redline.ts +19 -31
  124. package/dist/templates/research-workflow/agent.eval.test.ts +329 -0
  125. package/dist/templates/research-workflow/agent.test.ts +60 -59
  126. package/dist/templates/research-workflow/tools/research_progress.ts +9 -12
  127. package/dist/templates/research-workflow/tools/research_status.ts +9 -11
  128. package/dist/templates/research-workflow/workflows/research.ts +44 -61
  129. package/dist/templates/retail/address.ts +87 -1
  130. package/dist/templates/retail/agent.eval.test.ts +314 -0
  131. package/dist/templates/retail/agent.test.ts +195 -112
  132. package/dist/templates/retail/agent.ts +35 -1
  133. package/dist/templates/retail/cancel.ts +116 -0
  134. package/dist/templates/retail/client.tsx +265 -117
  135. package/dist/templates/retail/payment.ts +107 -0
  136. package/dist/templates/retail/pending.test.ts +196 -0
  137. package/dist/templates/retail/pending.ts +137 -0
  138. package/dist/templates/retail/registry.test.ts +150 -7
  139. package/dist/templates/retail/returns.ts +102 -0
  140. package/dist/templates/retail/shared.test.ts +42 -0
  141. package/dist/templates/retail/shared.ts +35 -0
  142. package/dist/templates/retail/store.test.ts +84 -15
  143. package/dist/templates/retail/store.ts +231 -48
  144. package/dist/templates/retail/swap.test.ts +4 -4
  145. package/dist/templates/retail/swap.ts +248 -15
  146. package/dist/templates/retail/system-prompt.md +69 -28
  147. package/dist/templates/retail/tools/cancel_change.ts +33 -0
  148. package/dist/templates/retail/tools/cancel_pending_order.ts +16 -60
  149. package/dist/templates/retail/tools/confirm_change.ts +42 -0
  150. package/dist/templates/retail/tools/exchange_delivered_order_items.ts +22 -67
  151. package/dist/templates/retail/tools/find_user_id_by_email.ts +8 -5
  152. package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +4 -5
  153. package/dist/templates/retail/tools/get_item_details.ts +3 -4
  154. package/dist/templates/retail/tools/get_order_details.ts +3 -4
  155. package/dist/templates/retail/tools/get_product_details.ts +3 -4
  156. package/dist/templates/retail/tools/get_user_details.ts +2 -2
  157. package/dist/templates/retail/tools/list_all_product_types.ts +5 -5
  158. package/dist/templates/retail/tools/modify_pending_order_address.ts +13 -25
  159. package/dist/templates/retail/tools/modify_pending_order_items.ts +23 -62
  160. package/dist/templates/retail/tools/modify_pending_order_payment.ts +16 -78
  161. package/dist/templates/retail/tools/modify_user_address.ts +13 -16
  162. package/dist/templates/retail/tools/return_delivered_order_items.ts +17 -72
  163. package/dist/templates/retail/tools/transfer_to_human_agents.ts +12 -6
  164. package/dist/templates/simple/agent.eval.test.ts +54 -0
  165. package/dist/templates/solo-rpg/agent.eval.test.ts +320 -0
  166. package/dist/templates/solo-rpg/agent.test.ts +227 -48
  167. package/dist/templates/solo-rpg/client.tsx +38 -37
  168. package/dist/templates/solo-rpg/shared.ts +145 -19
  169. package/dist/templates/solo-rpg/system-prompt.md +3 -2
  170. package/dist/templates/solo-rpg/tools/action_roll.ts +82 -63
  171. package/dist/templates/solo-rpg/tools/burn_momentum.ts +85 -50
  172. package/dist/templates/solo-rpg/tools/check_state.ts +24 -7
  173. package/dist/templates/solo-rpg/tools/load_game.ts +13 -1
  174. package/dist/templates/solo-rpg/tools/save_game.ts +16 -7
  175. package/dist/templates/solo-rpg/tools/setup_character.ts +22 -2
  176. package/dist/templates/solo-rpg/tools/update_state.ts +113 -100
  177. package/dist/templates/spoken-summary/agent.eval.test.ts +342 -0
  178. package/dist/templates/spoken-summary/agent.test.ts +68 -97
  179. package/dist/templates/spoken-summary/agent.ts +30 -17
  180. package/dist/templates/spoken-summary/client.tsx +10 -50
  181. package/dist/templates/spoken-summary/workflows/summarize.ts +17 -22
  182. package/dist/templates/spoken-summary/workflows/transcribe.ts +19 -26
  183. package/dist/templates/support-line/agent.eval.test.ts +273 -0
  184. package/dist/templates/support-line/agent.test.ts +11 -16
  185. package/dist/templates/support-line/agent.ts +1 -1
  186. package/dist/templates/support-line/client.tsx +9 -9
  187. package/dist/templates/support-line/nodes.ts +100 -0
  188. package/dist/templates/support-line/procedure.ts +407 -0
  189. package/dist/templates/support-line/system-prompt.md +2 -1
  190. package/dist/templates/support-line/tools/answer_question.ts +66 -4
  191. package/dist/templates/transcription-workflow/agent.eval.test.ts +234 -0
  192. package/dist/templates/transcription-workflow/agent.test.ts +94 -194
  193. package/dist/templates/transcription-workflow/agent.ts +1 -1
  194. package/dist/templates/transcription-workflow/client.tsx +17 -42
  195. package/dist/templates/transcription-workflow/workflows/batch.ts +19 -22
  196. package/dist/templates/transcription-workflow/workflows/normalize.ts +90 -186
  197. package/dist/templates/transcription-workflow/workflows/stitch.ts +0 -11
  198. package/dist/templates/transcription-workflow/workflows/stream.ts +4 -5
  199. package/dist/templates/transcription-workflow/workflows/sync-api.ts +6 -6
  200. package/dist/templates/transcription-workflow/workflows/transcribe.ts +32 -26
  201. package/dist/templates/transcription-workflow/workflows/wav.ts +0 -31
  202. package/dist/templates/travel-concierge/agent.eval.test.ts +323 -0
  203. package/dist/templates/travel-concierge/agent.test.ts +174 -43
  204. package/dist/templates/travel-concierge/agent.ts +18 -9
  205. package/dist/templates/travel-concierge/client.tsx +11 -23
  206. package/dist/templates/travel-concierge/routing.ts +34 -15
  207. package/dist/templates/travel-concierge/shared.ts +180 -12
  208. package/dist/templates/travel-concierge/system-prompt.md +7 -1
  209. package/dist/templates/travel-concierge/tools/book_car_rental.ts +8 -5
  210. package/dist/templates/travel-concierge/tools/book_excursion.ts +8 -5
  211. package/dist/templates/travel-concierge/tools/book_hotel.ts +8 -5
  212. package/dist/templates/travel-concierge/tools/cancel_ticket.ts +8 -5
  213. package/dist/templates/travel-concierge/tools/search_car_rentals.ts +9 -5
  214. package/dist/templates/travel-concierge/tools/search_excursions.ts +9 -5
  215. package/dist/templates/travel-concierge/tools/search_flights.ts +9 -6
  216. package/dist/templates/travel-concierge/tools/search_hotels.ts +9 -5
  217. package/dist/templates/travel-concierge/tools/update_ticket.ts +8 -5
  218. package/dist/templates/web-researcher/agent.eval.test.ts +121 -0
  219. package/dist/templates/web-researcher/system-prompt.md +15 -4
  220. package/dist/{test-9kPKJI-w.mjs → test-BvyT6sAc.mjs} +24 -10
  221. package/dist/test.d.ts +29 -5
  222. package/dist/{worker-bundler-CGD4r8Kc.mjs → worker-bundler-CCVEDjm1.mjs} +241 -3
  223. package/dist/worker-bundler.mjs +1 -1
  224. package/dist/{workflow-CFpxOFfQ.mjs → workflow-BflATMmU.mjs} +47 -12
  225. package/dist/workflow-bundler.d.ts +6 -1
  226. package/dist/workflow.d.ts +1 -1
  227. package/package.json +5 -4
  228. package/dist/eject-31gjtaHF.mjs +0 -116
  229. package/dist/eject.d.ts +0 -60
  230. package/dist/templates/call-audit/workflows/temp-media.ts +0 -138
  231. package/dist/templates/infocom-adventure/tools/game_state_history.ts +0 -14
  232. package/dist/templates/support-line/graph.ts +0 -224
  233. package/dist/{_config-D_s09e7g.mjs → _config-CmJOFsAP.mjs} +1 -1
  234. /package/dist/templates/plan-and-execute/{graph.ts → procedure.ts} +0 -0
@@ -11,6 +11,7 @@ function makeState(authenticatedUserId: string | null): RetailState {
11
11
  callSeq: 3,
12
12
  activity: [{ seq: 3, tool: "get_order_details", summary: "read #W5866402", at: 0 }],
13
13
  focus: { orderId: "#W5866402" },
14
+ pending: null,
14
15
  };
15
16
  }
16
17
 
@@ -196,3 +197,44 @@ describe("buildScriptBullets", () => {
196
197
  expect(bullets.length).toBeLessThanOrEqual(6);
197
198
  });
198
199
  });
200
+
201
+ describe("the staged change in the projection", () => {
202
+ function stagedState(authenticatedUserId: string | null): RetailState {
203
+ const state = makeState(authenticatedUserId);
204
+ state.pending = {
205
+ kind: "cancel_pending_order",
206
+ plan: {
207
+ readBack: "cancel order #W5866402 and refund $12.00 to gift_card_7794233",
208
+ orderId: "#W5866402",
209
+ reason: "no longer needed",
210
+ refunds: [{ methodId: "gift_card_7794233", amount: 12 }],
211
+ total: 12,
212
+ },
213
+ };
214
+ return state;
215
+ }
216
+
217
+ test("carries the sentence the agent is supposed to be reading back", () => {
218
+ const view = storeView(stagedState("olivia_ito_3591"));
219
+ expect(view.pending?.kind).toBe("cancel_pending_order");
220
+ expect(view.pending?.readBack).toContain("#W5866402");
221
+ });
222
+
223
+ test("is null when nothing is waiting", () => {
224
+ expect(storeView(makeState("olivia_ito_3591")).pending).toBeNull();
225
+ });
226
+
227
+ test("is withheld before authentication, like everything else here", () => {
228
+ // Unreachable in practice — a change cannot be staged before the caller is
229
+ // identified — but the projection does not lean on that: it gates `pending`
230
+ // on `user` exactly as it gates the orders it describes.
231
+ expect(storeView(stagedState(null)).pending).toBeNull();
232
+ });
233
+
234
+ test("the script offers the answer to the question, not a new request", () => {
235
+ expect(buildScriptBullets(stagedState("olivia_ito_3591"))).toEqual([
236
+ '"Yes, go ahead"',
237
+ `"No, don't do that"`,
238
+ ]);
239
+ });
240
+ });
@@ -1,4 +1,5 @@
1
1
  import type { DeepReadonly } from "@alexkroman1/aai";
2
+ import type { PendingAction } from "./pending.ts";
2
3
 
3
4
  // ─── Store types ─────────────────────────────────────────────────────────────
4
5
  // Field names are tau2's snake_case verbatim, because `seed.json` is tau2's
@@ -129,6 +130,15 @@ export interface RetailState {
129
130
  callSeq: number;
130
131
  activity: ActivityEntry[];
131
132
  focus: { orderId?: string; productId?: string };
133
+ /**
134
+ * The change waiting on the caller's explicit yes, or null.
135
+ *
136
+ * Lives here and not in the flow because `callFlow` holds a POSITION —
137
+ * "something is waiting" — and this holds the thing itself, which is what
138
+ * `confirm_change` applies and what the sidebar renders. One tool call always
139
+ * moves both. See `pending.ts`.
140
+ */
141
+ pending: PendingAction | null;
132
142
  }
133
143
 
134
144
  /**
@@ -159,6 +169,7 @@ export function emptyRetailState(): RetailState {
159
169
  callSeq: 0,
160
170
  activity: [],
161
171
  focus: {},
172
+ pending: null,
162
173
  };
163
174
  }
164
175
 
@@ -204,6 +215,14 @@ export interface SwapOptionView {
204
215
  alternatives: { itemId: string; options: Record<string, string>; price: number }[];
205
216
  }
206
217
 
218
+ /** The staged change, as the browser sees it: what it is and what the agent is
219
+ * supposed to be reading back. The plan itself stays on the server — the panel
220
+ * only has to show the caller's own words back to them. */
221
+ export interface PendingView {
222
+ kind: PendingAction["kind"];
223
+ readBack: string;
224
+ }
225
+
207
226
  export interface StoreView {
208
227
  customer: CustomerView | null;
209
228
  orders: OrderView[];
@@ -214,6 +233,10 @@ export interface StoreView {
214
233
  activity: ActivityEntry[];
215
234
  scriptBullets: string[];
216
235
  productCount: number;
236
+ /** Gated on `user` like everything else here: a change staged before the
237
+ * caller was identified cannot exist, but the projection does not rely on
238
+ * that — it withholds it the same way it withholds their orders. */
239
+ pending: PendingView | null;
217
240
  }
218
241
 
219
242
  export const MAX_SWAP_ITEMS = 3;
@@ -397,6 +420,10 @@ export function storeView(state: FrozenRetailState): StoreView {
397
420
  ...(state.focus.productId ? { productId: state.focus.productId } : {}),
398
421
  },
399
422
  swapOptions: swapOptionsFor(state, focusedOrder),
423
+ pending:
424
+ user && state.pending
425
+ ? { kind: state.pending.kind, readBack: state.pending.plan.readBack }
426
+ : null,
400
427
  callSeq: state.callSeq,
401
428
  activity: state.activity.slice(-MAX_ACTIVITY),
402
429
  scriptBullets: buildScriptBullets(state),
@@ -417,6 +444,14 @@ export function buildScriptBullets(state: FrozenRetailState): string[] {
417
444
  const userId = state.authenticatedUserId;
418
445
  const user = userId ? state.store.users[userId] : undefined;
419
446
 
447
+ // A staged change is a QUESTION the agent has just asked, so while one waits
448
+ // the only two useful things a caller can say are the answer to it. Offering
449
+ // "cancel my pending order" here would be offering an action the gate is
450
+ // about to refuse.
451
+ if (user && state.pending) {
452
+ return ['"Yes, go ahead"', '"No, don\'t do that"'];
453
+ }
454
+
420
455
  // Pre-auth: only the two ways in. The personas panel (a constant, rendered
421
456
  // straight from DEMO_PERSONAS) covers WHICH customer to be.
422
457
  if (!user) {
@@ -1,9 +1,11 @@
1
1
  import { isToolFailure, type ToolContext } from "@alexkroman1/aai";
2
- import { createToolContext } from "@alexkroman1/aai/testing";
2
+ import { createToolContext, ok, okPosition } from "@alexkroman1/aai/testing";
3
3
  import { describe, expect, test } from "vitest";
4
4
  import { z } from "zod";
5
5
  import {
6
6
  authenticatedUser,
7
+ BEFORE_TRANSFER,
8
+ callFlow,
7
9
  createDefaultState,
8
10
  findItem,
9
11
  findOrder,
@@ -163,15 +165,16 @@ describe("retailTool", () => {
163
165
  name: "echo",
164
166
  description: "test tool",
165
167
  inputSchema: z.object({ value: z.string() }),
166
- requiresAuth: false,
168
+ when: BEFORE_TRANSFER,
167
169
  summary: (args) => `echoed ${args.value}`,
168
170
  execute: (args) => ({ echoed: args.value }),
169
171
  });
170
172
 
171
173
  const gated = retailTool({
172
174
  name: "gated",
173
- description: "test tool needing auth",
175
+ description: "test tool needing an identified customer",
174
176
  inputSchema: z.object({}),
177
+ when: "serving",
175
178
  summary: () => "ran",
176
179
  execute: () => ({ ok: true }),
177
180
  });
@@ -180,11 +183,15 @@ describe("retailTool", () => {
180
183
  name: "failing",
181
184
  description: "test tool that returns an error",
182
185
  inputSchema: z.object({}),
183
- requiresAuth: false,
186
+ when: BEFORE_TRANSFER,
184
187
  summary: () => "should not be used",
185
188
  execute: () => ({ error: "nope" }),
186
189
  });
187
190
 
191
+ /** Put the call where a `when: "serving"` tool can run, through the flow
192
+ * rather than by writing the store — the gate reads the machine. */
193
+ const serve = (ctx: ToolContext) => callFlow.send(ctx, { type: "IDENTIFIED" });
194
+
188
195
  test("increments callSeq and logs activity on every call", async () => {
189
196
  const ctx = makeCtx();
190
197
  await echo.execute({ value: "a" }, ctx);
@@ -203,27 +210,45 @@ describe("retailTool", () => {
203
210
  expect(retailSlot.get(ctx).callSeq).toBeGreaterThan(first);
204
211
  });
205
212
 
206
- test("defaults to requiring authentication and does not run execute when blocked", async () => {
213
+ test("a tool gated on serving refuses while the caller is unidentified", async () => {
207
214
  const ctx = makeCtx();
208
215
  const result = await gated.execute({}, ctx);
209
- expect(isToolFailure(result) && result.error).toContain("find_user_id_by_email");
216
+ expect(isToolFailure(result)).toBe(true);
217
+ // The refusal is the SDK's, so it names the position and quotes the
218
+ // state's own instruction — where `requiresAuth` answered one fixed
219
+ // sentence that could not say where the call was.
220
+ expect(isToolFailure(result) && result.error).toContain('"identifying"');
221
+ expect(isToolFailure(result) && result.error).toMatch(/user id/);
210
222
  });
211
223
 
212
- test("a blocked call is still logged and still bumps callSeq", async () => {
224
+ test("a refused call touches nothing, callSeq included", async () => {
213
225
  const ctx = makeCtx();
214
226
  await gated.execute({}, ctx);
215
227
  const state = retailSlot.get(ctx);
216
- expect(state.callSeq).toBe(1);
217
- expect(state.activity[0]?.summary).toContain("blocked");
228
+ // The gate short-circuits before the wrapper's body, so the EXECUTION path
229
+ // writes nothing at all — which is what makes a refusal unable to half-write
230
+ // the store. The sidebar line for a blocked call comes from `agent.ts`'s
231
+ // `tool.called` hook instead, which is a different path and not under test
232
+ // here: see `registry.test.ts`.
233
+ expect(state.callSeq).toBe(0);
234
+ expect(state.activity).toEqual([]);
218
235
  });
219
236
 
220
- test("runs once authenticated", async () => {
237
+ test("runs once the flow says serving", async () => {
221
238
  const ctx = makeCtx();
222
- retailSlot.update(ctx, (state) => {
223
- state.authenticatedUserId = "olivia_ito_3591";
224
- });
225
- const result = await gated.execute({}, ctx);
226
- expect(result).toEqual({ ok: true });
239
+ serve(ctx);
240
+ expect(ok(await gated.execute({}, ctx))).toEqual({ ok: true });
241
+ });
242
+
243
+ test("the result carries the position the call landed in", async () => {
244
+ const ctx = makeCtx();
245
+ serve(ctx);
246
+ // `okPosition` rather than a cast to `{ instruction?: string }`: it keeps
247
+ // the envelope this test is about, and a refusal fails here naming what the
248
+ // flow said instead of reading `undefined` off a field nobody assigned.
249
+ const answered = okPosition<{ ok: boolean }>(await gated.execute({}, ctx));
250
+ expect(answered).toMatchObject({ state: "serving.helping", done: false });
251
+ expect(answered.instruction).toMatch(/one identified customer/);
227
252
  });
228
253
 
229
254
  test("an error result is logged as an error, not through summary()", async () => {
@@ -232,6 +257,21 @@ describe("retailTool", () => {
232
257
  expect(retailSlot.get(ctx).activity[0]?.summary).toBe("error: nope");
233
258
  });
234
259
 
260
+ test("a body that failed does not move the call", async () => {
261
+ const ctx = makeCtx();
262
+ const moves = retailTool({
263
+ name: "moves",
264
+ description: "test tool that would identify the caller and fails instead",
265
+ inputSchema: z.object({}),
266
+ when: BEFORE_TRANSFER,
267
+ send: { type: "IDENTIFIED" },
268
+ summary: () => "moved",
269
+ execute: () => ({ error: "no such customer" }),
270
+ });
271
+ expect(isToolFailure(await moves.execute({}, ctx))).toBe(true);
272
+ expect(callFlow.position(ctx).state).toBe("identifying");
273
+ });
274
+
235
275
  test("activity is capped so a long call cannot grow the payload", async () => {
236
276
  const ctx = makeCtx();
237
277
  for (let i = 0; i < 15; i++) await echo.execute({ value: String(i) }, ctx);
@@ -249,3 +289,32 @@ describe("retailTool", () => {
249
289
  expect(new Set(state.activity.map((a) => a.seq)).size).toBe(state.activity.length);
250
290
  });
251
291
  });
292
+
293
+ describe("the call flow", () => {
294
+ test("a fresh call is identifying, and nothing is latched", () => {
295
+ const ctx = makeCtx();
296
+ expect(callFlow.position(ctx).state).toBe("identifying");
297
+ expect(retailSlot.get(ctx).authenticatedUserId).toBeNull();
298
+ });
299
+
300
+ test("transferred is final, so every tool refuses after the handoff", async () => {
301
+ const ctx = makeCtx();
302
+ const anywhere = retailTool({
303
+ name: "anywhere",
304
+ description: "legal in every state but the terminal one",
305
+ inputSchema: z.object({}),
306
+ when: BEFORE_TRANSFER,
307
+ summary: () => "ran",
308
+ execute: () => ({ ok: true }),
309
+ });
310
+ expect(ok(await anywhere.execute({}, ctx))).toEqual({ ok: true });
311
+
312
+ const at = callFlow.send(ctx, { type: "TRANSFERRED" });
313
+ expect(at.state).toBe("transferred");
314
+ expect(at.done).toBe(true);
315
+
316
+ const refused = await anywhere.execute({}, ctx);
317
+ expect(isToolFailure(refused)).toBe(true);
318
+ expect(isToolFailure(refused) && refused.error).toContain('"transferred"');
319
+ });
320
+ });
@@ -1,5 +1,5 @@
1
1
  import type { ToolContext, ToolFailure } from "@alexkroman1/aai";
2
- import { isToolFailure, pushCapped, sessionSlot, toolFailure } from "@alexkroman1/aai";
2
+ import { dialog, isToolFailure, omitUndefined, pushCapped, sessionSlot } from "@alexkroman1/aai";
3
3
  import type { z } from "zod";
4
4
  import seedJson from "./seed.json";
5
5
  import type {
@@ -71,6 +71,108 @@ export function seedStore(): Store {
71
71
  */
72
72
  export const retailSlot = sessionSlot("retail", createDefaultState);
73
73
 
74
+ // ─── The call, as a machine ──────────────────────────────────────────────────
75
+
76
+ /**
77
+ * Where this call is, and what may be done from here, as a plain state map.
78
+ *
79
+ * A {@link DialogSpec} rather than an XState machine: everything this dialog
80
+ * says is states, events and an instruction each, which is exactly what a spec
81
+ * can say. The instruction is a declared field instead of an untyped `meta`
82
+ * bag, so misspelling it is a compile error rather than a refusal that arrives
83
+ * with no recovery text. `as const` is what keeps the `on` keys literal, so
84
+ * `send` below is checked against the events this spec actually declares.
85
+ *
86
+ * Three of the policy's sections live here rather than in prose.
87
+ *
88
+ * **"Authenticate first."** `identifying`'s instruction names both finder
89
+ * tools, and ten tools declare `when: "serving"`, so the gate is the SDK's.
90
+ *
91
+ * **"Handing off to a human."** The policy says to call
92
+ * `transfer_to_human_agents` and then say exactly one sentence "and nothing
93
+ * else" — which nothing checked, so every tool stayed callable after the handoff
94
+ * and a model that kept going kept acting on a call it had already given away.
95
+ * `transferred` is a `final` state: no tool declares itself legal there, so
96
+ * every one of them refuses. `TRANSFERRED` sits on the `serving` PARENT so it
97
+ * is reachable from either child — a caller asking for a human mid-confirmation
98
+ * must get one.
99
+ *
100
+ * **"Confirm every change out loud."** This was the last rule in the policy
101
+ * carried by nothing at all, and it is `serving`'s two children now. Nothing in
102
+ * this template mutates the store except `confirm_change`, which is legal only
103
+ * in `awaitingConfirmation`, which is reachable only by staging a change. See
104
+ * `pending.ts` for what that buys and what it still cannot promise.
105
+ *
106
+ * **`IDENTIFIED` is deliberately NOT declared on `serving`.** It was, while
107
+ * `serving` was a leaf, so that a caller repeating their email did not hit an
108
+ * error. Now that `serving` has children, a self-transition would RE-ENTER it
109
+ * and reset to `helping` — stranding a staged change that `state.pending` still
110
+ * holds, which is the one way the position and the store could come to
111
+ * disagree. An event no active state handles is ignored, and ignoring it is
112
+ * exactly right: re-identifying the same customer does not move the call.
113
+ * `authenticateAs` is what refuses a switch to a DIFFERENT customer. Whether
114
+ * the call is identified and WHO it is identified as are two facts: this holds
115
+ * the first, `authenticatedUserId` holds the second.
116
+ */
117
+ const callSpec = {
118
+ initial: "identifying",
119
+ states: {
120
+ identifying: {
121
+ // The instruction NAMES the two tools, because this sentence is what a
122
+ // refusal quotes and a refusal is the model's recovery path.
123
+ instruction:
124
+ "You do not know who this is yet. Identify the caller with " +
125
+ "find_user_id_by_email, or find_user_id_by_name_zip if they cannot " +
126
+ "remember the email. Do this even if they volunteer a user id.",
127
+ on: { IDENTIFIED: "serving", TRANSFERRED: "transferred" },
128
+ },
129
+ serving: {
130
+ initial: "helping",
131
+ on: { TRANSFERRED: "transferred" },
132
+ states: {
133
+ helping: {
134
+ instruction:
135
+ "You are helping one identified customer, and only that one. Nothing is " +
136
+ "waiting on their word. A tool that changes something only STAGES it and " +
137
+ "hands you a sentence to read back.",
138
+ on: { STAGED: "awaitingConfirmation" },
139
+ },
140
+ awaitingConfirmation: {
141
+ instruction:
142
+ "A change is staged and NOTHING HAS HAPPENED YET. Read the staged sentence " +
143
+ "back — the order, the items, the amounts, where the money goes — and wait " +
144
+ "for an explicit yes. Then call confirm_change, or cancel_change if they " +
145
+ "say no or want to change any part of it.",
146
+ on: { SETTLED: "helping" },
147
+ },
148
+ },
149
+ },
150
+ transferred: {
151
+ final: true,
152
+ instruction:
153
+ "The call belongs to a human agent now. Say nothing beyond the transfer " +
154
+ "sentence, and do nothing else.",
155
+ },
156
+ },
157
+ } as const;
158
+
159
+ /**
160
+ * The flow. Its own slot key beside {@link retailSlot}: the flow holds the
161
+ * POSITION and the store holds the customer, the orders, the activity feed and
162
+ * the staged change itself, because an inspectable {@link PendingAction} is
163
+ * what `confirm_change` applies. One tool call always moves both — a staging
164
+ * tool writes `pending` and sends `STAGED` in the same synchronous window.
165
+ */
166
+ export const callFlow = dialog("call", callSpec);
167
+
168
+ /** Every state a tool may run in before the call is handed to a human — i.e.
169
+ * everything but `transferred`. What the two finders, the three catalog reads
170
+ * and the transfer itself declare, so the terminal state gates them without an
171
+ * auth gate. `"serving"` matches both of its children, which is what keeps a
172
+ * read legal while a change is waiting: a caller who asks "what was the total
173
+ * again?" mid-confirmation must be answerable. */
174
+ export const BEFORE_TRANSFER = ["identifying", "serving"] as const;
175
+
74
176
  export function setFocus(
75
177
  state: RetailState,
76
178
  focus: { orderId?: string; productId?: string },
@@ -128,12 +230,26 @@ export function findPaymentMethod(user: User, methodId: string): PaymentMethod |
128
230
 
129
231
  // ─── Guards ──────────────────────────────────────────────────────────────────
130
232
 
131
- const NOT_AUTHENTICATED =
132
- "Not authenticated. Identify the customer first with find_user_id_by_email, " +
133
- "or find_user_id_by_name_zip if they cannot remember their email.";
134
-
233
+ /**
234
+ * The customer on this call.
235
+ *
236
+ * The null arm is reachable only if the POSITION and the STORE disagree — the
237
+ * flow says `serving` while nothing latched a user id — which no code path
238
+ * produces, since `authenticateAs` is what both writes the id and lets the
239
+ * `IDENTIFIED` event through. It is kept and reported rather than thrown for the
240
+ * reason `travel-concierge`'s `cancel_action` keeps its own: this runs mid-call,
241
+ * and a sentence the model can act on beats an exception. The GATE that a
242
+ * customer is identified at all is `callFlow`'s, declared per tool as `when`.
243
+ */
135
244
  export function authenticatedUser(state: RetailState): User | ToolFailure {
136
- if (!state.authenticatedUserId) return { error: NOT_AUTHENTICATED };
245
+ if (!state.authenticatedUserId) {
246
+ return {
247
+ error:
248
+ "No customer is latched onto this call yet. Identify them with " +
249
+ "find_user_id_by_email, or find_user_id_by_name_zip if they cannot " +
250
+ "remember their email.",
251
+ };
252
+ }
137
253
  return findUser(state, state.authenticatedUserId);
138
254
  }
139
255
 
@@ -187,24 +303,52 @@ interface RetailToolSpec<S extends z.ZodType<Record<string, unknown>>, R> {
187
303
  /** Required even for no-arg tools — pass `z.object({})`. One code path in the
188
304
  * wrapper is worth more than saving a line at one call site. */
189
305
  inputSchema: S;
190
- /** Default true. Only the two finder tools and the three catalog tools opt
191
- * out; everything else touches customer data. */
192
- requiresAuth?: boolean;
193
- summary: (args: z.output<S>, result: R) => string;
194
306
  /**
195
- * Handed the store as its second argument, and SYNCHRONOUS.
307
+ * The state(s) this tool may run in, as `callFlow`'s states spell them.
308
+ *
309
+ * Replaces the `requiresAuth` boolean this spec used to carry. Nine tools
310
+ * want `"serving"` — which matches both of its children, so a read and a
311
+ * staging tool are equally legal while a change waits; the two settling
312
+ * tools want `"serving.awaitingConfirmation"`; and the two finders, the three
313
+ * catalog reads and the transfer want {@link BEFORE_TRANSFER}, which is every
314
+ * state but the terminal one — so "does not need a customer" and "is still
315
+ * legal after the handoff" stopped being the same claim, and they were never
316
+ * the same claim.
317
+ */
318
+ when: string | readonly string[];
319
+ /** The event to send once the body has succeeded, for a tool that MOVES the
320
+ * call. Twelve do: the two finders send `IDENTIFIED`, the transfer sends
321
+ * `TRANSFERRED`, the seven staging tools send `STAGED`, and `confirm_change`
322
+ * and `cancel_change` send `SETTLED`. Nothing is sent when the body answers
323
+ * a `ToolFailure` — which is what stops a refused stage from moving the call
324
+ * into a confirmation with nothing behind it. */
325
+ send?:
326
+ | { type: "IDENTIFIED" }
327
+ | { type: "TRANSFERRED" }
328
+ | { type: "STAGED" }
329
+ | { type: "SETTLED" };
330
+ /**
331
+ * One line for the activity feed, from the call that SUCCEEDED.
196
332
  *
197
- * **Declare it BEFORE `summary` in the object literal.** TS infers this
198
- * wrapper's generic `R` from `execute`'s return type and processes an object
199
- * literal's properties in SOURCE ORDER, so with `summary` written first its
200
- * `result` parameter has nothing to infer from and silently falls back to
201
- * `unknown` every `isToolFailure(result) ? : result.order_id` in the
202
- * fifteen tool files then stops compiling, or worse, stops meaning anything.
203
- * It lives here rather than in each tool file because it is a property of
204
- * this type: the same four lines were pasted into eight of the fifteen and
205
- * pointed at from five more, which is a rule maintained in fourteen places.
333
+ * `R` is the SUCCESS type — `execute` below is declared `R | ToolFailure`, so
334
+ * the failure arm is matched against `ToolFailure` during inference and never
335
+ * lands in `R`. That is what lets the fifteen tool files stop writing
336
+ * `isToolFailure(result) ? "… failed" : …` for a case that cannot arrive: the
337
+ * wrapper never calls `summary` on a failure and now says so in the type.
206
338
  *
207
- * ---
339
+ * `NoInfer` takes this parameter out of the inference race `R` used to be
340
+ * decided by. It does NOT make the source order irrelevant — `execute` is an
341
+ * inline arrow whose parameters are contextually typed, so its return type is
342
+ * inferred in a LATER pass than this signature is checked, and a `summary`
343
+ * written above it still lands on `unknown`. What changed is that the failure
344
+ * is now LOUD: with no `isToolFailure` ternary left to swallow it, the wrong
345
+ * order is a `TS18046` on the first property read rather than a narrowing
346
+ * that quietly stops meaning anything. So the twelve-line warning this used
347
+ * to carry is the compiler's job now.
348
+ */
349
+ summary: (args: z.output<S>, result: NoInfer<R>) => string;
350
+ /**
351
+ * Handed the store as its second argument, and SYNCHRONOUS.
208
352
  *
209
353
  * **The draft is passed in rather than re-read**, which is the one change the
210
354
  * durable store forced on this template. The body used to open with
@@ -218,10 +362,16 @@ interface RetailToolSpec<S extends z.ZodType<Record<string, unknown>>, R> {
218
362
  * so none of them wants to; a tool that DID would await outside the wrapper and
219
363
  * call `retailSlot.update` itself, the way `plan-and-execute`'s do.
220
364
  */
221
- execute: (args: z.output<S>, state: RetailState, ctx: ToolContext) => R;
365
+ execute: (args: z.output<S>, state: RetailState, ctx: ToolContext) => R | ToolFailure;
222
366
  }
223
367
 
224
- function record(state: RetailState, name: string, summary: string): void {
368
+ /**
369
+ * One line of the activity feed, and the `callSeq` bump the sidebar moves on.
370
+ *
371
+ * Exported because {@link retailTool} is no longer its only caller: `agent.ts`
372
+ * records the calls that never reach a body — see {@link gateFor}.
373
+ */
374
+ export function record(state: RetailState, name: string, summary: string): void {
225
375
  state.callSeq += 1;
226
376
  pushCapped(
227
377
  state.activity,
@@ -231,13 +381,46 @@ function record(state: RetailState, name: string, summary: string): void {
231
381
  }
232
382
 
233
383
  /**
234
- * Every retail tool is built through this. It owns three things no tool body
235
- * may re-implement:
384
+ * Which states each retail tool may run in, by the name the model calls it by.
236
385
  *
237
- * 1. the authentication gate,
238
- * 2. serialization of the state mutation,
239
- * 3. the `callSeq` increment + activity entrythe reason the UI moves on
240
- * EVERY tool call rather than only when a projected value happens to differ.
386
+ * Populated as the fifteen `tools/` modules are loaded, because {@link retailTool}
387
+ * is what every one of them calls. It exists so `agent.ts`'s `tool.called` hook
388
+ * can ask the SAME question the gate asksthis is one fact evaluated twice, not
389
+ * two copies of a fact.
390
+ */
391
+ const TOOL_GATES = new Map<string, readonly string[]>();
392
+
393
+ /**
394
+ * The states `name` may run in, or `undefined` for a tool this template did not
395
+ * declare (a builtin, or anything a future author adds outside the wrapper).
396
+ */
397
+ export function gateFor(name: string): readonly string[] | undefined {
398
+ return TOOL_GATES.get(name);
399
+ }
400
+
401
+ /**
402
+ * Every retail tool is built through this. It owns two things no tool body may
403
+ * re-implement:
404
+ *
405
+ * 1. the mutation window the body's draft comes from,
406
+ * 2. the `callSeq` increment + activity entry — the reason the UI moves on
407
+ * every tool call rather than only when a projected value happens to differ.
408
+ *
409
+ * The third thing it used to own — the authentication gate — is
410
+ * {@link callFlow}'s now, declared per tool as `when`. What that buys is in the
411
+ * machine's own doc; what it COST was one line of the activity feed, because a
412
+ * refused call short-circuits before this wrapper's body runs and so recorded
413
+ * nothing and bumped no `callSeq`.
414
+ *
415
+ * **That line is back, and not from here.** `agent.ts` declares a `tool.called`
416
+ * hook, which the runtime emits for every call the model makes INCLUDING the
417
+ * ones the gate goes on to refuse — a place to observe from that a tool wrapper
418
+ * structurally does not have. See {@link gateFor}.
419
+ *
420
+ * **`callFlow.tool` rather than `retailSlot.updateTool`**, so the body opens the
421
+ * store's window itself. A flow tool's own `execute` is handed `(args, ctx)`;
422
+ * everything else about a tool body here is unchanged, including that it is
423
+ * synchronous — the window cannot span an await.
241
424
  *
242
425
  * `focus` is deliberately left to tool bodies (`setFocus`): it is a UI nicety,
243
426
  * not an invariant, and only the body knows what the call was about.
@@ -245,26 +428,26 @@ function record(state: RetailState, name: string, summary: string): void {
245
428
  export function retailTool<S extends z.ZodType<Record<string, unknown>>, R>(
246
429
  spec: RetailToolSpec<S, R>,
247
430
  ) {
248
- const requiresAuth = spec.requiresAuth ?? true;
249
- // `updateTool` rather than `tool` + a hand-written `retailSlot.update`: it runs
250
- // the body inside the slot's mutation window and hands it the draft, so this
251
- // wrapper is left with only what is specific to THIS agent.
252
- return retailSlot.updateTool({
431
+ TOOL_GATES.set(spec.name, typeof spec.when === "string" ? [spec.when] : spec.when);
432
+ return callFlow.tool({
253
433
  description: spec.description,
254
434
  inputSchema: spec.inputSchema,
255
- execute: (args, state, ctx) => {
256
- const typedArgs = args as z.output<S>;
257
- if (requiresAuth && !state.authenticatedUserId) {
258
- record(state, spec.name, "blocked: not authenticated");
259
- return toolFailure(NOT_AUTHENTICATED);
260
- }
261
- const result = spec.execute(typedArgs, state, ctx);
262
- record(
263
- state,
264
- spec.name,
265
- isToolFailure(result) ? `error: ${result.error}` : spec.summary(typedArgs, result),
266
- );
267
- return result;
268
- },
435
+ when: spec.when,
436
+ // `omitUndefined` rather than a conditional spread: `exactOptionalPropertyTypes`
437
+ // is on, so an explicit `send: undefined` is not the same as an absent one —
438
+ // and only three of the fifteen tools declare an event (guard-invariants
439
+ // rule 2).
440
+ ...omitUndefined({ send: spec.send }),
441
+ execute: (args, ctx) =>
442
+ retailSlot.update(ctx, (state) => {
443
+ const typedArgs = args as z.output<S>;
444
+ const result = spec.execute(typedArgs, state, ctx);
445
+ record(
446
+ state,
447
+ spec.name,
448
+ isToolFailure(result) ? `error: ${result.error}` : spec.summary(typedArgs, result),
449
+ );
450
+ return result;
451
+ }),
269
452
  });
270
453
  }
@@ -1,7 +1,7 @@
1
1
  import { isToolFailure } from "@alexkroman1/aai";
2
2
  import { describe, expect, test } from "vitest";
3
3
  import { createDefaultState, findUser } from "./store.ts";
4
- import { applySwap, assertCanCoverDiff, planItemSwap } from "./swap.ts";
4
+ import { applySwapLines, assertCanCoverDiff, planItemSwap, toSwapLines } from "./swap.ts";
5
5
 
6
6
  function fixture(orderId: string) {
7
7
  const state = createDefaultState();
@@ -152,7 +152,7 @@ describe("assertCanCoverDiff", () => {
152
152
  });
153
153
  });
154
154
 
155
- describe("applySwap", () => {
155
+ describe("applySwapLines", () => {
156
156
  test("each swapped line takes its OWN new price and options", () => {
157
157
  const { state, order } = fixture("#W9311069");
158
158
  // Two different products in one call — this is what catches the leaked
@@ -165,7 +165,7 @@ describe("applySwap", () => {
165
165
  { requireDifferent: true },
166
166
  );
167
167
  if (isToolFailure(plan)) throw new Error(plan.error);
168
- applySwap(order, plan);
168
+ applySwapLines(order, toSwapLines(plan));
169
169
 
170
170
  const vacuum = order.items.find((i) => i.item_id === "4725166838");
171
171
  const kettle = order.items.find((i) => i.item_id === "3909406921");
@@ -183,7 +183,7 @@ describe("applySwap", () => {
183
183
  requireDifferent: true,
184
184
  });
185
185
  if (isToolFailure(plan)) throw new Error(plan.error);
186
- applySwap(order, plan);
186
+ applySwapLines(order, toSwapLines(plan));
187
187
  expect(order.items.map((i) => i.item_id)).toEqual(["3909406921", "7292993796"]);
188
188
  });
189
189
  });