@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.
- package/README.md +51 -0
- package/dist/{_agent-D8zBb3M5.mjs → _agent-DMyOab9_.mjs} +2 -2
- package/dist/{_bundler-DFS4xxqE.mjs → _bundler-Cjaxa2wi.mjs} +1 -1
- package/dist/{_config-D3F9km8X.mjs → _config-5AEqhh-O.mjs} +2 -2
- package/dist/{_dev-server-BBUWo0sb.mjs → _dev-server-vV05Fnki.mjs} +3 -3
- package/dist/{_init-DU-sXH6S.mjs → _init-BZ9t_Kz-.mjs} +3 -56
- package/dist/{_slug-api-19R5kZ0U.mjs → _slug-api-DaqQJHk8.mjs} +1 -1
- package/dist/_templates-Bv8CR800.mjs +69 -0
- package/dist/_templates.d.ts +6 -0
- package/dist/{build-Xq1xxoSg.mjs → build-D_PgQOD4.mjs} +1 -1
- package/dist/cli.mjs +59 -17
- package/dist/{client-bundler-BWDkUeEP.mjs → client-bundler-yiWoXrgb.mjs} +3 -0
- package/dist/client-bundler.d.ts +9 -0
- package/dist/client-bundler.mjs +1 -1
- package/dist/{delete-kSXFLxek.mjs → delete-DXilFBb1.mjs} +1 -1
- package/dist/{deploy-Ci0X1gp2.mjs → deploy-1eaXcfUw.mjs} +3 -3
- package/dist/{dev-g7sGt8qX.mjs → dev-gVNdGFYY.mjs} +1 -1
- package/dist/{init-BjeK8crW.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 +190 -120
- 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.test.ts +3 -3
- package/dist/templates/dispatch-center/agent.ts +7 -7
- package/dist/templates/dispatch-center/client.tsx +7 -2
- package/dist/templates/dispatch-center/tools/incident_add_note.ts +1 -1
- package/dist/templates/dispatch-center/tools/incident_create.ts +1 -1
- package/dist/templates/dispatch-center/tools/incident_escalate.ts +1 -1
- package/dist/templates/dispatch-center/tools/incident_get.ts +1 -1
- package/dist/templates/dispatch-center/tools/incident_triage.ts +1 -1
- package/dist/templates/dispatch-center/tools/incident_update_status.ts +1 -1
- package/dist/templates/dispatch-center/tools/ops_protocols.ts +1 -1
- package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +1 -1
- package/dist/templates/dispatch-center/tools/resources_dispatch.ts +1 -1
- package/dist/templates/dispatch-center/tools/resources_get_available.ts +1 -1
- package/dist/templates/dispatch-center/tools/resources_update_status.ts +1 -1
- package/dist/templates/embedded-assets/agent.ts +2 -3
- package/dist/templates/health-assistant/agent.ts +3 -4
- package/dist/templates/infocom-adventure/agent.ts +10 -12
- package/dist/templates/infocom-adventure/client.tsx +5 -5
- package/dist/templates/math-buddy/agent.ts +7 -6
- package/dist/templates/night-owl/agent.ts +2 -3
- 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.test.ts +13 -11
- package/dist/templates/pizza-ordering/agent.ts +5 -6
- 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 +18 -0
- package/dist/templates/simple/agent.ts +3 -0
- package/dist/templates/solo-rpg/agent.test.ts +2 -2
- package/dist/templates/solo-rpg/agent.ts +1 -2
- package/dist/templates/solo-rpg/tools/action_roll.ts +1 -1
- package/dist/templates/solo-rpg/tools/load_game.ts +1 -1
- package/dist/templates/solo-rpg/tools/oracle.ts +1 -1
- package/dist/templates/solo-rpg/tools/save_game.ts +1 -1
- package/dist/templates/solo-rpg/tools/setup_character.ts +1 -1
- package/dist/templates/solo-rpg/tools/update_state.ts +1 -1
- package/dist/templates/web-researcher/agent.ts +1 -2
- package/dist/typecheck.d.ts +18 -0
- package/dist/typecheck.mjs +8 -0
- package/dist/worker-bundler.d.ts +9 -1
- package/dist/worker-bundler.mjs +8 -0
- package/package.json +3 -3
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import type { ToolContext } from "@alexkroman1/aai";
|
|
2
|
+
import { tool } from "@alexkroman1/aai";
|
|
3
|
+
import type { z } from "zod";
|
|
4
|
+
import seedJson from "./seed.json";
|
|
5
|
+
import type {
|
|
6
|
+
GiftCard,
|
|
7
|
+
Order,
|
|
8
|
+
PaymentMethod,
|
|
9
|
+
Product,
|
|
10
|
+
RetailState,
|
|
11
|
+
StateSlot,
|
|
12
|
+
Store,
|
|
13
|
+
User,
|
|
14
|
+
Variant,
|
|
15
|
+
} from "./shared.ts";
|
|
16
|
+
import { MAX_ACTIVITY } from "./shared.ts";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The JSON import's inferred type has `status: string` where `Order` wants a
|
|
20
|
+
* union, so a direct assignment can't type-check. The cast is validated once,
|
|
21
|
+
* for real, by `seed.test.ts` — which parses this same file through a zod
|
|
22
|
+
* schema — rather than by paying a 107 KB validation on every session start.
|
|
23
|
+
*/
|
|
24
|
+
const SEED = seedJson as unknown as Store;
|
|
25
|
+
|
|
26
|
+
export type ErrorResult = { error: string };
|
|
27
|
+
|
|
28
|
+
export function isError(value: unknown): value is ErrorResult {
|
|
29
|
+
return typeof value === "object" && value !== null && "error" in value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Round to cents. Gift-card balances and price differences are compared for
|
|
33
|
+
* equality, and raw float arithmetic makes that a coin toss. */
|
|
34
|
+
export function money(n: number): number {
|
|
35
|
+
return Math.round(n * 100) / 100;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function isGiftCard(method: PaymentMethod): method is GiftCard {
|
|
39
|
+
return method.source === "gift_card";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ─── Session state ───────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/** A pristine store per session. The deep clone is load-bearing: `SEED` is one
|
|
45
|
+
* module-level object shared by every session in the process, so a mutation
|
|
46
|
+
* without it would let one caller's cancellation show up in another's. */
|
|
47
|
+
export function createDefaultState(): RetailState {
|
|
48
|
+
return {
|
|
49
|
+
store: structuredClone(SEED),
|
|
50
|
+
authenticatedUserId: null,
|
|
51
|
+
callSeq: 0,
|
|
52
|
+
activity: [],
|
|
53
|
+
focus: {},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The session's live store. Mutations to the returned object stick — it is
|
|
58
|
+
* the object held in `ctx.state`. */
|
|
59
|
+
export function getState(ctx: ToolContext): RetailState {
|
|
60
|
+
const slot = ctx.state as StateSlot;
|
|
61
|
+
slot.retail ??= createDefaultState();
|
|
62
|
+
return slot.retail;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function setFocus(
|
|
66
|
+
state: RetailState,
|
|
67
|
+
focus: { orderId?: string; productId?: string },
|
|
68
|
+
): void {
|
|
69
|
+
state.focus = { ...state.focus, ...focus };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── Lookups ─────────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
export function findUser(state: RetailState, userId: string): User | ErrorResult {
|
|
75
|
+
return state.store.users[userId] ?? { error: `User ${userId} not found.` };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function findOrder(state: RetailState, orderId: string): Order | ErrorResult {
|
|
79
|
+
return state.store.orders[orderId] ?? { error: `Order ${orderId} not found.` };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function findProduct(state: RetailState, productId: string): Product | ErrorResult {
|
|
83
|
+
return (
|
|
84
|
+
state.store.products[productId] ?? {
|
|
85
|
+
error: `Product ${productId} not found. Note a product id is not an item id.`,
|
|
86
|
+
}
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function findVariant(product: Product, itemId: string): Variant | ErrorResult {
|
|
91
|
+
return (
|
|
92
|
+
product.variants[itemId] ?? {
|
|
93
|
+
error: `Item ${itemId} is not a variant of ${product.name} (${product.product_id}).`,
|
|
94
|
+
}
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Item ids carry no product reference, so finding one means scanning. */
|
|
99
|
+
export function findItem(
|
|
100
|
+
state: RetailState,
|
|
101
|
+
itemId: string,
|
|
102
|
+
): { product: Product; variant: Variant } | ErrorResult {
|
|
103
|
+
for (const product of Object.values(state.store.products)) {
|
|
104
|
+
const variant = product.variants[itemId];
|
|
105
|
+
if (variant) return { product, variant };
|
|
106
|
+
}
|
|
107
|
+
return { error: `Item ${itemId} not found. Note an item id is not a product id.` };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function findPaymentMethod(user: User, methodId: string): PaymentMethod | ErrorResult {
|
|
111
|
+
return (
|
|
112
|
+
user.payment_methods[methodId] ?? {
|
|
113
|
+
error: `Payment method ${methodId} is not on this customer's profile. Available: ${Object.keys(
|
|
114
|
+
user.payment_methods,
|
|
115
|
+
).join(", ")}.`,
|
|
116
|
+
}
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ─── Guards ──────────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
const NOT_AUTHENTICATED =
|
|
123
|
+
"Not authenticated. Identify the customer first with find_user_id_by_email, " +
|
|
124
|
+
"or find_user_id_by_name_zip if they cannot remember their email.";
|
|
125
|
+
|
|
126
|
+
export function authenticatedUser(state: RetailState): User | ErrorResult {
|
|
127
|
+
if (!state.authenticatedUserId) return { error: NOT_AUTHENTICATED };
|
|
128
|
+
return findUser(state, state.authenticatedUserId);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Resolve an order that belongs to the authenticated customer.
|
|
133
|
+
*
|
|
134
|
+
* An order belonging to someone else and an order that does not exist share
|
|
135
|
+
* one template on purpose: no "belongs to another customer" vs. "does not
|
|
136
|
+
* exist" wording, no owner name, no product name — nothing that lets a
|
|
137
|
+
* caller learn something they did not already know. The echoed `orderId` is
|
|
138
|
+
* the caller's own input, not information about the store, so keeping it
|
|
139
|
+
* matches every sibling lookup (`findUser`, `findOrder`, `findProduct`, …),
|
|
140
|
+
* which all echo the id precisely so an LLM can target a repair retry.
|
|
141
|
+
*/
|
|
142
|
+
export function requireOwnOrder(state: RetailState, orderId: string): Order | ErrorResult {
|
|
143
|
+
const user = authenticatedUser(state);
|
|
144
|
+
if (isError(user)) return user;
|
|
145
|
+
const order = state.store.orders[orderId];
|
|
146
|
+
if (!order || order.user_id !== user.user_id) {
|
|
147
|
+
return { error: `Order ${orderId} was not found on this customer's account.` };
|
|
148
|
+
}
|
|
149
|
+
return order;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ─── Serialized state updates ────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
const sessionLocks = new Map<string, Promise<unknown>>();
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Serialized update of the session's store.
|
|
158
|
+
*
|
|
159
|
+
* The LLM loop can execute parallel tool calls, and a mutator may be async —
|
|
160
|
+
* two interleaving async mutators can each observe the other's half-applied
|
|
161
|
+
* changes. This per-session promise chain runs each update against the previous
|
|
162
|
+
* one's finished result.
|
|
163
|
+
*
|
|
164
|
+
* NOT exported, unlike dispatch-center's equivalent. `retailTool` is the only
|
|
165
|
+
* caller, so a tool body cannot re-enter it — which would chain the inner call
|
|
166
|
+
* on a tail that only resolves once the inner call finishes, i.e. deadlock. A
|
|
167
|
+
* hazard you can't reach beats a hazard you documented.
|
|
168
|
+
*/
|
|
169
|
+
async function updateState<R>(
|
|
170
|
+
ctx: ToolContext,
|
|
171
|
+
mutator: (state: RetailState) => R | Promise<R>,
|
|
172
|
+
): Promise<R> {
|
|
173
|
+
const previous = sessionLocks.get(ctx.sessionId) ?? Promise.resolve();
|
|
174
|
+
const run = previous.then(() => mutator(getState(ctx)));
|
|
175
|
+
// Keep the chain alive across failures, and drop it once idle.
|
|
176
|
+
const tail = run.catch(() => {});
|
|
177
|
+
sessionLocks.set(ctx.sessionId, tail);
|
|
178
|
+
tail.then(() => {
|
|
179
|
+
if (sessionLocks.get(ctx.sessionId) === tail) sessionLocks.delete(ctx.sessionId);
|
|
180
|
+
});
|
|
181
|
+
return run;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ─── The tool wrapper ────────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
interface RetailToolSpec<S extends z.ZodType<Record<string, unknown>>, R> {
|
|
187
|
+
/** Must equal this tool's key in `agent.ts`. `agent.test.ts` asserts it. */
|
|
188
|
+
name: string;
|
|
189
|
+
description: string;
|
|
190
|
+
/** Required even for no-arg tools — pass `z.object({})`. One code path in the
|
|
191
|
+
* wrapper is worth more than saving a line at one call site. */
|
|
192
|
+
inputSchema: S;
|
|
193
|
+
/** Default true. Only the two finder tools and the three catalog tools opt
|
|
194
|
+
* out; everything else touches customer data. */
|
|
195
|
+
requiresAuth?: boolean;
|
|
196
|
+
summary: (args: z.output<S>, result: R) => string;
|
|
197
|
+
execute: (args: z.output<S>, ctx: ToolContext) => R | Promise<R>;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function record(state: RetailState, name: string, summary: string): void {
|
|
201
|
+
state.callSeq += 1;
|
|
202
|
+
state.activity.push({ seq: state.callSeq, tool: name, summary, at: Date.now() });
|
|
203
|
+
if (state.activity.length > MAX_ACTIVITY) {
|
|
204
|
+
state.activity = state.activity.slice(-MAX_ACTIVITY);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Every retail tool is built through this. It owns three things no tool body
|
|
210
|
+
* may re-implement:
|
|
211
|
+
*
|
|
212
|
+
* 1. the authentication gate,
|
|
213
|
+
* 2. serialization of the state mutation,
|
|
214
|
+
* 3. the `callSeq` increment + activity entry — the reason the UI moves on
|
|
215
|
+
* EVERY tool call rather than only when a projected value happens to differ.
|
|
216
|
+
*
|
|
217
|
+
* `focus` is deliberately left to tool bodies (`setFocus`): it is a UI nicety,
|
|
218
|
+
* not an invariant, and only the body knows what the call was about.
|
|
219
|
+
*/
|
|
220
|
+
export function retailTool<S extends z.ZodType<Record<string, unknown>>, R>(
|
|
221
|
+
spec: RetailToolSpec<S, R>,
|
|
222
|
+
) {
|
|
223
|
+
const requiresAuth = spec.requiresAuth ?? true;
|
|
224
|
+
return tool({
|
|
225
|
+
description: spec.description,
|
|
226
|
+
inputSchema: spec.inputSchema,
|
|
227
|
+
execute: (args, ctx) =>
|
|
228
|
+
updateState(ctx, async (state) => {
|
|
229
|
+
const typedArgs = args as z.output<S>;
|
|
230
|
+
if (requiresAuth && !state.authenticatedUserId) {
|
|
231
|
+
record(state, spec.name, "blocked: not authenticated");
|
|
232
|
+
return { error: NOT_AUTHENTICATED };
|
|
233
|
+
}
|
|
234
|
+
const result = await spec.execute(typedArgs, ctx);
|
|
235
|
+
record(
|
|
236
|
+
state,
|
|
237
|
+
spec.name,
|
|
238
|
+
isError(result) ? `error: ${result.error}` : spec.summary(typedArgs, result),
|
|
239
|
+
);
|
|
240
|
+
return result;
|
|
241
|
+
}),
|
|
242
|
+
});
|
|
243
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { createDefaultState, findUser, isError } from "./store.ts";
|
|
3
|
+
import { applySwap, assertCanCoverDiff, planItemSwap } from "./swap.ts";
|
|
4
|
+
|
|
5
|
+
function fixture(orderId: string) {
|
|
6
|
+
const state = createDefaultState();
|
|
7
|
+
const order = state.store.orders[orderId];
|
|
8
|
+
if (!order) throw new Error(`fixture missing ${orderId}`);
|
|
9
|
+
return { state, order };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("planItemSwap", () => {
|
|
13
|
+
test("computes a positive price difference", () => {
|
|
14
|
+
const { state, order } = fixture("#W9311069");
|
|
15
|
+
const plan = planItemSwap(state, order, ["1304426904"], ["4725166838"], {
|
|
16
|
+
requireDifferent: true,
|
|
17
|
+
});
|
|
18
|
+
if (isError(plan)) throw new Error(plan.error);
|
|
19
|
+
expect(plan.diff).toBe(36.32);
|
|
20
|
+
expect(plan.pairs).toHaveLength(1);
|
|
21
|
+
expect(plan.pairs[0]?.newVariant.item_id).toBe("4725166838");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("computes a negative price difference", () => {
|
|
25
|
+
const { state, order } = fixture("#W5866402");
|
|
26
|
+
const plan = planItemSwap(state, order, ["6242772310"], ["6200867091"], {
|
|
27
|
+
requireDifferent: true,
|
|
28
|
+
});
|
|
29
|
+
expect(isError(plan) ? null : plan.diff).toBe(-40.86);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("handles a duplicate item id swapped twice", () => {
|
|
33
|
+
const { state, order } = fixture("#W4316152");
|
|
34
|
+
const plan = planItemSwap(
|
|
35
|
+
state,
|
|
36
|
+
order,
|
|
37
|
+
["7292993796", "7292993796"],
|
|
38
|
+
["3909406921", "3909406921"],
|
|
39
|
+
{ requireDifferent: true },
|
|
40
|
+
);
|
|
41
|
+
if (isError(plan)) throw new Error(plan.error);
|
|
42
|
+
expect(plan.diff).toBe(6.9);
|
|
43
|
+
expect(plan.pairs.map((p) => p.index)).toEqual([0, 1]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("swapping one of two duplicates touches only one line", () => {
|
|
47
|
+
const { state, order } = fixture("#W4316152");
|
|
48
|
+
const plan = planItemSwap(state, order, ["7292993796"], ["3909406921"], {
|
|
49
|
+
requireDifferent: true,
|
|
50
|
+
});
|
|
51
|
+
if (isError(plan)) throw new Error(plan.error);
|
|
52
|
+
expect(plan.pairs).toHaveLength(1);
|
|
53
|
+
expect(plan.diff).toBe(3.45);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("refuses asking for more copies than the order holds", () => {
|
|
57
|
+
const { state, order } = fixture("#W4316152");
|
|
58
|
+
const plan = planItemSwap(
|
|
59
|
+
state,
|
|
60
|
+
order,
|
|
61
|
+
["7292993796", "7292993796", "7292993796"],
|
|
62
|
+
["3909406921", "3909406921", "3909406921"],
|
|
63
|
+
{ requireDifferent: true },
|
|
64
|
+
);
|
|
65
|
+
expect(isError(plan) && plan.error).toContain("7292993796");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("refuses mismatched list lengths", () => {
|
|
69
|
+
const { state, order } = fixture("#W4316152");
|
|
70
|
+
const plan = planItemSwap(state, order, ["7292993796"], ["3909406921", "3738831434"], {
|
|
71
|
+
requireDifferent: true,
|
|
72
|
+
});
|
|
73
|
+
expect(isError(plan) && plan.error.toLowerCase()).toContain("same number");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("refuses an empty swap", () => {
|
|
77
|
+
const { state, order } = fixture("#W4316152");
|
|
78
|
+
expect(isError(planItemSwap(state, order, [], [], { requireDifferent: true }))).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("refuses an item the order does not contain", () => {
|
|
82
|
+
const { state, order } = fixture("#W4316152");
|
|
83
|
+
const plan = planItemSwap(state, order, ["1304426904"], ["4725166838"], {
|
|
84
|
+
requireDifferent: true,
|
|
85
|
+
});
|
|
86
|
+
expect(isError(plan)).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("refuses a target of a different product", () => {
|
|
90
|
+
const { state, order } = fixture("#W4316152");
|
|
91
|
+
const plan = planItemSwap(state, order, ["7292993796"], ["4725166838"], {
|
|
92
|
+
requireDifferent: true,
|
|
93
|
+
});
|
|
94
|
+
expect(isError(plan) && plan.error).toContain("Tea Kettle");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("refuses an unavailable target", () => {
|
|
98
|
+
const { state, order } = fixture("#W4316152");
|
|
99
|
+
const plan = planItemSwap(state, order, ["7292993796"], ["6454334990"], {
|
|
100
|
+
requireDifferent: true,
|
|
101
|
+
});
|
|
102
|
+
expect(isError(plan) && plan.error.toLowerCase()).toContain("not available");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("requireDifferent refuses a no-op swap; exchange allows it", () => {
|
|
106
|
+
const { state, order } = fixture("#W4316152");
|
|
107
|
+
const strict = planItemSwap(state, order, ["7292993796"], ["7292993796"], {
|
|
108
|
+
requireDifferent: true,
|
|
109
|
+
});
|
|
110
|
+
expect(isError(strict)).toBe(true);
|
|
111
|
+
const loose = planItemSwap(state, order, ["7292993796"], ["7292993796"], {
|
|
112
|
+
requireDifferent: false,
|
|
113
|
+
});
|
|
114
|
+
expect(isError(loose) ? null : loose.diff).toBe(0);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("assertCanCoverDiff", () => {
|
|
119
|
+
function aarav() {
|
|
120
|
+
const state = createDefaultState();
|
|
121
|
+
const user = findUser(state, "aarav_anderson_8794");
|
|
122
|
+
if (isError(user)) throw new Error(user.error);
|
|
123
|
+
return user;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
test("refuses a gift card that cannot cover the difference", () => {
|
|
127
|
+
const result = assertCanCoverDiff(aarav(), "gift_card_7245904", 36.32);
|
|
128
|
+
expect(result?.error.toLowerCase()).toContain("balance");
|
|
129
|
+
expect(result?.error).toContain("17");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("allows a difference the gift card covers", () => {
|
|
133
|
+
expect(assertCanCoverDiff(aarav(), "gift_card_7245904", 3.45)).toBeNull();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("allows a refund direction regardless of balance", () => {
|
|
137
|
+
expect(assertCanCoverDiff(aarav(), "gift_card_7245904", -382.03)).toBeNull();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("does not gate a non-gift-card method on any balance", () => {
|
|
141
|
+
const state = createDefaultState();
|
|
142
|
+
const olivia = findUser(state, "olivia_ito_3591");
|
|
143
|
+
if (isError(olivia)) throw new Error(olivia.error);
|
|
144
|
+
expect(assertCanCoverDiff(olivia, "credit_card_9753331", 100_000)).toBeNull();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("refuses a method not on the profile", () => {
|
|
148
|
+
expect(assertCanCoverDiff(aarav(), "gift_card_7794233", 1)?.error).toContain("profile");
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("applySwap", () => {
|
|
153
|
+
test("each swapped line takes its OWN new price and options", () => {
|
|
154
|
+
const { state, order } = fixture("#W9311069");
|
|
155
|
+
// Two different products in one call — this is what catches the leaked
|
|
156
|
+
// loop variable tau2's implementation writes prices with.
|
|
157
|
+
const plan = planItemSwap(
|
|
158
|
+
state,
|
|
159
|
+
order,
|
|
160
|
+
["1304426904", "4238115171"],
|
|
161
|
+
["4725166838", "3909406921"],
|
|
162
|
+
{ requireDifferent: true },
|
|
163
|
+
);
|
|
164
|
+
if (isError(plan)) throw new Error(plan.error);
|
|
165
|
+
applySwap(order, plan);
|
|
166
|
+
|
|
167
|
+
const vacuum = order.items.find((i) => i.item_id === "4725166838");
|
|
168
|
+
const kettle = order.items.find((i) => i.item_id === "3909406921");
|
|
169
|
+
expect(vacuum?.price).toBe(602.11);
|
|
170
|
+
expect(kettle?.price).toBe(98.25);
|
|
171
|
+
expect(vacuum?.name).toBe("Vacuum Cleaner");
|
|
172
|
+
expect(kettle?.options).toEqual(
|
|
173
|
+
state.store.products["9832717871"]?.variants["3909406921"]?.options,
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("leaves untouched lines alone", () => {
|
|
178
|
+
const { state, order } = fixture("#W4316152");
|
|
179
|
+
const plan = planItemSwap(state, order, ["7292993796"], ["3909406921"], {
|
|
180
|
+
requireDifferent: true,
|
|
181
|
+
});
|
|
182
|
+
if (isError(plan)) throw new Error(plan.error);
|
|
183
|
+
applySwap(order, plan);
|
|
184
|
+
expect(order.items.map((i) => i.item_id)).toEqual(["3909406921", "7292993796"]);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { Order, OrderItem, RetailState, User, Variant } from "./shared.ts";
|
|
2
|
+
import {
|
|
3
|
+
type ErrorResult,
|
|
4
|
+
findPaymentMethod,
|
|
5
|
+
findProduct,
|
|
6
|
+
findVariant,
|
|
7
|
+
isError,
|
|
8
|
+
isGiftCard,
|
|
9
|
+
money,
|
|
10
|
+
} from "./store.ts";
|
|
11
|
+
|
|
12
|
+
export interface SwapPair {
|
|
13
|
+
/** Index into `order.items`. Matching by index rather than by "first item
|
|
14
|
+
* with this id" is what makes duplicates independent of mutation order. */
|
|
15
|
+
index: number;
|
|
16
|
+
item: OrderItem;
|
|
17
|
+
newVariant: Variant;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SwapPlan {
|
|
21
|
+
pairs: SwapPair[];
|
|
22
|
+
/** Positive = the customer owes; negative = they are owed. Cents-rounded. */
|
|
23
|
+
diff: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Validate a proposed item swap and compute its price difference, without
|
|
28
|
+
* mutating anything. Shared by `modify_pending_order_items` (pending, which
|
|
29
|
+
* additionally forbids a no-op swap) and `exchange_delivered_order_items`
|
|
30
|
+
* (delivered, which does not).
|
|
31
|
+
*
|
|
32
|
+
* Validation order matches tau2's so a bad call fails on the same check.
|
|
33
|
+
*/
|
|
34
|
+
export function planItemSwap(
|
|
35
|
+
state: RetailState,
|
|
36
|
+
order: Order,
|
|
37
|
+
itemIds: string[],
|
|
38
|
+
newItemIds: string[],
|
|
39
|
+
opts: { requireDifferent: boolean },
|
|
40
|
+
): SwapPlan | ErrorResult {
|
|
41
|
+
if (itemIds.length === 0) {
|
|
42
|
+
return { error: "No items were listed to change." };
|
|
43
|
+
}
|
|
44
|
+
if (itemIds.length !== newItemIds.length) {
|
|
45
|
+
return {
|
|
46
|
+
error: `Got ${itemIds.length} item(s) to change but ${newItemIds.length} replacement(s) — the lists must hold the same number, each replacement matching the item in the same position.`,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Duplicate-aware availability: an order holding one of an item cannot have
|
|
51
|
+
// two of them changed.
|
|
52
|
+
const heldCounts = new Map<string, number>();
|
|
53
|
+
for (const item of order.items) {
|
|
54
|
+
heldCounts.set(item.item_id, (heldCounts.get(item.item_id) ?? 0) + 1);
|
|
55
|
+
}
|
|
56
|
+
const askedCounts = new Map<string, number>();
|
|
57
|
+
for (const itemId of itemIds) {
|
|
58
|
+
askedCounts.set(itemId, (askedCounts.get(itemId) ?? 0) + 1);
|
|
59
|
+
}
|
|
60
|
+
for (const [itemId, asked] of askedCounts) {
|
|
61
|
+
const held = heldCounts.get(itemId) ?? 0;
|
|
62
|
+
if (asked > held) {
|
|
63
|
+
return {
|
|
64
|
+
error: `Order ${order.order_id} holds ${held} of item ${itemId}, but ${asked} were listed to change.`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const consumed = new Set<number>();
|
|
70
|
+
const pairs: SwapPair[] = [];
|
|
71
|
+
let diff = 0;
|
|
72
|
+
|
|
73
|
+
for (const [position, itemId] of itemIds.entries()) {
|
|
74
|
+
const newItemId = newItemIds[position];
|
|
75
|
+
if (!newItemId) {
|
|
76
|
+
return { error: `No replacement was given for item ${itemId}.` };
|
|
77
|
+
}
|
|
78
|
+
if (opts.requireDifferent && newItemId === itemId) {
|
|
79
|
+
return {
|
|
80
|
+
error: `Item ${itemId} was listed as its own replacement. Pick a different option of the same product, or leave it out.`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const index = order.items.findIndex((item, i) => item.item_id === itemId && !consumed.has(i));
|
|
85
|
+
const item = order.items[index];
|
|
86
|
+
if (index === -1 || !item) {
|
|
87
|
+
return { error: `Item ${itemId} is not in order ${order.order_id}.` };
|
|
88
|
+
}
|
|
89
|
+
consumed.add(index);
|
|
90
|
+
|
|
91
|
+
const product = findProduct(state, item.product_id);
|
|
92
|
+
if (isError(product)) return product;
|
|
93
|
+
|
|
94
|
+
const newVariant = findVariant(product, newItemId);
|
|
95
|
+
if (isError(newVariant)) {
|
|
96
|
+
return {
|
|
97
|
+
error: `${newItemId} is not an option of ${product.name} (${product.product_id}). An item can only be changed to a different option of the same product — you cannot change a ${product.name} into something else.`,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if (!newVariant.available) {
|
|
101
|
+
return {
|
|
102
|
+
error: `${product.name} option ${newItemId} (${Object.values(newVariant.options).join(", ")}) is not available.`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
diff += newVariant.price - item.price;
|
|
107
|
+
pairs.push({ index, item, newVariant });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { pairs, diff: money(diff) };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Gate the price difference on the chosen payment method. Only a gift card has
|
|
115
|
+
* a balance to run out of; a negative difference is a refund and never gated.
|
|
116
|
+
*/
|
|
117
|
+
export function assertCanCoverDiff(user: User, methodId: string, diff: number): ErrorResult | null {
|
|
118
|
+
const method = findPaymentMethod(user, methodId);
|
|
119
|
+
if (isError(method)) return method;
|
|
120
|
+
if (isGiftCard(method) && method.balance < diff) {
|
|
121
|
+
return {
|
|
122
|
+
error: `Gift card ${methodId}'s balance ($${method.balance.toFixed(2)}) does not cover the $${diff.toFixed(2)} difference. Ask for another payment method.`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Apply a validated plan. Each line takes its OWN new variant's price, options
|
|
129
|
+
* and name — see the module note on tau2's leaked loop variable. */
|
|
130
|
+
export function applySwap(order: Order, plan: SwapPlan): void {
|
|
131
|
+
for (const pair of plan.pairs) {
|
|
132
|
+
const line = order.items[pair.index];
|
|
133
|
+
if (!line) continue;
|
|
134
|
+
line.item_id = pair.newVariant.item_id;
|
|
135
|
+
line.price = pair.newVariant.price;
|
|
136
|
+
line.options = pair.newVariant.options;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
You are a retail customer-service agent for an online store, talking to a
|
|
2
|
+
customer on the phone. You can help them cancel or modify pending orders,
|
|
3
|
+
return or exchange delivered orders, update their default address, and answer
|
|
4
|
+
questions about their own profile, orders and our products.
|
|
5
|
+
|
|
6
|
+
# Authenticate first
|
|
7
|
+
|
|
8
|
+
Before anything else, identify who you are talking to by finding their user id
|
|
9
|
+
— by email (`find_user_id_by_email`), or by first name, last name and zip code
|
|
10
|
+
(`find_user_id_by_name_zip`) if they cannot remember the email. Do this **even
|
|
11
|
+
if the caller volunteers their user id**.
|
|
12
|
+
|
|
13
|
+
Prefer email. Fall back to name + zip only when they cannot recall the address
|
|
14
|
+
on the account.
|
|
15
|
+
|
|
16
|
+
# One customer per call
|
|
17
|
+
|
|
18
|
+
You help exactly one customer per conversation. You may handle any number of
|
|
19
|
+
requests from that person, but you must refuse anything to do with anybody
|
|
20
|
+
else's account, and you cannot switch to a different customer mid-call — ask
|
|
21
|
+
them to call back.
|
|
22
|
+
|
|
23
|
+
# Confirm every change out loud
|
|
24
|
+
|
|
25
|
+
Before any action that changes something — cancel, modify, return, exchange —
|
|
26
|
+
say what you are about to do, including the order, the items, the amounts and
|
|
27
|
+
where money is going, and wait for an explicit "yes". Never act on an implied
|
|
28
|
+
yes.
|
|
29
|
+
|
|
30
|
+
# Never invent anything
|
|
31
|
+
|
|
32
|
+
Only tell the customer what the tools and the customer themselves have told
|
|
33
|
+
you. Do not guess at policy, stock, delivery dates, or prices, and do not offer
|
|
34
|
+
opinions or recommendations about products. If you do not know, say so.
|
|
35
|
+
|
|
36
|
+
Refuse requests that fall outside this policy.
|
|
37
|
+
|
|
38
|
+
# One thing at a time
|
|
39
|
+
|
|
40
|
+
Make at most one tool call at a time. When you make a tool call, do not also
|
|
41
|
+
speak; when you speak, do not also make a tool call.
|
|
42
|
+
|
|
43
|
+
# Handing off to a human
|
|
44
|
+
|
|
45
|
+
Transfer only if the caller explicitly asks for a human, or their request
|
|
46
|
+
cannot be handled with your tools and this policy. Call
|
|
47
|
+
`transfer_to_human_agents` first, then say exactly: "You are being transferred
|
|
48
|
+
to a human agent. Please hold on." Say nothing else after that.
|
|
49
|
+
|
|
50
|
+
# Speaking on the phone
|
|
51
|
+
|
|
52
|
+
- The customer can see their orders on screen. Say "your pending order" or "the
|
|
53
|
+
espresso machine order" rather than reciting an order number.
|
|
54
|
+
- When you do have to say an order number, read it in groups: "W, seven six
|
|
55
|
+
seven, eight zero seven two" — never as one long run of digits.
|
|
56
|
+
- **Never read a ten-digit item number out loud** unless the customer asks for
|
|
57
|
+
it. Name the item and its options instead: "the glass two-litre kettle".
|
|
58
|
+
- When a customer gives you a number, read it back before you use it.
|
|
59
|
+
- Prices: "three hundred and twenty dollars and fifty cents", not "320.50".
|
|
60
|
+
- Keep replies to one or two sentences. This is a phone call, not an email.
|
|
61
|
+
- Do not spell out tool names, statuses in snake_case, or user ids.
|
|
62
|
+
|
|
63
|
+
# What the tools accept
|
|
64
|
+
|
|
65
|
+
Order references can be spoken: "my pending order", "the delivered one", "the
|
|
66
|
+
second pending order". If a reference is ambiguous the tool will tell you which
|
|
67
|
+
orders matched — ask the customer which one, never pick for them.
|
|
68
|
+
|
|
69
|
+
Item references can be spoken too: "the blue medium" resolves against a
|
|
70
|
+
product's options.
|
|
71
|
+
|
|
72
|
+
# The rules that bite
|
|
73
|
+
|
|
74
|
+
**All times in the store's records are EST, 24-hour.** "02:30:00" is 2:30 in
|
|
75
|
+
the morning.
|
|
76
|
+
|
|
77
|
+
**Cancelling a pending order.** Only an order whose status is exactly
|
|
78
|
+
"pending". Check the status first. The reason must be either "no longer needed"
|
|
79
|
+
or "ordered by mistake" — if the customer gives another reason, tell them those
|
|
80
|
+
are the only two we can record and ask which fits. A gift-card refund lands
|
|
81
|
+
immediately; everything else takes 5 to 7 business days.
|
|
82
|
+
|
|
83
|
+
**Changing the items in a pending order is once-only and irreversible.** After
|
|
84
|
+
it, the order cannot be cancelled or modified again — not by you, not by
|
|
85
|
+
anyone. So before you call it: get the customer to confirm they have named
|
|
86
|
+
**every** item they want changed, then read the complete list and the price
|
|
87
|
+
difference back. Ask "is that everything you want to change?" explicitly. An
|
|
88
|
+
item can only become a different option of the same product — a shirt cannot
|
|
89
|
+
become shoes.
|
|
90
|
+
|
|
91
|
+
**Changing a pending order's payment method.** One method only, and it must be
|
|
92
|
+
different from the current one. A gift card must cover the whole order total.
|
|
93
|
+
The old method is refunded.
|
|
94
|
+
|
|
95
|
+
**Returning a delivered order.** Only "delivered", only once. The refund goes
|
|
96
|
+
to the original payment method or to one of their gift cards — nothing else.
|
|
97
|
+
Confirm the exact item list. The customer gets an email about sending things
|
|
98
|
+
back.
|
|
99
|
+
|
|
100
|
+
**Exchanging a delivered order.** Only "delivered", only once. Same-product
|
|
101
|
+
options only. Remind them to name every item first, exactly as with modifying a
|
|
102
|
+
pending order. The price difference is charged to or refunded from a payment
|
|
103
|
+
method they choose; a gift card must cover a positive difference. No new order
|
|
104
|
+
is needed.
|
|
105
|
+
|
|
106
|
+
**Processed orders.** Once an order is "processed" it has left us — it cannot be
|
|
107
|
+
cancelled or modified. It can be returned or exchanged once it is delivered.
|
|
108
|
+
|
|
109
|
+
**Product ids and item ids are different things** and are never
|
|
110
|
+
interchangeable. A product is "Tea Kettle"; an item is one specific kettle with
|
|
111
|
+
its own material, capacity and stovetop compatibility.
|