@alexkroman1/aai-cli 7.0.0 → 8.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +1 -0
  2. package/dist/{_bundler-B4RqNF5Z.mjs → _bundler-CDuYl5Gb.mjs} +1 -1
  3. package/dist/{_dev-server-CiKFH9qw.mjs → _dev-server-Dt3TCjhj.mjs} +6 -2
  4. package/dist/{_init-CE4HKdgV.mjs → _init-BhEfQ6Yi.mjs} +1 -1
  5. package/dist/_resource-commands.d.ts +23 -0
  6. package/dist/{_slug-api-nRFaBEPJ.mjs → _slug-api-z-USxPtL.mjs} +14 -10
  7. package/dist/_slug-api.d.ts +5 -2
  8. package/dist/{_templates-FI3xPEMj.mjs → _templates-4WcKOjS5.mjs} +4 -5
  9. package/dist/_templates.d.ts +3 -4
  10. package/dist/{build-6jfaakgc.mjs → build-BBxf8vom.mjs} +2 -2
  11. package/dist/cli.mjs +338 -346
  12. package/dist/{deploy-DvYeD8ia.mjs → deploy-DRC8jWr8.mjs} +1 -1
  13. package/dist/{dev-B59JIfWO.mjs → dev-QuuOOUtv.mjs} +1 -1
  14. package/dist/eval-DfQ8cm5p.mjs +56 -0
  15. package/dist/eval.d.ts +43 -0
  16. package/dist/{init-CpJVjEg0.mjs → init-CUo87gcI.mjs} +2 -2
  17. package/dist/{logs-9q-psa4q.mjs → logs-CQ2T46dr.mjs} +1 -1
  18. package/dist/scaffold/CLAUDE.md +409 -68
  19. package/dist/scaffold/package.json +5 -4
  20. package/dist/scaffold/server.mjs +10 -3
  21. package/dist/scaffold/tsconfig.json +0 -1
  22. package/dist/{secret-CVvSLIDV.mjs → secret-Ct34kdgU.mjs} +1 -1
  23. package/dist/{storage-BvUrnvM3.mjs → storage-i_79ZwAR.mjs} +1 -1
  24. package/dist/{studio--MUV0cid.mjs → studio-CzTzPqe1.mjs} +1 -1
  25. package/dist/templates/briefing-desk/agent.test.ts +350 -0
  26. package/dist/templates/briefing-desk/agent.ts +30 -0
  27. package/dist/templates/briefing-desk/shared.ts +200 -0
  28. package/dist/templates/briefing-desk/system-prompt.md +39 -0
  29. package/dist/templates/briefing-desk/tools/briefing_so_far.ts +35 -0
  30. package/dist/templates/briefing-desk/tools/research_topic.ts +88 -0
  31. package/dist/templates/briefing-desk/tools/verify_claim.ts +77 -0
  32. package/dist/templates/call-audit/agent.eval.test.ts +474 -0
  33. package/dist/templates/code-interpreter/agent.eval.test.ts +165 -0
  34. package/dist/templates/dispatch-center/agent.eval.test.ts +349 -0
  35. package/dist/templates/embedded-assets/agent.eval.test.ts +138 -0
  36. package/dist/templates/health-assistant/agent.eval.test.ts +167 -0
  37. package/dist/templates/infocom-adventure/agent.eval.test.ts +158 -0
  38. package/dist/templates/infocom-adventure/agent.test.ts +49 -18
  39. package/dist/templates/infocom-adventure/agent.ts +21 -0
  40. package/dist/templates/infocom-adventure/shared.ts +20 -9
  41. package/dist/templates/infocom-adventure/system-prompt.md +1 -1
  42. package/dist/templates/infocom-adventure/tools/game_state_move.ts +3 -2
  43. package/dist/templates/link-digest/agent.eval.test.ts +195 -0
  44. package/dist/templates/math-buddy/agent.eval.test.ts +146 -0
  45. package/dist/templates/night-owl/agent.eval.test.ts +272 -0
  46. package/dist/templates/personal-finance/agent.eval.test.ts +168 -0
  47. package/dist/templates/personal-finance/system-prompt.md +4 -0
  48. package/dist/templates/pipeline-simple/agent.eval.test.ts +78 -0
  49. package/dist/templates/pipeline-simple/agent.ts +2 -2
  50. package/dist/templates/pizza-ordering/agent.eval.test.ts +171 -0
  51. package/dist/templates/pizza-ordering/agent.test.ts +15 -0
  52. package/dist/templates/pizza-ordering/shared.ts +20 -1
  53. package/dist/templates/pizza-ordering/system-prompt.md +6 -0
  54. package/dist/templates/plan-and-execute/agent.eval.test.ts +209 -0
  55. package/dist/templates/podcast-digest/agent.eval.test.ts +411 -0
  56. package/dist/templates/podcast-digest/agent.test.ts +30 -59
  57. package/dist/templates/podcast-digest/agent.ts +1 -1
  58. package/dist/templates/podcast-digest/workflows/digest.ts +52 -19
  59. package/dist/templates/podcast-digest/workflows/slack.ts +48 -169
  60. package/dist/templates/recap-workflow/agent.eval.test.ts +366 -0
  61. package/dist/templates/redline/agent.eval.test.ts +209 -0
  62. package/dist/templates/research-workflow/agent.eval.test.ts +329 -0
  63. package/dist/templates/retail/address.ts +87 -1
  64. package/dist/templates/retail/agent.eval.test.ts +314 -0
  65. package/dist/templates/retail/agent.test.ts +171 -91
  66. package/dist/templates/retail/agent.ts +35 -1
  67. package/dist/templates/retail/cancel.ts +116 -0
  68. package/dist/templates/retail/client.tsx +39 -0
  69. package/dist/templates/retail/payment.ts +107 -0
  70. package/dist/templates/retail/pending.test.ts +196 -0
  71. package/dist/templates/retail/pending.ts +137 -0
  72. package/dist/templates/retail/registry.test.ts +119 -8
  73. package/dist/templates/retail/returns.ts +102 -0
  74. package/dist/templates/retail/shared.test.ts +42 -0
  75. package/dist/templates/retail/shared.ts +35 -0
  76. package/dist/templates/retail/store.test.ts +6 -4
  77. package/dist/templates/retail/store.ts +113 -57
  78. package/dist/templates/retail/swap.test.ts +4 -4
  79. package/dist/templates/retail/swap.ts +248 -15
  80. package/dist/templates/retail/system-prompt.md +62 -30
  81. package/dist/templates/retail/tools/cancel_change.ts +33 -0
  82. package/dist/templates/retail/tools/cancel_pending_order.ts +15 -59
  83. package/dist/templates/retail/tools/confirm_change.ts +42 -0
  84. package/dist/templates/retail/tools/exchange_delivered_order_items.ts +21 -66
  85. package/dist/templates/retail/tools/modify_pending_order_address.ts +12 -24
  86. package/dist/templates/retail/tools/modify_pending_order_items.ts +22 -60
  87. package/dist/templates/retail/tools/modify_pending_order_payment.ts +15 -77
  88. package/dist/templates/retail/tools/modify_user_address.ts +12 -15
  89. package/dist/templates/retail/tools/return_delivered_order_items.ts +16 -71
  90. package/dist/templates/retail/tools/transfer_to_human_agents.ts +4 -2
  91. package/dist/templates/simple/agent.eval.test.ts +54 -0
  92. package/dist/templates/solo-rpg/agent.eval.test.ts +320 -0
  93. package/dist/templates/spoken-summary/agent.eval.test.ts +342 -0
  94. package/dist/templates/support-line/agent.eval.test.ts +273 -0
  95. package/dist/templates/support-line/system-prompt.md +2 -1
  96. package/dist/templates/support-line/tools/answer_question.ts +56 -7
  97. package/dist/templates/transcription-workflow/agent.eval.test.ts +234 -0
  98. package/dist/templates/travel-concierge/agent.eval.test.ts +323 -0
  99. package/dist/templates/travel-concierge/agent.test.ts +110 -10
  100. package/dist/templates/travel-concierge/agent.ts +18 -9
  101. package/dist/templates/travel-concierge/shared.ts +110 -9
  102. package/dist/templates/travel-concierge/system-prompt.md +7 -1
  103. package/dist/templates/travel-concierge/tools/book_car_rental.ts +6 -3
  104. package/dist/templates/travel-concierge/tools/book_excursion.ts +6 -3
  105. package/dist/templates/travel-concierge/tools/book_hotel.ts +6 -3
  106. package/dist/templates/travel-concierge/tools/cancel_ticket.ts +6 -3
  107. package/dist/templates/travel-concierge/tools/search_car_rentals.ts +9 -5
  108. package/dist/templates/travel-concierge/tools/search_excursions.ts +9 -5
  109. package/dist/templates/travel-concierge/tools/search_flights.ts +9 -6
  110. package/dist/templates/travel-concierge/tools/search_hotels.ts +9 -5
  111. package/dist/templates/travel-concierge/tools/update_ticket.ts +6 -3
  112. package/dist/templates/web-researcher/agent.eval.test.ts +121 -0
  113. package/dist/templates/web-researcher/system-prompt.md +15 -4
  114. package/dist/{test-9kPKJI-w.mjs → test-BvyT6sAc.mjs} +24 -10
  115. package/dist/test.d.ts +29 -5
  116. package/dist/{worker-bundler-COxnqstQ.mjs → worker-bundler-CCVEDjm1.mjs} +45 -25
  117. package/dist/worker-bundler.mjs +1 -1
  118. package/dist/{workflow-D2AQf2Pl.mjs → workflow-BflATMmU.mjs} +19 -11
  119. package/package.json +5 -5
  120. package/dist/eject-C9WJyyr2.mjs +0 -116
  121. package/dist/eject.d.ts +0 -60
  122. package/dist/templates/infocom-adventure/tools/game_state_history.ts +0 -14
@@ -76,51 +76,50 @@ export const retailSlot = sessionSlot("retail", createDefaultState);
76
76
  /**
77
77
  * Where this call is, and what may be done from here, as a plain state map.
78
78
  *
79
- * A {@link DialogSpec} rather than an XState machine: this dialog said three
80
- * states, two events and an instruction each, which is exactly what a spec can
81
- * say and the `setup({ types: {} as { events: } })` block it used to carry
82
- * restated the two event names already written in the `on` maps. The
83
- * instruction is a declared field now instead of an untyped `meta` bag, so
84
- * misspelling it is a compile error rather than a refusal that arrives with no
85
- * recovery text. `as const` is what keeps the `on` keys literal, so `send`
86
- * below is checked against the events this spec actually declares.
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.
87
85
  *
88
- * The policy's first two sections "Authenticate first" and "Handing off to a
89
- * human" — used to be prose plus a boolean. `requiresAuth` was that boolean:
90
- * fifteen tools declared it (five opting out), the wrapper below read it, and a
91
- * refusal answered one fixed sentence. Three things change by declaring the
92
- * states instead.
86
+ * Three of the policy's sections live here rather than in prose.
93
87
  *
94
- * **`transferred` is a real terminal state, and it was not enforced at all.**
95
- * The policy says to call `transfer_to_human_agents` and then say exactly one
96
- * sentence "and nothing else" — which nothing checked, so every tool stayed
97
- * callable after the handoff and a model that kept going would keep acting on a
98
- * call it had already given away. It is a `final` state now: no tool declares
99
- * itself legal there, so every one of them refuses.
88
+ * **"Authenticate first."** `identifying`'s instruction names both finder
89
+ * tools, and ten tools declare `when: "serving"`, so the gate is the SDK's.
100
90
  *
101
- * **The refusal quotes the state.** `when` names where a tool may run and the
102
- * SDK writes the sentence, so the message is one thing rather than a constant
103
- * threaded through a wrapper and it says where the call actually is, which
104
- * "Not authenticated" could not.
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.
105
99
  *
106
- * **The position rides every result.** A flow tool answers the author's value
107
- * wrapped in the position it landed in, so the stage and its instruction reach
108
- * the model on every call rather than only when the prompt is still in context.
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.
109
105
  *
110
- * `IDENTIFIED` is declared on `serving` as well, because a caller repeating
111
- * their email must not hit an error see `authenticateAs`, which is what
112
- * refuses a switch to a DIFFERENT customer. Whether the flow is identified and
113
- * WHO it is identified as are two facts: this holds the first,
114
- * `authenticatedUserId` holds the second.
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.
115
116
  */
116
117
  const callSpec = {
117
118
  initial: "identifying",
118
119
  states: {
119
120
  identifying: {
120
121
  // The instruction NAMES the two tools, because this sentence is what a
121
- // refusal quotes and a refusal is the model's recovery path — the same
122
- // job the removed `NOT_AUTHENTICATED` constant did, now attached to the
123
- // state that means it.
122
+ // refusal quotes and a refusal is the model's recovery path.
124
123
  instruction:
125
124
  "You do not know who this is yet. Identify the caller with " +
126
125
  "find_user_id_by_email, or find_user_id_by_name_zip if they cannot " +
@@ -128,11 +127,25 @@ const callSpec = {
128
127
  on: { IDENTIFIED: "serving", TRANSFERRED: "transferred" },
129
128
  },
130
129
  serving: {
131
- instruction:
132
- "You are helping one identified customer, and only that one. Say what you " +
133
- "are about to change — the order, the items, the amounts, where the money " +
134
- "goes — and wait for an explicit yes before you call anything that changes it.",
135
- on: { IDENTIFIED: "serving", TRANSFERRED: "transferred" },
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
+ },
136
149
  },
137
150
  transferred: {
138
151
  final: true,
@@ -145,13 +158,19 @@ const callSpec = {
145
158
 
146
159
  /**
147
160
  * The flow. Its own slot key beside {@link retailSlot}: the flow holds the
148
- * POSITION and the store holds the customer, the orders and the activity feed.
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.
149
165
  */
150
166
  export const callFlow = dialog("call", callSpec);
151
167
 
152
168
  /** Every state a tool may run in before the call is handed to a human — i.e.
153
- * everything but `transferred`. What the five formerly `requiresAuth: false`
154
- * tools declare, so the terminal state gates them without an auth gate. */
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. */
155
174
  export const BEFORE_TRANSFER = ["identifying", "serving"] as const;
156
175
 
157
176
  export function setFocus(
@@ -287,17 +306,27 @@ interface RetailToolSpec<S extends z.ZodType<Record<string, unknown>>, R> {
287
306
  /**
288
307
  * The state(s) this tool may run in, as `callFlow`'s states spell them.
289
308
  *
290
- * Replaces the `requiresAuth` boolean this spec used to carry. Ten tools want
291
- * `"serving"`; the two finders, the three catalog reads and the transfer want
292
- * {@link BEFORE_TRANSFER}, which is every state but the terminal one so
293
- * "does not need a customer" and "is still legal after the handoff" stopped
294
- * being the same claim, and they were never the same claim.
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.
295
317
  */
296
318
  when: string | readonly string[];
297
319
  /** The event to send once the body has succeeded, for a tool that MOVES the
298
- * call. Three do: the two finders send `IDENTIFIED`, the transfer sends
299
- * `TRANSFERRED`. Nothing is sent when the body answers a `ToolFailure`. */
300
- send?: { type: "IDENTIFIED" } | { type: "TRANSFERRED" };
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" };
301
330
  /**
302
331
  * One line for the activity feed, from the call that SUCCEEDED.
303
332
  *
@@ -336,7 +365,13 @@ interface RetailToolSpec<S extends z.ZodType<Record<string, unknown>>, R> {
336
365
  execute: (args: z.output<S>, state: RetailState, ctx: ToolContext) => R | ToolFailure;
337
366
  }
338
367
 
339
- 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 {
340
375
  state.callSeq += 1;
341
376
  pushCapped(
342
377
  state.activity,
@@ -345,6 +380,24 @@ function record(state: RetailState, name: string, summary: string): void {
345
380
  );
346
381
  }
347
382
 
383
+ /**
384
+ * Which states each retail tool may run in, by the name the model calls it by.
385
+ *
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 asks — this 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
+
348
401
  /**
349
402
  * Every retail tool is built through this. It owns two things no tool body may
350
403
  * re-implement:
@@ -355,12 +408,14 @@ function record(state: RetailState, name: string, summary: string): void {
355
408
  *
356
409
  * The third thing it used to own — the authentication gate — is
357
410
  * {@link callFlow}'s now, declared per tool as `when`. What that buys is in the
358
- * machine's own doc; what it COSTS is one line of the activity feed: a refused
359
- * call short-circuits before this wrapper's body runs, so a blocked call no
360
- * longer records `blocked: not authenticated` and no longer bumps `callSeq`.
361
- * That is the right trade — the refusal reaches the model, which the sidebar
362
- * line never did, and it carries the state and its instruction rather than one
363
- * fixed sentence.
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}.
364
419
  *
365
420
  * **`callFlow.tool` rather than `retailSlot.updateTool`**, so the body opens the
366
421
  * store's window itself. A flow tool's own `execute` is handed `(args, ctx)`;
@@ -373,6 +428,7 @@ function record(state: RetailState, name: string, summary: string): void {
373
428
  export function retailTool<S extends z.ZodType<Record<string, unknown>>, R>(
374
429
  spec: RetailToolSpec<S, R>,
375
430
  ) {
431
+ TOOL_GATES.set(spec.name, typeof spec.when === "string" ? [spec.when] : spec.when);
376
432
  return callFlow.tool({
377
433
  description: spec.description,
378
434
  inputSchema: spec.inputSchema,
@@ -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
  });
@@ -1,6 +1,22 @@
1
+ /**
2
+ * Swapping items for other options of the same product — the validation both
3
+ * item changes share, and the two actions built on it (modifying a pending
4
+ * order, exchanging a delivered one) as plan/apply pairs.
5
+ *
6
+ * See `cancel.ts` for why every mutating action is split that way.
7
+ */
8
+
1
9
  import { isToolFailure, type ToolFailure } from "@alexkroman1/aai";
10
+ import { resolveOrder } from "./resolve.ts";
2
11
  import type { Order, OrderItem, RetailState, User, Variant } from "./shared.ts";
3
- import { findPaymentMethod, findProduct, findVariant, isGiftCard, money } from "./store.ts";
12
+ import {
13
+ authenticatedUser,
14
+ findPaymentMethod,
15
+ findProduct,
16
+ findVariant,
17
+ isGiftCard,
18
+ money,
19
+ } from "./store.ts";
4
20
 
5
21
  export interface SwapPair {
6
22
  /** Index into `order.items`. Matching by index rather than by "first item
@@ -17,12 +33,30 @@ export interface SwapPlan {
17
33
  }
18
34
 
19
35
  /**
20
- * Validate a proposed item swap and compute its price difference, without
21
- * mutating anything. Shared by `modify_pending_order_items` (pending, which
22
- * additionally forbids a no-op swap) and `exchange_delivered_order_items`
23
- * (delivered, which does not).
36
+ * One priced swap, flattened to primitives.
24
37
  *
25
- * Validation order matches tau2's so a bad call fails on the same check.
38
+ * A {@link SwapPair} holds live `OrderItem` and `Variant` references into the
39
+ * store, which is right for a plan computed and applied inside one tool call
40
+ * and wrong for one that waits in a session slot for the caller to say yes —
41
+ * see `CancelPlan` in `cancel.ts`. This is the storable form, and it carries enough that
42
+ * {@link applySwapLines} needs no lookup at all.
43
+ */
44
+ export interface SwapLine {
45
+ index: number;
46
+ fromItemId: string;
47
+ fromName: string;
48
+ fromOptions: Record<string, string>;
49
+ fromPrice: number;
50
+ toItemId: string;
51
+ toName: string;
52
+ toOptions: Record<string, string>;
53
+ toPrice: number;
54
+ }
55
+
56
+ /**
57
+ * Validate a proposed item swap and compute its price difference, without
58
+ * mutating anything. Shared by the pending-order change (which additionally
59
+ * forbids a no-op swap) and the delivered-order exchange (which does not).
26
60
  */
27
61
  export function planItemSwap(
28
62
  state: RetailState,
@@ -103,6 +137,23 @@ export function planItemSwap(
103
137
  return { pairs, diff: money(diff) };
104
138
  }
105
139
 
140
+ /** Flatten a freshly-computed plan into the form a staged action can hold. */
141
+ export function toSwapLines(plan: SwapPlan): SwapLine[] {
142
+ return plan.pairs.map((pair) => ({
143
+ index: pair.index,
144
+ fromItemId: pair.item.item_id,
145
+ fromName: pair.item.name,
146
+ fromOptions: { ...pair.item.options },
147
+ fromPrice: pair.item.price,
148
+ toItemId: pair.newVariant.item_id,
149
+ // Same product by construction — a swap can only reach another option of
150
+ // it — so the name is the item's own and the OPTIONS are what differ.
151
+ toName: pair.item.name,
152
+ toOptions: { ...pair.newVariant.options },
153
+ toPrice: pair.newVariant.price,
154
+ }));
155
+ }
156
+
106
157
  /**
107
158
  * Gate the price difference on the chosen payment method. Only a gift card has
108
159
  * a balance to run out of; a negative difference is a refund and never gated.
@@ -118,14 +169,196 @@ export function assertCanCoverDiff(user: User, methodId: string, diff: number):
118
169
  return null;
119
170
  }
120
171
 
121
- /** Apply a validated plan. Each line takes its OWN new variant's price, options
122
- * and name — see the module note on tau2's leaked loop variable. */
123
- export function applySwap(order: Order, plan: SwapPlan): void {
124
- for (const pair of plan.pairs) {
125
- const line = order.items[pair.index];
126
- if (!line) continue;
127
- line.item_id = pair.newVariant.item_id;
128
- line.price = pair.newVariant.price;
129
- line.options = pair.newVariant.options;
172
+ /** Apply validated lines. Each takes its OWN new option's price and options
173
+ * see the module note on tau2's leaked loop variable. */
174
+ export function applySwapLines(order: Order, lines: readonly SwapLine[]): void {
175
+ for (const line of lines) {
176
+ const item = order.items[line.index];
177
+ if (!item) continue;
178
+ item.item_id = line.toItemId;
179
+ item.price = line.toPrice;
180
+ item.options = { ...line.toOptions };
181
+ }
182
+ }
183
+
184
+ /** "the glass 2 litre one for the stainless steel 1 litre one" — how a swap is
185
+ * said out loud. Item numbers are never in it; the prompt forbids reading one. */
186
+ function describeLine(line: SwapLine): string {
187
+ const from = Object.values(line.fromOptions).join(" ");
188
+ const to = Object.values(line.toOptions).join(" ");
189
+ return `the ${from} ${line.fromName} for the ${to} one`;
190
+ }
191
+
192
+ function describeDiff(diff: number, methodId: string): string {
193
+ if (diff > 0) return `$${diff.toFixed(2)} charged to ${methodId}`;
194
+ if (diff < 0) return `$${Math.abs(diff).toFixed(2)} refunded to ${methodId}`;
195
+ return "no price difference";
196
+ }
197
+
198
+ // ─── Changing the items in a pending order ───────────────────────────────────
199
+
200
+ export interface ModifyItemsPlan {
201
+ readBack: string;
202
+ orderId: string;
203
+ lines: SwapLine[];
204
+ diff: number;
205
+ paymentMethodId: string;
206
+ }
207
+
208
+ export function planModifyItems(
209
+ state: RetailState,
210
+ spokenOrderId: string,
211
+ itemIds: string[],
212
+ newItemIds: string[],
213
+ paymentMethodId: string,
214
+ ): ModifyItemsPlan | ToolFailure {
215
+ const user = authenticatedUser(state);
216
+ if (isToolFailure(user)) return user;
217
+
218
+ const order = resolveOrder(state, spokenOrderId);
219
+ if (isToolFailure(order)) return order;
220
+
221
+ // Exactly 'pending'. A 'pending (item modified)' order has already used its
222
+ // one modification, which is what makes this action terminal.
223
+ if (order.status !== "pending") {
224
+ return {
225
+ error: `Order ${order.order_id} is ${order.status}. Items can only be changed while an order is exactly 'pending', and only once.`,
226
+ };
227
+ }
228
+
229
+ const plan = planItemSwap(state, order, itemIds, newItemIds, { requireDifferent: true });
230
+ if (isToolFailure(plan)) return plan;
231
+
232
+ const blocked = assertCanCoverDiff(user, paymentMethodId, plan.diff);
233
+ if (blocked) return blocked;
234
+
235
+ const lines = toSwapLines(plan);
236
+ return {
237
+ readBack:
238
+ `swap ${lines.map(describeLine).join(", and ")} on order ${order.order_id}, ` +
239
+ `with ${describeDiff(plan.diff, paymentMethodId)} — and this is the ONE change that order ` +
240
+ "allows: after it, it can no longer be cancelled or modified by anyone",
241
+ orderId: order.order_id,
242
+ lines,
243
+ diff: plan.diff,
244
+ paymentMethodId,
245
+ };
246
+ }
247
+
248
+ export function applyModifyItems(state: RetailState, plan: ModifyItemsPlan) {
249
+ const order = state.store.orders[plan.orderId];
250
+ const user = order ? state.store.users[order.user_id] : undefined;
251
+
252
+ if (order && user) {
253
+ const method = user.payment_methods[plan.paymentMethodId];
254
+ if (method && isGiftCard(method)) {
255
+ method.balance = money(method.balance - plan.diff);
256
+ }
257
+ order.payment_history.push({
258
+ transaction_type: plan.diff > 0 ? "payment" : "refund",
259
+ amount: money(Math.abs(plan.diff)),
260
+ payment_method_id: plan.paymentMethodId,
261
+ });
262
+ applySwapLines(order, plan.lines);
263
+ order.status = "pending (item modified)";
130
264
  }
265
+
266
+ return {
267
+ order_id: plan.orderId,
268
+ status: "pending (item modified)" as const,
269
+ price_difference: plan.diff,
270
+ items: (order?.items ?? []).map((item) => ({
271
+ name: item.name,
272
+ item_id: item.item_id,
273
+ options: item.options,
274
+ price: item.price,
275
+ })),
276
+ message:
277
+ plan.diff > 0
278
+ ? `Done. $${plan.diff.toFixed(2)} was charged to ${plan.paymentMethodId}. This order can no longer be modified or cancelled.`
279
+ : `Done. $${Math.abs(plan.diff).toFixed(2)} is being refunded to ${plan.paymentMethodId}. This order can no longer be modified or cancelled.`,
280
+ };
281
+ }
282
+
283
+ // ─── Exchanging a delivered order ────────────────────────────────────────────
284
+
285
+ export interface ExchangePlan {
286
+ readBack: string;
287
+ orderId: string;
288
+ lines: SwapLine[];
289
+ diff: number;
290
+ paymentMethodId: string;
291
+ }
292
+
293
+ export function planExchange(
294
+ state: RetailState,
295
+ spokenOrderId: string,
296
+ itemIds: string[],
297
+ newItemIds: string[],
298
+ paymentMethodId: string,
299
+ ): ExchangePlan | ToolFailure {
300
+ const user = authenticatedUser(state);
301
+ if (isToolFailure(user)) return user;
302
+
303
+ const order = resolveOrder(state, spokenOrderId);
304
+ if (isToolFailure(order)) return order;
305
+
306
+ if (order.status !== "delivered") {
307
+ return {
308
+ error: `Order ${order.order_id} is ${order.status}. Only a delivered order can be exchanged, and only once.`,
309
+ };
310
+ }
311
+
312
+ // requireDifferent is false: a zero-difference line is harmless on a
313
+ // delivered order, and refusing one would reject a caller who listed every
314
+ // item and changed their mind about only some.
315
+ const plan = planItemSwap(state, order, itemIds, newItemIds, { requireDifferent: false });
316
+ if (isToolFailure(plan)) return plan;
317
+
318
+ const blocked = assertCanCoverDiff(user, paymentMethodId, plan.diff);
319
+ if (blocked) return blocked;
320
+
321
+ const lines = toSwapLines(plan);
322
+ return {
323
+ readBack:
324
+ `exchange ${lines.map(describeLine).join(", and ")} on order ${order.order_id}, ` +
325
+ `with ${describeDiff(plan.diff, paymentMethodId)}`,
326
+ orderId: order.order_id,
327
+ lines,
328
+ diff: plan.diff,
329
+ paymentMethodId,
330
+ };
331
+ }
332
+
333
+ export function applyExchange(state: RetailState, plan: ExchangePlan) {
334
+ const order = state.store.orders[plan.orderId];
335
+
336
+ // The pairing AS PRICED, in the order the caller gave it. tau2 stored these
337
+ // as two INDEPENDENTLY SORTED lists, which permuted the second against the
338
+ // first whenever more than one item was named — two sets, not a pairing, and
339
+ // the result had to carry the real pairing separately to avoid telling the
340
+ // caller a quote that was not the one they got. Nothing here is compared
341
+ // against a tau2 end state any more, so the fields hold the pairing itself.
342
+ if (order) {
343
+ order.status = "exchange requested";
344
+ order.exchange_items = plan.lines.map((line) => line.fromItemId);
345
+ order.exchange_new_items = plan.lines.map((line) => line.toItemId);
346
+ order.exchange_payment_method_id = plan.paymentMethodId;
347
+ order.exchange_price_difference = plan.diff;
348
+ }
349
+
350
+ return {
351
+ order_id: plan.orderId,
352
+ status: "exchange requested" as const,
353
+ price_difference: plan.diff,
354
+ exchanges: plan.lines.map((line) => ({
355
+ item_id: line.fromItemId,
356
+ new_item_id: line.toItemId,
357
+ price_difference: money(line.toPrice - line.fromPrice),
358
+ })),
359
+ message:
360
+ plan.diff > 0
361
+ ? `Exchange requested on ${plan.orderId}. $${plan.diff.toFixed(2)} will be charged to ${plan.paymentMethodId}. An email with return instructions is on its way.`
362
+ : `Exchange requested on ${plan.orderId}. $${Math.abs(plan.diff).toFixed(2)} will be refunded to ${plan.paymentMethodId}. An email with return instructions is on its way.`,
363
+ };
131
364
  }