@alexkroman1/aai-cli 5.3.0 → 5.5.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 (99) hide show
  1. package/README.md +51 -0
  2. package/dist/{_agent-D8zBb3M5.mjs → _agent-DMyOab9_.mjs} +2 -2
  3. package/dist/{_bundler-DFS4xxqE.mjs → _bundler-Cjaxa2wi.mjs} +1 -1
  4. package/dist/{_config-D3F9km8X.mjs → _config-5AEqhh-O.mjs} +2 -2
  5. package/dist/{_dev-server-BBUWo0sb.mjs → _dev-server-vV05Fnki.mjs} +3 -3
  6. package/dist/{_init-DU-sXH6S.mjs → _init-BZ9t_Kz-.mjs} +3 -56
  7. package/dist/{_slug-api-19R5kZ0U.mjs → _slug-api-DaqQJHk8.mjs} +1 -1
  8. package/dist/_templates-Bv8CR800.mjs +69 -0
  9. package/dist/_templates.d.ts +6 -0
  10. package/dist/{build-Xq1xxoSg.mjs → build-D_PgQOD4.mjs} +1 -1
  11. package/dist/cli.mjs +59 -17
  12. package/dist/{client-bundler-BWDkUeEP.mjs → client-bundler-yiWoXrgb.mjs} +3 -0
  13. package/dist/client-bundler.d.ts +9 -0
  14. package/dist/client-bundler.mjs +1 -1
  15. package/dist/{delete-kSXFLxek.mjs → delete-DXilFBb1.mjs} +1 -1
  16. package/dist/{deploy-Ci0X1gp2.mjs → deploy-1eaXcfUw.mjs} +3 -3
  17. package/dist/{dev-g7sGt8qX.mjs → dev-gVNdGFYY.mjs} +1 -1
  18. package/dist/{init-BjeK8crW.mjs → init-DoU4_txp.mjs} +3 -3
  19. package/dist/login-C59ZHzuO.mjs +109 -0
  20. package/dist/login.d.ts +34 -0
  21. package/dist/scaffold/CLAUDE.md +190 -120
  22. package/dist/scaffold/package.json +3 -3
  23. package/dist/{secret-DOva9OGk.mjs → secret-CGAIAbUx.mjs} +1 -1
  24. package/dist/{storage-vM6HjDZZ.mjs → storage-CnhOayhm.mjs} +1 -1
  25. package/dist/templates/code-interpreter/agent.ts +1 -2
  26. package/dist/templates/dispatch-center/agent.test.ts +3 -3
  27. package/dist/templates/dispatch-center/agent.ts +7 -7
  28. package/dist/templates/dispatch-center/client.tsx +7 -2
  29. package/dist/templates/dispatch-center/tools/incident_add_note.ts +1 -1
  30. package/dist/templates/dispatch-center/tools/incident_create.ts +1 -1
  31. package/dist/templates/dispatch-center/tools/incident_escalate.ts +1 -1
  32. package/dist/templates/dispatch-center/tools/incident_get.ts +1 -1
  33. package/dist/templates/dispatch-center/tools/incident_triage.ts +1 -1
  34. package/dist/templates/dispatch-center/tools/incident_update_status.ts +1 -1
  35. package/dist/templates/dispatch-center/tools/ops_protocols.ts +1 -1
  36. package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +1 -1
  37. package/dist/templates/dispatch-center/tools/resources_dispatch.ts +1 -1
  38. package/dist/templates/dispatch-center/tools/resources_get_available.ts +1 -1
  39. package/dist/templates/dispatch-center/tools/resources_update_status.ts +1 -1
  40. package/dist/templates/embedded-assets/agent.ts +2 -3
  41. package/dist/templates/health-assistant/agent.ts +3 -4
  42. package/dist/templates/infocom-adventure/agent.ts +10 -12
  43. package/dist/templates/infocom-adventure/client.tsx +5 -5
  44. package/dist/templates/math-buddy/agent.ts +7 -6
  45. package/dist/templates/night-owl/agent.ts +2 -3
  46. package/dist/templates/personal-finance/agent.ts +1 -2
  47. package/dist/templates/pipeline-simple/agent.test.ts +13 -13
  48. package/dist/templates/pipeline-simple/agent.ts +5 -6
  49. package/dist/templates/pizza-ordering/agent.test.ts +13 -11
  50. package/dist/templates/pizza-ordering/agent.ts +5 -6
  51. package/dist/templates/retail/address.ts +29 -0
  52. package/dist/templates/retail/agent.test.ts +1030 -0
  53. package/dist/templates/retail/agent.ts +71 -0
  54. package/dist/templates/retail/authenticate.ts +34 -0
  55. package/dist/templates/retail/client.tsx +459 -0
  56. package/dist/templates/retail/refund.ts +19 -0
  57. package/dist/templates/retail/registry.test.ts +182 -0
  58. package/dist/templates/retail/resolve.test.ts +158 -0
  59. package/dist/templates/retail/resolve.ts +158 -0
  60. package/dist/templates/retail/seed.json +4260 -0
  61. package/dist/templates/retail/seed.test.ts +224 -0
  62. package/dist/templates/retail/shared.test.ts +192 -0
  63. package/dist/templates/retail/shared.ts +430 -0
  64. package/dist/templates/retail/store.test.ts +256 -0
  65. package/dist/templates/retail/store.ts +243 -0
  66. package/dist/templates/retail/swap.test.ts +186 -0
  67. package/dist/templates/retail/swap.ts +138 -0
  68. package/dist/templates/retail/system-prompt.md +111 -0
  69. package/dist/templates/retail/tools/cancel_pending_order.ts +82 -0
  70. package/dist/templates/retail/tools/exchange_delivered_order_items.ts +83 -0
  71. package/dist/templates/retail/tools/find_user_id_by_email.ts +32 -0
  72. package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +38 -0
  73. package/dist/templates/retail/tools/get_item_details.ts +29 -0
  74. package/dist/templates/retail/tools/get_order_details.ts +41 -0
  75. package/dist/templates/retail/tools/get_product_details.ts +33 -0
  76. package/dist/templates/retail/tools/get_user_details.ts +44 -0
  77. package/dist/templates/retail/tools/list_all_product_types.ts +24 -0
  78. package/dist/templates/retail/tools/modify_pending_order_address.ts +46 -0
  79. package/dist/templates/retail/tools/modify_pending_order_items.ts +102 -0
  80. package/dist/templates/retail/tools/modify_pending_order_payment.ts +103 -0
  81. package/dist/templates/retail/tools/modify_user_address.ts +38 -0
  82. package/dist/templates/retail/tools/return_delivered_order_items.ts +101 -0
  83. package/dist/templates/retail/tools/transfer_to_human_agents.ts +22 -0
  84. package/dist/templates/simple/agent.test.ts +18 -0
  85. package/dist/templates/simple/agent.ts +3 -0
  86. package/dist/templates/solo-rpg/agent.test.ts +2 -2
  87. package/dist/templates/solo-rpg/agent.ts +1 -2
  88. package/dist/templates/solo-rpg/tools/action_roll.ts +1 -1
  89. package/dist/templates/solo-rpg/tools/load_game.ts +1 -1
  90. package/dist/templates/solo-rpg/tools/oracle.ts +1 -1
  91. package/dist/templates/solo-rpg/tools/save_game.ts +1 -1
  92. package/dist/templates/solo-rpg/tools/setup_character.ts +1 -1
  93. package/dist/templates/solo-rpg/tools/update_state.ts +1 -1
  94. package/dist/templates/web-researcher/agent.ts +1 -2
  95. package/dist/typecheck.d.ts +18 -0
  96. package/dist/typecheck.mjs +8 -0
  97. package/dist/worker-bundler.d.ts +9 -1
  98. package/dist/worker-bundler.mjs +8 -0
  99. package/package.json +3 -3
@@ -0,0 +1,430 @@
1
+ // ─── Store types ─────────────────────────────────────────────────────────────
2
+ // Field names are tau2's snake_case verbatim, because `seed.json` is tau2's
3
+ // records unmodified. Renaming them here would mean transforming 107 KB of
4
+ // data on every session start for cosmetics.
5
+
6
+ export interface Address {
7
+ address1: string;
8
+ address2: string;
9
+ city: string;
10
+ state: string;
11
+ country: string;
12
+ zip: string;
13
+ }
14
+
15
+ export interface GiftCard {
16
+ source: "gift_card";
17
+ id: string;
18
+ balance: number;
19
+ }
20
+ export interface PayPal {
21
+ source: "paypal";
22
+ id: string;
23
+ }
24
+ export interface CreditCard {
25
+ source: "credit_card";
26
+ id: string;
27
+ brand: string;
28
+ last_four: string;
29
+ }
30
+ export type PaymentMethod = GiftCard | PayPal | CreditCard;
31
+
32
+ export interface Variant {
33
+ item_id: string;
34
+ options: Record<string, string>;
35
+ available: boolean;
36
+ price: number;
37
+ }
38
+
39
+ export interface Product {
40
+ name: string;
41
+ product_id: string;
42
+ variants: Record<string, Variant>;
43
+ }
44
+
45
+ export interface OrderItem {
46
+ name: string;
47
+ product_id: string;
48
+ item_id: string;
49
+ price: number;
50
+ options: Record<string, string>;
51
+ }
52
+
53
+ /** tau2's exact status strings. `pending (item modified)` is terminal — see
54
+ * `modify_pending_order_items`. */
55
+ export type OrderStatus =
56
+ | "pending"
57
+ | "pending (item modified)"
58
+ | "processed"
59
+ | "delivered"
60
+ | "cancelled"
61
+ | "return requested"
62
+ | "exchange requested";
63
+
64
+ export interface OrderPayment {
65
+ transaction_type: "payment" | "refund";
66
+ amount: number;
67
+ payment_method_id: string;
68
+ }
69
+
70
+ export interface Fulfillment {
71
+ tracking_id: string[];
72
+ item_ids: string[];
73
+ }
74
+
75
+ export interface Order {
76
+ order_id: string;
77
+ user_id: string;
78
+ address: Address;
79
+ items: OrderItem[];
80
+ status: OrderStatus;
81
+ fulfillments?: Fulfillment[];
82
+ payment_history: OrderPayment[];
83
+ cancel_reason?: string;
84
+ return_items?: string[];
85
+ return_payment_method_id?: string;
86
+ exchange_items?: string[];
87
+ exchange_new_items?: string[];
88
+ exchange_payment_method_id?: string;
89
+ exchange_price_difference?: number;
90
+ }
91
+
92
+ export interface User {
93
+ user_id: string;
94
+ name: { first_name: string; last_name: string };
95
+ address: Address;
96
+ email: string;
97
+ payment_methods: Record<string, PaymentMethod>;
98
+ orders: string[];
99
+ }
100
+
101
+ export interface Store {
102
+ products: Record<string, Product>;
103
+ users: Record<string, User>;
104
+ orders: Record<string, Order>;
105
+ }
106
+
107
+ // ─── Session state ───────────────────────────────────────────────────────────
108
+
109
+ export interface ActivityEntry {
110
+ seq: number;
111
+ tool: string;
112
+ summary: string;
113
+ at: number;
114
+ }
115
+
116
+ /** Capped in state, not just in the projection — an uncapped list would grow
117
+ * the pushed payload for the length of the call. */
118
+ export const MAX_ACTIVITY = 10;
119
+
120
+ export interface RetailState {
121
+ store: Store;
122
+ /** Set by the two finder tools. Null until the caller is identified. */
123
+ authenticatedUserId: string | null;
124
+ /** Monotonic. Every tool call increments it, which is what guarantees the
125
+ * projection differs and therefore gets pushed — `syncState` sends only
126
+ * when the projected result changed. */
127
+ callSeq: number;
128
+ activity: ActivityEntry[];
129
+ focus: { orderId?: string; productId?: string };
130
+ }
131
+
132
+ export type StateSlot = { retail?: RetailState };
133
+
134
+ // ─── View types — exactly what crosses the wire ──────────────────────────────
135
+
136
+ export interface PaymentMethodView {
137
+ id: string;
138
+ source: PaymentMethod["source"];
139
+ label: string;
140
+ /** Gift cards only. */
141
+ balance?: number;
142
+ }
143
+
144
+ export interface OrderItemView {
145
+ name: string;
146
+ itemId: string;
147
+ options: Record<string, string>;
148
+ price: number;
149
+ }
150
+
151
+ export interface OrderView {
152
+ orderId: string;
153
+ status: OrderStatus;
154
+ total: number;
155
+ items: OrderItemView[];
156
+ address: Address;
157
+ }
158
+
159
+ export interface CustomerView {
160
+ userId: string;
161
+ name: string;
162
+ email: string;
163
+ address: Address;
164
+ paymentMethods: PaymentMethodView[];
165
+ }
166
+
167
+ /** What else one item in the focused order could become. This is what makes an
168
+ * exchange sayable — without it "exchange the kettle" has no target. */
169
+ export interface SwapOptionView {
170
+ itemId: string;
171
+ itemName: string;
172
+ currentOptions: Record<string, string>;
173
+ alternatives: { itemId: string; options: Record<string, string>; price: number }[];
174
+ }
175
+
176
+ export interface StoreView {
177
+ customer: CustomerView | null;
178
+ orders: OrderView[];
179
+ focus: { orderId?: string; productId?: string };
180
+ /** Populated only for the focused order. */
181
+ swapOptions: SwapOptionView[];
182
+ callSeq: number;
183
+ activity: ActivityEntry[];
184
+ scriptBullets: string[];
185
+ productCount: number;
186
+ }
187
+
188
+ export const MAX_SWAP_ITEMS = 3;
189
+ export const MAX_SWAP_ALTERNATIVES = 4;
190
+
191
+ // ─── Demo personas ───────────────────────────────────────────────────────────
192
+
193
+ export interface DemoPersona {
194
+ name: string;
195
+ email: string;
196
+ zip: string;
197
+ /** The one interesting thing about this customer, so a user can pick the path
198
+ * they want to exercise instead of guessing. */
199
+ hint: string;
200
+ }
201
+
202
+ /**
203
+ * The character-select screen. A first-time user has no email to give, so
204
+ * without this the agent cannot be driven at all.
205
+ *
206
+ * A CONSTANT, not a projection of `store.users`: reading it out of the store
207
+ * would put five unauthenticated customers' emails into the projection, which
208
+ * is the one thing `storeView` exists to prevent. The duplication with
209
+ * `seed.json` is deliberate and `shared.test.ts` pins the two together.
210
+ */
211
+ export const DEMO_PERSONAS: readonly DemoPersona[] = [
212
+ {
213
+ name: "Emma Smith",
214
+ email: "emma.smith3991@example.com",
215
+ zip: "10192",
216
+ hint: "Simplest path — single-item orders, $62.00 gift card.",
217
+ },
218
+ {
219
+ name: "Olivia Ito",
220
+ email: "olivia.ito5204@example.com",
221
+ zip: "80218",
222
+ hint: "Three pending orders, all three payment types. Try switching an order's payment method.",
223
+ },
224
+ {
225
+ name: "Aarav Anderson",
226
+ email: "aarav.anderson9752@example.com",
227
+ zip: "19031",
228
+ hint: "One gift card holding $17.00. A pricier exchange gets refused; one order has the same kettle twice.",
229
+ },
230
+ {
231
+ name: "Anya Garcia",
232
+ email: "anya.garcia2061@example.com",
233
+ zip: "19036",
234
+ hint: "$51.00 gift card — upgrading the laptop in her pending order costs more than it holds.",
235
+ },
236
+ {
237
+ name: "Harper Brown",
238
+ email: "harper.brown3965@example.com",
239
+ zip: "76112",
240
+ hint: "No gift card, so a refund can only go back to PayPal.",
241
+ },
242
+ {
243
+ name: "Aarav Gonzalez",
244
+ email: "aarav.gonzalez9269@example.com",
245
+ zip: "78268",
246
+ hint: "Shares a first name with Aarav Anderson — the zip is what tells them apart.",
247
+ },
248
+ ] as const;
249
+
250
+ // ─── Projection ──────────────────────────────────────────────────────────────
251
+
252
+ function paymentLabel(method: PaymentMethod): string {
253
+ if (method.source === "gift_card") return "Gift card";
254
+ if (method.source === "paypal") return "PayPal";
255
+ return `${method.brand} ending ${method.last_four}`;
256
+ }
257
+
258
+ function paymentMethodView(method: PaymentMethod): PaymentMethodView {
259
+ return {
260
+ id: method.id,
261
+ source: method.source,
262
+ label: paymentLabel(method),
263
+ ...(method.source === "gift_card" ? { balance: method.balance } : {}),
264
+ };
265
+ }
266
+
267
+ /** Net of payments minus refunds — what the customer has actually paid. Items
268
+ * can't be summed instead: a modified order's items no longer match what was
269
+ * charged. */
270
+ export function orderTotal(order: Order): number {
271
+ const net = order.payment_history.reduce(
272
+ (sum, p) => sum + (p.transaction_type === "payment" ? p.amount : -p.amount),
273
+ 0,
274
+ );
275
+ return Math.round(net * 100) / 100;
276
+ }
277
+
278
+ function orderView(order: Order): OrderView {
279
+ return {
280
+ orderId: order.order_id,
281
+ status: order.status,
282
+ total: orderTotal(order),
283
+ address: order.address,
284
+ items: order.items.map((i) => ({
285
+ name: i.name,
286
+ itemId: i.item_id,
287
+ options: i.options,
288
+ price: i.price,
289
+ })),
290
+ };
291
+ }
292
+
293
+ /**
294
+ * What else each item in the focused order could become — the data that makes
295
+ * an exchange sayable. Only available variants, only the focused order, and
296
+ * capped on both axes so the payload stays bounded.
297
+ */
298
+ function swapOptionsFor(state: RetailState, order: Order | undefined): SwapOptionView[] {
299
+ if (!order) return [];
300
+ return order.items.slice(0, MAX_SWAP_ITEMS).map((item) => {
301
+ const product = state.store.products[item.product_id];
302
+ const alternatives = Object.values(product?.variants ?? {})
303
+ .filter((variant) => variant.available && variant.item_id !== item.item_id)
304
+ .sort((a, b) => Math.abs(a.price - item.price) - Math.abs(b.price - item.price))
305
+ .slice(0, MAX_SWAP_ALTERNATIVES)
306
+ .map((variant) => ({
307
+ itemId: variant.item_id,
308
+ options: variant.options,
309
+ price: variant.price,
310
+ }));
311
+ return {
312
+ itemId: item.item_id,
313
+ itemName: item.name,
314
+ currentOptions: item.options,
315
+ alternatives,
316
+ };
317
+ });
318
+ }
319
+
320
+ /** The `syncState` projection — the entire contract with `client.tsx`.
321
+ *
322
+ * Only the authenticated customer is projected. The other five seeded
323
+ * customers' emails, addresses, and payment methods never leave the server,
324
+ * which is why `syncState` takes a projection rather than a boolean. */
325
+ export function storeView(slot: StateSlot): StoreView {
326
+ const state = slot.retail;
327
+ const userId = state?.authenticatedUserId ?? null;
328
+ const user = userId ? state?.store.users[userId] : undefined;
329
+ const focusedOrderId = state?.focus.orderId;
330
+ // Gated on `user`: an order is only ever projected for the customer on the
331
+ // call, so a stale focus from before authentication cannot leak one.
332
+ const focusedOrder =
333
+ state && user && focusedOrderId && user.orders.includes(focusedOrderId)
334
+ ? state.store.orders[focusedOrderId]
335
+ : undefined;
336
+
337
+ return {
338
+ customer: user
339
+ ? {
340
+ userId: user.user_id,
341
+ name: `${user.name.first_name} ${user.name.last_name}`,
342
+ email: user.email,
343
+ address: user.address,
344
+ paymentMethods: Object.values(user.payment_methods).map(paymentMethodView),
345
+ }
346
+ : null,
347
+ orders: user
348
+ ? user.orders
349
+ .map((id) => state?.store.orders[id])
350
+ .filter((o): o is Order => o !== undefined)
351
+ .map(orderView)
352
+ : [],
353
+ // The orderId half is gated the same way `focusedOrder` is above: a focus
354
+ // left over from another customer's session (or from before
355
+ // authentication) must not surface that order's id in the view, even
356
+ // though its details were already withheld from `orders`/`swapOptions`.
357
+ focus: {
358
+ ...(focusedOrder && focusedOrderId ? { orderId: focusedOrderId } : {}),
359
+ ...(state?.focus.productId ? { productId: state.focus.productId } : {}),
360
+ },
361
+ swapOptions: state ? swapOptionsFor(state, focusedOrder) : [],
362
+ callSeq: state?.callSeq ?? 0,
363
+ activity: state?.activity.slice(-MAX_ACTIVITY) ?? [],
364
+ scriptBullets: buildScriptBullets(state),
365
+ productCount: Object.keys(state?.store.products ?? {}).length,
366
+ };
367
+ }
368
+
369
+ // ─── Script bullets ──────────────────────────────────────────────────────────
370
+
371
+ const MAX_BULLETS = 6;
372
+
373
+ /**
374
+ * Generated from the caller's own orders, not a fixed list. A capability list
375
+ * that offers actions the data can't support — "return an item" to someone
376
+ * with no delivered order — is worse than no list.
377
+ */
378
+ export function buildScriptBullets(state: RetailState | undefined): string[] {
379
+ const userId = state?.authenticatedUserId;
380
+ const user = userId ? state?.store.users[userId] : undefined;
381
+
382
+ // Pre-auth: only the two ways in. The personas panel (a constant, rendered
383
+ // straight from DEMO_PERSONAS) covers WHICH customer to be.
384
+ if (!(state && user)) {
385
+ const first = DEMO_PERSONAS[0];
386
+ return [
387
+ `"My email is ${first?.email ?? ""}"`,
388
+ `"I'm ${first?.name ?? ""}, zip ${first?.zip ?? ""}"`,
389
+ ];
390
+ }
391
+
392
+ const orders = user.orders
393
+ .map((id) => state.store.orders[id])
394
+ .filter((o): o is Order => o !== undefined);
395
+ const pending = orders.filter((o) => o.status === "pending");
396
+ const delivered = orders.filter((o) => o.status === "delivered");
397
+
398
+ const bullets: string[] = [];
399
+
400
+ if (pending.length === 1) {
401
+ bullets.push('"Cancel my pending order"');
402
+ } else if (pending.length > 1) {
403
+ bullets.push(`"Cancel my first pending order" (${pending.length} pending)`);
404
+ }
405
+ if (pending.length > 0) {
406
+ bullets.push('"Change the shipping address on my pending order"');
407
+ }
408
+ if (pending.length > 0 && Object.keys(user.payment_methods).length > 1) {
409
+ bullets.push('"Switch that order to my other payment method"');
410
+ }
411
+
412
+ const deliveredItem = delivered[0]?.items[0];
413
+ if (deliveredItem) {
414
+ bullets.push(`"Return the ${deliveredItem.name}"`);
415
+ // Name a real alternative option. "Exchange it for a different option" is
416
+ // not something a caller can act on — they need words to say.
417
+ const alternative = Object.values(
418
+ state.store.products[deliveredItem.product_id]?.variants ?? {},
419
+ ).find((variant) => variant.available && variant.item_id !== deliveredItem.item_id);
420
+ bullets.push(
421
+ alternative
422
+ ? `"Exchange the ${deliveredItem.name} for the ${Object.values(alternative.options).join(" ")} one"`
423
+ : `"Exchange the ${deliveredItem.name}"`,
424
+ );
425
+ }
426
+
427
+ bullets.push('"Update my default address"');
428
+
429
+ return bullets.slice(0, MAX_BULLETS);
430
+ }
@@ -0,0 +1,256 @@
1
+ import type { ToolContext } from "@alexkroman1/aai";
2
+ import { describe, expect, test } from "vitest";
3
+ import { z } from "zod";
4
+ import {
5
+ authenticatedUser,
6
+ createDefaultState,
7
+ findItem,
8
+ findOrder,
9
+ findPaymentMethod,
10
+ findProduct,
11
+ findUser,
12
+ findVariant,
13
+ getState,
14
+ isError,
15
+ isGiftCard,
16
+ money,
17
+ requireOwnOrder,
18
+ retailTool,
19
+ } from "./store.ts";
20
+
21
+ let sessionCounter = 0;
22
+
23
+ function makeCtx(): ToolContext {
24
+ return {
25
+ sessionId: `test-session-${++sessionCounter}`,
26
+ send: () => {},
27
+ env: {},
28
+ state: {},
29
+ messages: [],
30
+ } as unknown as ToolContext;
31
+ }
32
+
33
+ describe("session state", () => {
34
+ test("getState seeds lazily and returns the same object on re-entry", () => {
35
+ const ctx = makeCtx();
36
+ const a = getState(ctx);
37
+ const b = getState(ctx);
38
+ expect(a).toBe(b);
39
+ expect(a.authenticatedUserId).toBeNull();
40
+ expect(a.callSeq).toBe(0);
41
+ expect(Object.keys(a.store.orders)).toHaveLength(22);
42
+ });
43
+
44
+ test("each session gets its own deep copy — a mutation cannot leak across sessions", () => {
45
+ const first = getState(makeCtx());
46
+ const order = first.store.orders["#W9300146"];
47
+ if (!order) throw new Error("fixture missing");
48
+ order.status = "cancelled";
49
+ const giftCard = first.store.users.aarav_anderson_8794?.payment_methods.gift_card_7245904;
50
+ if (giftCard?.source === "gift_card") giftCard.balance = 0;
51
+
52
+ const second = getState(makeCtx());
53
+ expect(second.store.orders["#W9300146"]?.status).toBe("pending");
54
+ const fresh = second.store.users.aarav_anderson_8794?.payment_methods.gift_card_7245904;
55
+ expect(fresh?.source === "gift_card" && fresh.balance).toBe(17);
56
+ });
57
+ });
58
+
59
+ describe("money", () => {
60
+ test("rounds to two decimals", () => {
61
+ expect(money(3.005)).toBe(3.01);
62
+ expect(money(0.1 + 0.2)).toBe(0.3);
63
+ expect(money(-40.860_000_000_000_014)).toBe(-40.86);
64
+ });
65
+ });
66
+
67
+ describe("lookups", () => {
68
+ test("findUser resolves and reports a miss by id", () => {
69
+ const state = createDefaultState();
70
+ expect(isError(findUser(state, "olivia_ito_3591"))).toBe(false);
71
+ const miss = findUser(state, "nobody_1");
72
+ expect(isError(miss) && miss.error).toContain("nobody_1");
73
+ });
74
+
75
+ test("findOrder resolves and reports a miss", () => {
76
+ const state = createDefaultState();
77
+ const order = findOrder(state, "#W5866402");
78
+ expect(isError(order)).toBe(false);
79
+ expect(isError(order) ? null : order.status).toBe("delivered");
80
+ expect(isError(findOrder(state, "#W0000000"))).toBe(true);
81
+ });
82
+
83
+ test("findProduct and findVariant resolve within one product", () => {
84
+ const state = createDefaultState();
85
+ const product = findProduct(state, "9832717871");
86
+ if (isError(product)) throw new Error(product.error);
87
+ expect(product.name).toBe("Tea Kettle");
88
+ const variant = findVariant(product, "3909406921");
89
+ expect(isError(variant) ? null : variant.price).toBe(98.25);
90
+ // A real item id, but of a different product.
91
+ expect(isError(findVariant(product, "4725166838"))).toBe(true);
92
+ });
93
+
94
+ test("findItem scans every product and returns both product and variant", () => {
95
+ const state = createDefaultState();
96
+ const found = findItem(state, "3909406921");
97
+ if (isError(found)) throw new Error(found.error);
98
+ expect(found.product.product_id).toBe("9832717871");
99
+ expect(found.variant.price).toBe(98.25);
100
+ expect(isError(findItem(state, "0000000000"))).toBe(true);
101
+ });
102
+
103
+ test("findPaymentMethod resolves on the owning user only", () => {
104
+ const state = createDefaultState();
105
+ const olivia = findUser(state, "olivia_ito_3591");
106
+ if (isError(olivia)) throw new Error(olivia.error);
107
+ expect(isError(findPaymentMethod(olivia, "gift_card_7794233"))).toBe(false);
108
+ // aarav's card is not olivia's.
109
+ expect(isError(findPaymentMethod(olivia, "gift_card_7245904"))).toBe(true);
110
+ });
111
+
112
+ test("isGiftCard narrows", () => {
113
+ const state = createDefaultState();
114
+ const olivia = findUser(state, "olivia_ito_3591");
115
+ if (isError(olivia)) throw new Error(olivia.error);
116
+ const card = olivia.payment_methods.gift_card_7794233;
117
+ const paypal = olivia.payment_methods.paypal_8049766;
118
+ expect(card && isGiftCard(card)).toBe(true);
119
+ expect(paypal && isGiftCard(paypal)).toBe(false);
120
+ });
121
+ });
122
+
123
+ describe("ownership and authentication guards", () => {
124
+ test("authenticatedUser errors before authentication", () => {
125
+ const state = createDefaultState();
126
+ const result = authenticatedUser(state);
127
+ expect(isError(result) && result.error.toLowerCase()).toContain("find_user_id_by_email");
128
+ });
129
+
130
+ test("requireOwnOrder refuses another customer's order without revealing it", () => {
131
+ const state = createDefaultState();
132
+ state.authenticatedUserId = "olivia_ito_3591";
133
+ const result = requireOwnOrder(state, "#W4316152"); // aarav's
134
+ expect(isError(result)).toBe(true);
135
+ if (!isError(result)) throw new Error("expected refusal");
136
+ expect(result.error).not.toContain("aarav");
137
+ expect(result.error).not.toContain("Tea Kettle");
138
+ });
139
+
140
+ test("requireOwnOrder gives the same shape of answer for an unknown order — no existence oracle", () => {
141
+ const state = createDefaultState();
142
+ state.authenticatedUserId = "olivia_ito_3591";
143
+ const foreignId = "#W4316152";
144
+ const missingId = "#W0000000";
145
+ const foreign = requireOwnOrder(state, foreignId);
146
+ const missing = requireOwnOrder(state, missingId);
147
+ expect(isError(foreign) && isError(missing)).toBe(true);
148
+ if (!(isError(foreign) && isError(missing))) throw new Error("expected refusals");
149
+ // Structural, not literal, equality: the messages legitimately differ by
150
+ // the id the caller themselves passed in (that's not information about
151
+ // the store — they already knew it), so comparing byte-for-byte would be
152
+ // unsatisfiable by any implementation that echoes the id back for repair
153
+ // retries, as every sibling lookup in this file does. Normalizing the id
154
+ // out asserts the actual guarantee: no wording distinguishes "belongs to
155
+ // someone else" from "doesn't exist".
156
+ const shape = (msg: string, id: string) => msg.replace(id, "<ID>");
157
+ expect(shape(foreign.error, foreignId)).toBe(shape(missing.error, missingId));
158
+ });
159
+
160
+ test("requireOwnOrder resolves the caller's own order", () => {
161
+ const state = createDefaultState();
162
+ state.authenticatedUserId = "olivia_ito_3591";
163
+ const result = requireOwnOrder(state, "#W5866402");
164
+ expect(isError(result) ? null : result.order_id).toBe("#W5866402");
165
+ });
166
+ });
167
+
168
+ describe("retailTool", () => {
169
+ const echo = retailTool({
170
+ name: "echo",
171
+ description: "test tool",
172
+ inputSchema: z.object({ value: z.string() }),
173
+ requiresAuth: false,
174
+ summary: (args) => `echoed ${args.value}`,
175
+ execute: (args) => ({ echoed: args.value }),
176
+ });
177
+
178
+ const gated = retailTool({
179
+ name: "gated",
180
+ description: "test tool needing auth",
181
+ inputSchema: z.object({}),
182
+ summary: () => "ran",
183
+ execute: () => ({ ok: true }),
184
+ });
185
+
186
+ const failing = retailTool({
187
+ name: "failing",
188
+ description: "test tool that returns an error",
189
+ inputSchema: z.object({}),
190
+ requiresAuth: false,
191
+ summary: () => "should not be used",
192
+ execute: () => ({ error: "nope" }),
193
+ });
194
+
195
+ test("increments callSeq and logs activity on every call", async () => {
196
+ const ctx = makeCtx();
197
+ await echo.execute({ value: "a" }, ctx);
198
+ await echo.execute({ value: "b" }, ctx);
199
+ const state = getState(ctx);
200
+ expect(state.callSeq).toBe(2);
201
+ expect(state.activity.map((a) => a.summary)).toEqual(["echoed a", "echoed b"]);
202
+ expect(state.activity.map((a) => a.tool)).toEqual(["echo", "echo"]);
203
+ });
204
+
205
+ test("a repeated identical call still changes the projection", async () => {
206
+ const ctx = makeCtx();
207
+ await echo.execute({ value: "same" }, ctx);
208
+ const first = getState(ctx).callSeq;
209
+ await echo.execute({ value: "same" }, ctx);
210
+ expect(getState(ctx).callSeq).toBeGreaterThan(first);
211
+ });
212
+
213
+ test("defaults to requiring authentication and does not run execute when blocked", async () => {
214
+ const ctx = makeCtx();
215
+ const result = await gated.execute({}, ctx);
216
+ expect(isError(result) && result.error).toContain("find_user_id_by_email");
217
+ });
218
+
219
+ test("a blocked call is still logged and still bumps callSeq", async () => {
220
+ const ctx = makeCtx();
221
+ await gated.execute({}, ctx);
222
+ const state = getState(ctx);
223
+ expect(state.callSeq).toBe(1);
224
+ expect(state.activity[0]?.summary).toContain("blocked");
225
+ });
226
+
227
+ test("runs once authenticated", async () => {
228
+ const ctx = makeCtx();
229
+ getState(ctx).authenticatedUserId = "olivia_ito_3591";
230
+ const result = await gated.execute({}, ctx);
231
+ expect(result).toEqual({ ok: true });
232
+ });
233
+
234
+ test("an error result is logged as an error, not through summary()", async () => {
235
+ const ctx = makeCtx();
236
+ await failing.execute({}, ctx);
237
+ expect(getState(ctx).activity[0]?.summary).toBe("error: nope");
238
+ });
239
+
240
+ test("activity is capped so a long call cannot grow the payload", async () => {
241
+ const ctx = makeCtx();
242
+ for (let i = 0; i < 15; i++) await echo.execute({ value: String(i) }, ctx);
243
+ const state = getState(ctx);
244
+ expect(state.callSeq).toBe(15);
245
+ expect(state.activity).toHaveLength(10);
246
+ expect(state.activity[0]?.summary).toBe("echoed 5");
247
+ });
248
+
249
+ test("concurrent calls serialize — no lost increments", async () => {
250
+ const ctx = makeCtx();
251
+ await Promise.all(Array.from({ length: 8 }, (_, i) => echo.execute({ value: String(i) }, ctx)));
252
+ const state = getState(ctx);
253
+ expect(state.callSeq).toBe(8);
254
+ expect(new Set(state.activity.map((a) => a.seq)).size).toBe(state.activity.length);
255
+ });
256
+ });