@alexkroman1/aai-cli 5.4.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.
- package/dist/{_agent-D8zBb3M5.mjs → _agent-DMyOab9_.mjs} +2 -2
- package/dist/{_config-D3F9km8X.mjs → _config-5AEqhh-O.mjs} +2 -2
- package/dist/{_dev-server-cigzayJe.mjs → _dev-server-vV05Fnki.mjs} +1 -1
- package/dist/{_init-_G8l4lh2.mjs → _init-BZ9t_Kz-.mjs} +2 -2
- package/dist/{_slug-api-19R5kZ0U.mjs → _slug-api-DaqQJHk8.mjs} +1 -1
- package/dist/{_templates-DW9CvuDN.mjs → _templates-Bv8CR800.mjs} +1 -1
- package/dist/cli.mjs +28 -11
- package/dist/{delete-kSXFLxek.mjs → delete-DXilFBb1.mjs} +1 -1
- package/dist/{deploy-C0VLnnJe.mjs → deploy-1eaXcfUw.mjs} +2 -2
- package/dist/{dev-2oUsvgRX.mjs → dev-gVNdGFYY.mjs} +1 -1
- package/dist/{init-BkW9BuZF.mjs → init-DoU4_txp.mjs} +3 -3
- package/dist/login-C59ZHzuO.mjs +109 -0
- package/dist/login.d.ts +34 -0
- package/dist/scaffold/CLAUDE.md +69 -53
- package/dist/scaffold/package.json +3 -3
- package/dist/{secret-DOva9OGk.mjs → secret-CGAIAbUx.mjs} +1 -1
- package/dist/{storage-vM6HjDZZ.mjs → storage-CnhOayhm.mjs} +1 -1
- package/dist/templates/code-interpreter/agent.ts +1 -2
- package/dist/templates/dispatch-center/agent.ts +5 -5
- package/dist/templates/dispatch-center/client.tsx +7 -2
- package/dist/templates/embedded-assets/agent.ts +1 -2
- package/dist/templates/health-assistant/agent.ts +1 -2
- package/dist/templates/infocom-adventure/agent.ts +4 -6
- package/dist/templates/infocom-adventure/client.tsx +5 -5
- package/dist/templates/math-buddy/agent.ts +6 -5
- package/dist/templates/night-owl/agent.ts +1 -2
- package/dist/templates/personal-finance/agent.ts +1 -2
- package/dist/templates/pipeline-simple/agent.test.ts +13 -13
- package/dist/templates/pipeline-simple/agent.ts +5 -6
- package/dist/templates/pizza-ordering/agent.ts +1 -2
- package/dist/templates/retail/address.ts +29 -0
- package/dist/templates/retail/agent.test.ts +1030 -0
- package/dist/templates/retail/agent.ts +71 -0
- package/dist/templates/retail/authenticate.ts +34 -0
- package/dist/templates/retail/client.tsx +459 -0
- package/dist/templates/retail/refund.ts +19 -0
- package/dist/templates/retail/registry.test.ts +182 -0
- package/dist/templates/retail/resolve.test.ts +158 -0
- package/dist/templates/retail/resolve.ts +158 -0
- package/dist/templates/retail/seed.json +4260 -0
- package/dist/templates/retail/seed.test.ts +224 -0
- package/dist/templates/retail/shared.test.ts +192 -0
- package/dist/templates/retail/shared.ts +430 -0
- package/dist/templates/retail/store.test.ts +256 -0
- package/dist/templates/retail/store.ts +243 -0
- package/dist/templates/retail/swap.test.ts +186 -0
- package/dist/templates/retail/swap.ts +138 -0
- package/dist/templates/retail/system-prompt.md +111 -0
- package/dist/templates/retail/tools/cancel_pending_order.ts +82 -0
- package/dist/templates/retail/tools/exchange_delivered_order_items.ts +83 -0
- package/dist/templates/retail/tools/find_user_id_by_email.ts +32 -0
- package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +38 -0
- package/dist/templates/retail/tools/get_item_details.ts +29 -0
- package/dist/templates/retail/tools/get_order_details.ts +41 -0
- package/dist/templates/retail/tools/get_product_details.ts +33 -0
- package/dist/templates/retail/tools/get_user_details.ts +44 -0
- package/dist/templates/retail/tools/list_all_product_types.ts +24 -0
- package/dist/templates/retail/tools/modify_pending_order_address.ts +46 -0
- package/dist/templates/retail/tools/modify_pending_order_items.ts +102 -0
- package/dist/templates/retail/tools/modify_pending_order_payment.ts +103 -0
- package/dist/templates/retail/tools/modify_user_address.ts +38 -0
- package/dist/templates/retail/tools/return_delivered_order_items.ts +101 -0
- package/dist/templates/retail/tools/transfer_to_human_agents.ts +22 -0
- package/dist/templates/simple/agent.test.ts +1 -1
- package/dist/templates/simple/agent.ts +2 -1
- package/dist/templates/solo-rpg/agent.ts +1 -2
- package/dist/templates/web-researcher/agent.ts +1 -2
- 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
|
+
});
|