@alexkroman1/aai-cli 7.0.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 (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-Me06wgeF.mjs} +2 -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-BvcctCn2.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
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Cancelling a pending order, as a PLAN and an APPLY.
3
+ *
4
+ * The split is what the confirmation gate is built out of, and it is the same
5
+ * split in all seven mutating actions here: `plan*` validates everything and
6
+ * computes the effect without touching the store, `apply*` performs it and
7
+ * cannot fail. A staged plan is what the caller says yes to, so the validation
8
+ * has to happen BEFORE the readback — a "yes" followed by a refusal is the one
9
+ * sequence this gate exists to make impossible.
10
+ */
11
+
12
+ import { isToolFailure, type ToolFailure } from "@alexkroman1/aai";
13
+ import { creditRefund, REFUND_DELAY_NOTE, REFUND_IMMEDIATE_NOTE } from "./refund.ts";
14
+ import { resolveOrder } from "./resolve.ts";
15
+ import type { RetailState } from "./shared.ts";
16
+ import { authenticatedUser, money } from "./store.ts";
17
+
18
+ /** The only two reasons the store records. */
19
+ export const CANCEL_REASONS = ["no longer needed", "ordered by mistake"] as const;
20
+ export type CancelReason = (typeof CANCEL_REASONS)[number];
21
+
22
+ /**
23
+ * A validated cancellation, holding only storable primitives.
24
+ *
25
+ * No `Order` or `PaymentMethod` reference: a plan is written into the session
26
+ * slot and rides across turns, so a live object here would alias the store and
27
+ * a persisted session could not carry it. Ids and amounts are enough, and
28
+ * looking them up again at apply time is what keeps {@link applyCancel}
29
+ * total.
30
+ */
31
+ export interface CancelPlan {
32
+ /** The sentence the agent reads back before asking for a yes. */
33
+ readBack: string;
34
+ orderId: string;
35
+ reason: CancelReason;
36
+ refunds: { methodId: string; amount: number }[];
37
+ total: number;
38
+ }
39
+
40
+ export function planCancel(
41
+ state: RetailState,
42
+ spokenOrderId: string,
43
+ reason: CancelReason,
44
+ ): CancelPlan | ToolFailure {
45
+ const user = authenticatedUser(state);
46
+ if (isToolFailure(user)) return user;
47
+
48
+ const order = resolveOrder(state, spokenOrderId);
49
+ if (isToolFailure(order)) return order;
50
+
51
+ // The enum makes this unreachable from a well-formed call; it stays because
52
+ // the reason is the one field a caller supplies in free speech, and an LLM
53
+ // tool call arrives untyped.
54
+ if (!CANCEL_REASONS.includes(reason)) {
55
+ return {
56
+ error: `'${reason}' is not an accepted cancellation reason. It must be 'no longer needed' or 'ordered by mistake'.`,
57
+ };
58
+ }
59
+
60
+ // Exactly 'pending'. A 'pending (item modified)' order has spent its one
61
+ // modification and is past cancelling.
62
+ if (order.status !== "pending") {
63
+ return {
64
+ error: `Order ${order.order_id} is ${order.status}, and only a pending order can be cancelled.`,
65
+ };
66
+ }
67
+
68
+ const refunds = order.payment_history
69
+ .filter((payment) => payment.transaction_type === "payment")
70
+ .map((payment) => ({ methodId: payment.payment_method_id, amount: payment.amount }));
71
+ const total = money(refunds.reduce((sum, refund) => sum + refund.amount, 0));
72
+
73
+ const items = order.items.map((item) => item.name).join(", ");
74
+ const destinations = [...new Set(refunds.map((refund) => refund.methodId))].join(" and ");
75
+ return {
76
+ readBack:
77
+ `cancel order ${order.order_id} (${items}) as '${reason}' and refund ` +
78
+ `$${total.toFixed(2)} to ${destinations}`,
79
+ orderId: order.order_id,
80
+ reason,
81
+ refunds,
82
+ total,
83
+ };
84
+ }
85
+
86
+ /** The effect. Total by construction: every id in the plan was resolved from
87
+ * this same store, and nothing between staging and confirming can remove one. */
88
+ export function applyCancel(state: RetailState, plan: CancelPlan) {
89
+ const order = state.store.orders[plan.orderId];
90
+ const user = order ? state.store.users[order.user_id] : undefined;
91
+ let immediate = false;
92
+
93
+ if (order && user) {
94
+ for (const refund of plan.refunds) {
95
+ immediate = creditRefund(user, refund.methodId, refund.amount).immediate || immediate;
96
+ order.payment_history.push({
97
+ transaction_type: "refund",
98
+ amount: refund.amount,
99
+ payment_method_id: refund.methodId,
100
+ });
101
+ }
102
+ order.status = "cancelled";
103
+ order.cancel_reason = plan.reason;
104
+ }
105
+
106
+ return {
107
+ order_id: plan.orderId,
108
+ status: "cancelled" as const,
109
+ cancel_reason: plan.reason,
110
+ refunded: plan.total,
111
+ refund_immediate: immediate,
112
+ message: `Order ${plan.orderId} is cancelled and $${plan.total.toFixed(2)} is being refunded. ${
113
+ immediate ? REFUND_IMMEDIATE_NOTE : REFUND_DELAY_NOTE
114
+ }`,
115
+ };
116
+ }
@@ -13,6 +13,7 @@ import type {
13
13
  OrderStatus,
14
14
  OrderView,
15
15
  PaymentMethodView,
16
+ PendingView,
16
17
  StoreView,
17
18
  SwapOptionView,
18
19
  } from "./shared.ts";
@@ -155,6 +156,42 @@ function OrderCard({ order, focused }: { order: OrderView; focused: boolean }) {
155
156
 
156
157
  /** Character select. Rendered before authentication, because a user with no
157
158
  * email to give cannot start the conversation at all. */
159
+ /**
160
+ * The change waiting on the caller's word.
161
+ *
162
+ * The one panel that renders BECAUSE nothing has happened. A staged change is
163
+ * the agent's promise about what it is going to do, so showing it beside the
164
+ * orders it has not touched is what lets a watcher catch a readback that does
165
+ * not match the request — which is the failure the whole gate is aimed at, and
166
+ * the one nobody can see from a transcript alone.
167
+ */
168
+ function PendingChange({ pending }: { pending: PendingView }) {
169
+ return (
170
+ <div
171
+ className="rounded-lg p-3"
172
+ style={{
173
+ background: "#fffbeb",
174
+ border: "1px solid #fcd34d",
175
+ animation: "rt-slide-in 180ms ease-out",
176
+ }}
177
+ >
178
+ <div
179
+ className="text-[10px] font-semibold uppercase tracking-wider mb-1.5 flex items-center gap-1.5"
180
+ style={{ color: "#b45309" }}
181
+ >
182
+ <span style={{ animation: "rt-pulse 1.4s ease-in-out infinite" }}>●</span>
183
+ Awaiting the caller's yes
184
+ </div>
185
+ <div className="text-xs leading-relaxed" style={{ color: "#78350f" }}>
186
+ {pending.readBack}
187
+ </div>
188
+ <div className="text-[10px] mt-1.5" style={{ color: "#a16207" }}>
189
+ Nothing has changed yet — {pending.kind}
190
+ </div>
191
+ </div>
192
+ );
193
+ }
194
+
158
195
  function PersonaList() {
159
196
  return (
160
197
  <div className="flex flex-col gap-2">
@@ -489,6 +526,8 @@ function App() {
489
526
  className="rt-scroll overflow-y-auto p-3 flex flex-col gap-3"
490
527
  style={{ background: "#f4f4f5" }}
491
528
  >
529
+ {view.pending && <PendingChange pending={view.pending} />}
530
+
492
531
  {!view.customer && (
493
532
  <Panel title="Who to be">
494
533
  <PersonaList />
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Changing which payment method a pending order is charged to, as a plan and an
3
+ * apply (see `cancel.ts` for why every mutating action is split that way).
4
+ */
5
+
6
+ import { isToolFailure, type ToolFailure } from "@alexkroman1/aai";
7
+ import { resolveOrder } from "./resolve.ts";
8
+ import type { RetailState } from "./shared.ts";
9
+ import { authenticatedUser, findPaymentMethod, isGiftCard, money } from "./store.ts";
10
+
11
+ export interface PaymentPlan {
12
+ readBack: string;
13
+ orderId: string;
14
+ /** Where the order is charged after the change. */
15
+ newMethodId: string;
16
+ /** Where the original charge goes back to. */
17
+ oldMethodId: string;
18
+ amount: number;
19
+ }
20
+
21
+ export function planPayment(
22
+ state: RetailState,
23
+ spokenOrderId: string,
24
+ newMethodId: string,
25
+ ): PaymentPlan | ToolFailure {
26
+ const user = authenticatedUser(state);
27
+ if (isToolFailure(user)) return user;
28
+
29
+ const order = resolveOrder(state, spokenOrderId);
30
+ if (isToolFailure(order)) return order;
31
+
32
+ if (!order.status.startsWith("pending")) {
33
+ return {
34
+ error: `Order ${order.order_id} is ${order.status}, and only a pending order's payment method can be changed.`,
35
+ };
36
+ }
37
+
38
+ const newMethod = findPaymentMethod(user, newMethodId);
39
+ if (isToolFailure(newMethod)) return newMethod;
40
+
41
+ const original = order.payment_history[0];
42
+ if (order.payment_history.length !== 1 || original?.transaction_type !== "payment") {
43
+ return {
44
+ error: `Order ${order.order_id} does not have exactly one payment on record, so its payment method cannot be changed.`,
45
+ };
46
+ }
47
+ if (original.payment_method_id === newMethodId) {
48
+ return {
49
+ error: `Order ${order.order_id} is already paid with ${newMethodId}. The new method must be different.`,
50
+ };
51
+ }
52
+
53
+ const amount = original.amount;
54
+ if (isGiftCard(newMethod) && newMethod.balance < amount) {
55
+ return {
56
+ error: `Gift card ${newMethodId}'s balance ($${newMethod.balance.toFixed(2)}) does not cover the $${amount.toFixed(2)} order total.`,
57
+ };
58
+ }
59
+
60
+ return {
61
+ readBack:
62
+ `charge order ${order.order_id} — $${amount.toFixed(2)} — to ${newMethodId} instead, ` +
63
+ `refunding ${original.payment_method_id}`,
64
+ orderId: order.order_id,
65
+ newMethodId,
66
+ oldMethodId: original.payment_method_id,
67
+ amount,
68
+ };
69
+ }
70
+
71
+ export function applyPayment(state: RetailState, plan: PaymentPlan) {
72
+ const order = state.store.orders[plan.orderId];
73
+ const user = order ? state.store.users[order.user_id] : undefined;
74
+
75
+ if (order && user) {
76
+ order.payment_history.push(
77
+ {
78
+ transaction_type: "payment",
79
+ amount: plan.amount,
80
+ payment_method_id: plan.newMethodId,
81
+ },
82
+ {
83
+ transaction_type: "refund",
84
+ amount: plan.amount,
85
+ payment_method_id: plan.oldMethodId,
86
+ },
87
+ );
88
+
89
+ const newMethod = user.payment_methods[plan.newMethodId];
90
+ if (newMethod && isGiftCard(newMethod)) {
91
+ newMethod.balance = money(newMethod.balance - plan.amount);
92
+ }
93
+ const oldMethod = user.payment_methods[plan.oldMethodId];
94
+ if (oldMethod && isGiftCard(oldMethod)) {
95
+ oldMethod.balance = money(oldMethod.balance + plan.amount);
96
+ }
97
+ }
98
+
99
+ return {
100
+ order_id: plan.orderId,
101
+ status: order?.status ?? "pending",
102
+ amount: plan.amount,
103
+ paid_with: plan.newMethodId,
104
+ refunded_to: plan.oldMethodId,
105
+ message: `Order ${plan.orderId} is now charged to ${plan.newMethodId}, and $${plan.amount.toFixed(2)} is going back to ${plan.oldMethodId}.`,
106
+ };
107
+ }
@@ -0,0 +1,196 @@
1
+ import type { ToolContext } from "@alexkroman1/aai";
2
+ import { isToolFailure } from "@alexkroman1/aai";
3
+ import { createToolContext, ok, okPosition } from "@alexkroman1/aai/testing";
4
+ import { describe, expect, test } from "vitest";
5
+ import type { StagedResult } from "./pending.ts";
6
+ import { retailSlot } from "./store.ts";
7
+ import cancelChange from "./tools/cancel_change.ts";
8
+ import cancelPendingOrder from "./tools/cancel_pending_order.ts";
9
+ import confirmChange from "./tools/confirm_change.ts";
10
+ import findUserIdByEmail from "./tools/find_user_id_by_email.ts";
11
+ import getOrderDetails from "./tools/get_order_details.ts";
12
+ import modifyUserAddress from "./tools/modify_user_address.ts";
13
+ import returnDeliveredOrderItems from "./tools/return_delivered_order_items.ts";
14
+ import transferToHumanAgents from "./tools/transfer_to_human_agents.ts";
15
+
16
+ /** Aarav Anderson: one pending order (#W9300146, $153.23 on a gift card) and
17
+ * one delivered order (#W4316152), which is every shape these cases need. */
18
+ async function aaravCtx(): Promise<ToolContext> {
19
+ const ctx = createToolContext();
20
+ ok(await findUserIdByEmail.execute({ email: "aarav.anderson9752@example.com" }, ctx));
21
+ return ctx;
22
+ }
23
+
24
+ const stageCancel = (ctx: ToolContext) =>
25
+ cancelPendingOrder.execute({ order_id: "#W9300146", reason: "no longer needed" }, ctx);
26
+
27
+ describe("staging", () => {
28
+ test("a staging tool changes NOTHING and moves the call to awaitingConfirmation", async () => {
29
+ const ctx = await aaravCtx();
30
+ const before = structuredClone(retailSlot.get(ctx).store.orders["#W9300146"]);
31
+
32
+ const staged = okPosition<StagedResult>(await stageCancel(ctx));
33
+
34
+ expect(staged.state).toBe("serving.awaitingConfirmation");
35
+ expect(staged.result.staged).toBe("cancel_pending_order");
36
+ // The whole claim of this template's policy section, as one assertion: the
37
+ // tool that used to cancel and refund on its first call now does neither.
38
+ expect(retailSlot.get(ctx).store.orders["#W9300146"]).toEqual(before);
39
+ const card =
40
+ retailSlot.get(ctx).store.users.aarav_anderson_8794?.payment_methods.gift_card_7245904;
41
+ expect(card?.source === "gift_card" && card.balance).toBe(17);
42
+ });
43
+
44
+ test("the readback names the order, the items, the amount and where the money goes", async () => {
45
+ const ctx = await aaravCtx();
46
+ const staged = ok<StagedResult>(await stageCancel(ctx));
47
+ expect(staged.read_back).toContain("#W9300146");
48
+ expect(staged.read_back).toContain("153.23");
49
+ expect(staged.read_back).toContain("gift_card_7245904");
50
+ expect(staged.read_back).toContain("no longer needed");
51
+ });
52
+
53
+ test("the result says out loud that nothing has happened", async () => {
54
+ const ctx = await aaravCtx();
55
+ const staged = ok<StagedResult>(await stageCancel(ctx));
56
+ expect(staged.message).toContain("NOTHING HAS CHANGED YET");
57
+ expect(staged.message).toContain("confirm_change");
58
+ });
59
+
60
+ test("a second stage is refused, and the refusal names the one already waiting", async () => {
61
+ const ctx = await aaravCtx();
62
+ ok(await stageCancel(ctx));
63
+
64
+ // A DIFFERENT change, so this is not a repeat of the first call.
65
+ const second = await modifyUserAddress.execute(
66
+ {
67
+ user_id: "aarav_anderson_8794",
68
+ address1: "1 A St",
69
+ address2: "",
70
+ city: "Springfield",
71
+ state: "OR",
72
+ country: "USA",
73
+ zip: "97477",
74
+ },
75
+ ctx,
76
+ );
77
+ expect(isToolFailure(second)).toBe(true);
78
+ // Naming the waiting sentence is why the staging tools are gated on
79
+ // `serving` rather than on `serving.helping`: a state's instruction is
80
+ // static and could only have said which STATE the call is in.
81
+ expect(isToolFailure(second) && second.error).toContain("#W9300146");
82
+ expect(isToolFailure(second) && second.error).toContain("cancel_change");
83
+ expect(retailSlot.get(ctx).store.users.aarav_anderson_8794?.address.city).not.toBe(
84
+ "Springfield",
85
+ );
86
+ });
87
+ });
88
+
89
+ describe("confirming", () => {
90
+ test("confirm_change applies the staged change and returns to helping", async () => {
91
+ const ctx = await aaravCtx();
92
+ ok(await stageCancel(ctx));
93
+
94
+ const done = okPosition<{ confirmed: string; status: string }>(
95
+ await confirmChange.execute({}, ctx),
96
+ );
97
+ expect(done.state).toBe("serving.helping");
98
+ expect(done.result.confirmed).toBe("cancel_pending_order");
99
+ expect(retailSlot.get(ctx).store.orders["#W9300146"]?.status).toBe("cancelled");
100
+ expect(retailSlot.get(ctx).pending).toBeNull();
101
+ });
102
+
103
+ test("confirm_change with nothing staged is refused by the GATE, before its body", async () => {
104
+ const ctx = await aaravCtx();
105
+ const result = await confirmChange.execute({}, ctx);
106
+ expect(isToolFailure(result)).toBe(true);
107
+ // The SDK writes this refusal from `when` plus the state's instruction, so
108
+ // it says where the call is rather than merely that this was not allowed —
109
+ // and the body, which is the only thing in the template that mutates, never
110
+ // ran to find out.
111
+ expect(isToolFailure(result) && result.error).toContain("serving.helping");
112
+ expect(isToolFailure(result) && result.error).toContain("STAGES");
113
+ });
114
+
115
+ test("confirming twice is refused — the second call has nothing staged", async () => {
116
+ const ctx = await aaravCtx();
117
+ ok(await stageCancel(ctx));
118
+ ok(await confirmChange.execute({}, ctx));
119
+
120
+ const again = await confirmChange.execute({}, ctx);
121
+ expect(isToolFailure(again)).toBe(true);
122
+ // One cancellation, one refund: a double-apply would have credited the card
123
+ // twice, which is the failure a "did we already do this?" flag gets wrong.
124
+ const card =
125
+ retailSlot.get(ctx).store.users.aarav_anderson_8794?.payment_methods.gift_card_7245904;
126
+ expect(card?.source === "gift_card" && card.balance).toBe(170.23);
127
+ });
128
+ });
129
+
130
+ describe("cancelling a staged change", () => {
131
+ test("cancel_change drops it, changes nothing, and frees the call", async () => {
132
+ const ctx = await aaravCtx();
133
+ ok(await stageCancel(ctx));
134
+
135
+ const dropped = okPosition<{ dropped: string | null; message: string }>(
136
+ await cancelChange.execute({}, ctx),
137
+ );
138
+ expect(dropped.state).toBe("serving.helping");
139
+ expect(dropped.result.dropped).toBe("cancel_pending_order");
140
+ expect(dropped.result.message).toContain("#W9300146");
141
+ expect(retailSlot.get(ctx).store.orders["#W9300146"]?.status).toBe("pending");
142
+ expect(retailSlot.get(ctx).pending).toBeNull();
143
+ });
144
+
145
+ test("a corrected change can be staged straight after", async () => {
146
+ const ctx = await aaravCtx();
147
+ ok(await stageCancel(ctx));
148
+ ok(await cancelChange.execute({}, ctx));
149
+
150
+ // The caller changed their mind about which order — the very thing
151
+ // `cancel_change` exists for.
152
+ const restaged = ok<StagedResult>(
153
+ await returnDeliveredOrderItems.execute(
154
+ {
155
+ order_id: "#W4316152",
156
+ item_ids: ["7292993796"],
157
+ payment_method_id: "gift_card_7245904",
158
+ },
159
+ ctx,
160
+ ),
161
+ );
162
+ expect(restaged.staged).toBe("return_delivered_order_items");
163
+ });
164
+ });
165
+
166
+ describe("what stays legal while a change waits", () => {
167
+ test("a read is still answerable — 'what was the total again?'", async () => {
168
+ const ctx = await aaravCtx();
169
+ ok(await stageCancel(ctx));
170
+ // `when: "serving"` matches both children, which is what makes this work.
171
+ const read = okPosition<{ order_id: string }>(
172
+ await getOrderDetails.execute({ order_id: "#W9300146" }, ctx),
173
+ );
174
+ expect(read.result.order_id).toBe("#W9300146");
175
+ // A read does not settle anything, so the change is still waiting.
176
+ expect(read.state).toBe("serving.awaitingConfirmation");
177
+ });
178
+
179
+ test("a caller can still ask for a human, and everything refuses afterwards", async () => {
180
+ const ctx = await aaravCtx();
181
+ ok(await stageCancel(ctx));
182
+
183
+ const transferred = okPosition(
184
+ await transferToHumanAgents.execute({ summary: "wants a human" }, ctx),
185
+ );
186
+ // `TRANSFERRED` is declared on the `serving` PARENT, which is what lets it
187
+ // fire from inside the confirmation.
188
+ expect(transferred.state).toBe("transferred");
189
+ expect(transferred.done).toBe(true);
190
+
191
+ // Including the two settling tools: a call given away cannot be finished.
192
+ expect(isToolFailure(await confirmChange.execute({}, ctx))).toBe(true);
193
+ expect(isToolFailure(await cancelChange.execute({}, ctx))).toBe(true);
194
+ expect(retailSlot.get(ctx).store.orders["#W9300146"]?.status).toBe("pending");
195
+ });
196
+ });
@@ -0,0 +1,137 @@
1
+ /**
2
+ * The change waiting on the caller's word — what is staged, and what applying
3
+ * it does.
4
+ *
5
+ * Retail's policy has always said "confirm every change out loud … never act on
6
+ * an implied yes", and for a long time the only thing carrying that was prose:
7
+ * the sentence sat in the system prompt and in seven tool descriptions, and
8
+ * `cancel_pending_order` cancelled and refunded on its first call whether or not
9
+ * a word had been said. A rule enforced by asking a model nicely is not a rule.
10
+ *
11
+ * It is a MECHANISM now, and the mechanism is two halves that need each other:
12
+ *
13
+ * 1. **No tool mutates.** The seven changing tools validate everything, price
14
+ * it, write a {@link PendingAction} here and return the sentence to read
15
+ * back. `confirm_change` is the only thing in the template that writes to
16
+ * the store.
17
+ * 2. **`confirm_change` is gated on a STATE.** `callFlow`'s
18
+ * `serving.awaitingConfirmation` (`store.ts`) is reachable only by staging,
19
+ * so confirming a change nobody staged is refused by the SDK before the body
20
+ * runs — and the refusal says where the call is and what has to happen first.
21
+ *
22
+ * The half that is not enforceable stays honest about it: nothing here can know
23
+ * that the agent really SAID the sentence, or that the caller really answered
24
+ * yes. What the gate buys is that a change cannot happen in the same turn it was
25
+ * described in — the model has to come back for a second call, with the caller's
26
+ * answer in between — which is the property the prose could not have.
27
+ *
28
+ * Adapted from `travel-concierge`, whose `stageAction`/`confirm_action` pair is
29
+ * the same shape one domain over. The difference worth noting: its plans are
30
+ * re-derived at confirm time, and these are computed once at stage time and held
31
+ * as primitives. That is deliberate — see `cancel.ts`. A "yes" must never be
32
+ * followed by a refusal.
33
+ */
34
+
35
+ import type { ToolFailure } from "@alexkroman1/aai";
36
+ import type { OrderAddressPlan, UserAddressPlan } from "./address.ts";
37
+ import { applyOrderAddress, applyUserAddress } from "./address.ts";
38
+ import { applyCancel, type CancelPlan } from "./cancel.ts";
39
+ import { applyPayment, type PaymentPlan } from "./payment.ts";
40
+ import { applyReturn, type ReturnPlan } from "./returns.ts";
41
+ import type { RetailState } from "./shared.ts";
42
+ import type { ExchangePlan, ModifyItemsPlan } from "./swap.ts";
43
+ import { applyExchange, applyModifyItems } from "./swap.ts";
44
+
45
+ /**
46
+ * A validated, priced, not-yet-applied change.
47
+ *
48
+ * `kind` is the NAME OF THE TOOL that staged it, so the activity feed, the
49
+ * sidebar and every refusal can say which change is waiting in the same words
50
+ * the model called it by.
51
+ */
52
+ export type PendingAction =
53
+ | { kind: "cancel_pending_order"; plan: CancelPlan }
54
+ | { kind: "modify_pending_order_address"; plan: OrderAddressPlan }
55
+ | { kind: "modify_pending_order_items"; plan: ModifyItemsPlan }
56
+ | { kind: "modify_pending_order_payment"; plan: PaymentPlan }
57
+ | { kind: "modify_user_address"; plan: UserAddressPlan }
58
+ | { kind: "return_delivered_order_items"; plan: ReturnPlan }
59
+ | { kind: "exchange_delivered_order_items"; plan: ExchangePlan };
60
+
61
+ /** What a staging tool answers with: the sentence, and nothing done yet. */
62
+ export interface StagedResult {
63
+ staged: PendingAction["kind"];
64
+ read_back: string;
65
+ message: string;
66
+ }
67
+
68
+ /**
69
+ * Put a change in front of the caller.
70
+ *
71
+ * A SECOND stage is refused rather than queued, and the refusal names the
72
+ * sentence already waiting — which is the whole reason the staging tools are
73
+ * gated on `serving` (either child) rather than on `serving.helping`. A state
74
+ * gate could only say "you are at serving.awaitingConfirmation", a state's
75
+ * instruction being static; this can say *which* change is waiting, which is
76
+ * what lets the model settle that one and re-stage the other. Same resolution
77
+ * as `travel-concierge`, and for the same reason.
78
+ */
79
+ export function stageAction(state: RetailState, action: PendingAction): StagedResult | ToolFailure {
80
+ const waiting = state.pending;
81
+ if (waiting) {
82
+ return {
83
+ error:
84
+ `A change is already waiting on the caller's yes: "${waiting.plan.readBack}". ` +
85
+ "Settle that one with confirm_change or cancel_change, then stage this one.",
86
+ };
87
+ }
88
+ state.pending = action;
89
+ return {
90
+ staged: action.kind,
91
+ read_back: action.plan.readBack,
92
+ message:
93
+ `NOTHING HAS CHANGED YET. Read this back to the caller — "${action.plan.readBack}" — ` +
94
+ "and wait for an explicit yes. Then call confirm_change, or cancel_change if they say no.",
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Perform the staged change.
100
+ *
101
+ * Total: every plan was validated against this same store, and nothing between
102
+ * staging and confirming can invalidate one — `confirm_change` and
103
+ * `cancel_change` are the only tools legal in `awaitingConfirmation`, and
104
+ * neither is a mutation the other's plan depends on. That totality is the
105
+ * point. An `applyAction` that could fail would mean a caller saying yes and
106
+ * hearing "actually, no", which is exactly the outcome staging exists to
107
+ * prevent.
108
+ */
109
+ export function applyAction(state: RetailState, action: PendingAction) {
110
+ switch (action.kind) {
111
+ case "cancel_pending_order":
112
+ return applyCancel(state, action.plan);
113
+ case "modify_pending_order_address":
114
+ return applyOrderAddress(state, action.plan);
115
+ case "modify_pending_order_items":
116
+ return applyModifyItems(state, action.plan);
117
+ case "modify_pending_order_payment":
118
+ return applyPayment(state, action.plan);
119
+ case "modify_user_address":
120
+ return applyUserAddress(state, action.plan);
121
+ case "return_delivered_order_items":
122
+ return applyReturn(state, action.plan);
123
+ case "exchange_delivered_order_items":
124
+ return applyExchange(state, action.plan);
125
+ default: {
126
+ // Unreachable: the seven arms above exhaust `PendingAction`, and this
127
+ // ASSIGNMENT is what keeps that true — an eighth member of the union
128
+ // stops compiling here rather than falling silently through to a change
129
+ // nobody applies. It exists because biome's `useDefaultSwitchClause`
130
+ // wants an arm, not because a call can reach it, which is why it throws
131
+ // rather than answering the `ToolFailure` the rest of this template
132
+ // prefers: there is no live call on this path to recover.
133
+ const unreachable: never = action;
134
+ throw new Error(`No apply for staged action ${JSON.stringify(unreachable)}`);
135
+ }
136
+ }
137
+ }