@alexkroman1/aai-cli 5.13.2 → 5.14.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 (64) hide show
  1. package/dist/scaffold/package.json +3 -3
  2. package/dist/scaffold/pnpm-workspace.yaml +24 -0
  3. package/dist/templates/dispatch-center/agent.test.ts +10 -17
  4. package/dist/templates/dispatch-center/agent.ts +2 -2
  5. package/dist/templates/dispatch-center/client.tsx +9 -13
  6. package/dist/templates/dispatch-center/shared.ts +36 -63
  7. package/dist/templates/dispatch-center/tools/incident_add_note.ts +4 -4
  8. package/dist/templates/dispatch-center/tools/incident_create.ts +2 -2
  9. package/dist/templates/dispatch-center/tools/incident_escalate.ts +4 -4
  10. package/dist/templates/dispatch-center/tools/incident_get.ts +9 -4
  11. package/dist/templates/dispatch-center/tools/incident_triage.ts +4 -4
  12. package/dist/templates/dispatch-center/tools/incident_update_status.ts +4 -4
  13. package/dist/templates/dispatch-center/tools/ops_dashboard.ts +2 -2
  14. package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +2 -2
  15. package/dist/templates/dispatch-center/tools/resources_dispatch.ts +4 -4
  16. package/dist/templates/dispatch-center/tools/resources_get_available.ts +2 -2
  17. package/dist/templates/dispatch-center/tools/resources_update_status.ts +2 -2
  18. package/dist/templates/infocom-adventure/agent.ts +10 -10
  19. package/dist/templates/infocom-adventure/client.tsx +5 -17
  20. package/dist/templates/infocom-adventure/shared.ts +17 -14
  21. package/dist/templates/pizza-ordering/agent.test.ts +27 -31
  22. package/dist/templates/pizza-ordering/agent.ts +8 -8
  23. package/dist/templates/pizza-ordering/client.tsx +4 -4
  24. package/dist/templates/pizza-ordering/shared.ts +14 -20
  25. package/dist/templates/retail/agent.test.ts +137 -138
  26. package/dist/templates/retail/agent.ts +7 -10
  27. package/dist/templates/retail/authenticate.ts +2 -2
  28. package/dist/templates/retail/client.tsx +13 -14
  29. package/dist/templates/retail/registry.test.ts +9 -9
  30. package/dist/templates/retail/resolve.test.ts +17 -16
  31. package/dist/templates/retail/resolve.ts +5 -4
  32. package/dist/templates/retail/shared.test.ts +17 -16
  33. package/dist/templates/retail/shared.ts +39 -18
  34. package/dist/templates/retail/store.test.ts +40 -47
  35. package/dist/templates/retail/store.ts +39 -65
  36. package/dist/templates/retail/swap.test.ts +20 -17
  37. package/dist/templates/retail/swap.ts +7 -14
  38. package/dist/templates/retail/tools/cancel_pending_order.ts +5 -4
  39. package/dist/templates/retail/tools/exchange_delivered_order_items.ts +6 -5
  40. package/dist/templates/retail/tools/find_user_id_by_email.ts +2 -2
  41. package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +2 -2
  42. package/dist/templates/retail/tools/get_item_details.ts +4 -3
  43. package/dist/templates/retail/tools/get_order_details.ts +4 -3
  44. package/dist/templates/retail/tools/get_product_details.ts +4 -3
  45. package/dist/templates/retail/tools/get_user_details.ts +4 -3
  46. package/dist/templates/retail/tools/list_all_product_types.ts +2 -2
  47. package/dist/templates/retail/tools/modify_pending_order_address.ts +4 -3
  48. package/dist/templates/retail/tools/modify_pending_order_items.ts +6 -6
  49. package/dist/templates/retail/tools/modify_pending_order_payment.ts +6 -6
  50. package/dist/templates/retail/tools/modify_user_address.ts +4 -3
  51. package/dist/templates/retail/tools/return_delivered_order_items.ts +6 -6
  52. package/dist/templates/solo-rpg/agent.test.ts +26 -34
  53. package/dist/templates/solo-rpg/agent.ts +2 -2
  54. package/dist/templates/solo-rpg/client.tsx +1 -1
  55. package/dist/templates/solo-rpg/shared.ts +6 -14
  56. package/dist/templates/solo-rpg/tools/action_roll.ts +2 -2
  57. package/dist/templates/solo-rpg/tools/burn_momentum.ts +2 -2
  58. package/dist/templates/solo-rpg/tools/check_state.ts +2 -2
  59. package/dist/templates/solo-rpg/tools/load_game.ts +2 -2
  60. package/dist/templates/solo-rpg/tools/oracle.ts +3 -3
  61. package/dist/templates/solo-rpg/tools/save_game.ts +2 -2
  62. package/dist/templates/solo-rpg/tools/setup_character.ts +2 -2
  63. package/dist/templates/solo-rpg/tools/update_state.ts +12 -11
  64. package/package.json +3 -3
@@ -1,4 +1,5 @@
1
- import type { ToolContext } from "@alexkroman1/aai";
1
+ import { isToolFailure, type ToolContext } from "@alexkroman1/aai";
2
+ import { createToolContext } from "@alexkroman1/aai/testing";
2
3
  import { describe, expect, test } from "vitest";
3
4
  import { z } from "zod";
4
5
  import {
@@ -10,31 +11,23 @@ import {
10
11
  findProduct,
11
12
  findUser,
12
13
  findVariant,
13
- getState,
14
- isError,
15
14
  isGiftCard,
16
15
  money,
17
16
  requireOwnOrder,
17
+ retailSlot,
18
18
  retailTool,
19
+ type StateSlot,
19
20
  } from "./store.ts";
20
21
 
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;
22
+ function makeCtx(): ToolContext<StateSlot> {
23
+ return createToolContext<StateSlot>();
31
24
  }
32
25
 
33
26
  describe("session state", () => {
34
- test("getState seeds lazily and returns the same object on re-entry", () => {
27
+ test("the slot seeds lazily and returns the same object on re-entry", () => {
35
28
  const ctx = makeCtx();
36
- const a = getState(ctx);
37
- const b = getState(ctx);
29
+ const a = retailSlot.get(ctx);
30
+ const b = retailSlot.get(ctx);
38
31
  expect(a).toBe(b);
39
32
  expect(a.authenticatedUserId).toBeNull();
40
33
  expect(a.callSeq).toBe(0);
@@ -42,14 +35,14 @@ describe("session state", () => {
42
35
  });
43
36
 
44
37
  test("each session gets its own deep copy — a mutation cannot leak across sessions", () => {
45
- const first = getState(makeCtx());
38
+ const first = retailSlot.get(makeCtx());
46
39
  const order = first.store.orders["#W9300146"];
47
40
  if (!order) throw new Error("fixture missing");
48
41
  order.status = "cancelled";
49
42
  const giftCard = first.store.users.aarav_anderson_8794?.payment_methods.gift_card_7245904;
50
43
  if (giftCard?.source === "gift_card") giftCard.balance = 0;
51
44
 
52
- const second = getState(makeCtx());
45
+ const second = retailSlot.get(makeCtx());
53
46
  expect(second.store.orders["#W9300146"]?.status).toBe("pending");
54
47
  const fresh = second.store.users.aarav_anderson_8794?.payment_methods.gift_card_7245904;
55
48
  expect(fresh?.source === "gift_card" && fresh.balance).toBe(17);
@@ -67,52 +60,52 @@ describe("money", () => {
67
60
  describe("lookups", () => {
68
61
  test("findUser resolves and reports a miss by id", () => {
69
62
  const state = createDefaultState();
70
- expect(isError(findUser(state, "olivia_ito_3591"))).toBe(false);
63
+ expect(isToolFailure(findUser(state, "olivia_ito_3591"))).toBe(false);
71
64
  const miss = findUser(state, "nobody_1");
72
- expect(isError(miss) && miss.error).toContain("nobody_1");
65
+ expect(isToolFailure(miss) && miss.error).toContain("nobody_1");
73
66
  });
74
67
 
75
68
  test("findOrder resolves and reports a miss", () => {
76
69
  const state = createDefaultState();
77
70
  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);
71
+ expect(isToolFailure(order)).toBe(false);
72
+ expect(isToolFailure(order) ? null : order.status).toBe("delivered");
73
+ expect(isToolFailure(findOrder(state, "#W0000000"))).toBe(true);
81
74
  });
82
75
 
83
76
  test("findProduct and findVariant resolve within one product", () => {
84
77
  const state = createDefaultState();
85
78
  const product = findProduct(state, "9832717871");
86
- if (isError(product)) throw new Error(product.error);
79
+ if (isToolFailure(product)) throw new Error(product.error);
87
80
  expect(product.name).toBe("Tea Kettle");
88
81
  const variant = findVariant(product, "3909406921");
89
- expect(isError(variant) ? null : variant.price).toBe(98.25);
82
+ expect(isToolFailure(variant) ? null : variant.price).toBe(98.25);
90
83
  // A real item id, but of a different product.
91
- expect(isError(findVariant(product, "4725166838"))).toBe(true);
84
+ expect(isToolFailure(findVariant(product, "4725166838"))).toBe(true);
92
85
  });
93
86
 
94
87
  test("findItem scans every product and returns both product and variant", () => {
95
88
  const state = createDefaultState();
96
89
  const found = findItem(state, "3909406921");
97
- if (isError(found)) throw new Error(found.error);
90
+ if (isToolFailure(found)) throw new Error(found.error);
98
91
  expect(found.product.product_id).toBe("9832717871");
99
92
  expect(found.variant.price).toBe(98.25);
100
- expect(isError(findItem(state, "0000000000"))).toBe(true);
93
+ expect(isToolFailure(findItem(state, "0000000000"))).toBe(true);
101
94
  });
102
95
 
103
96
  test("findPaymentMethod resolves on the owning user only", () => {
104
97
  const state = createDefaultState();
105
98
  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);
99
+ if (isToolFailure(olivia)) throw new Error(olivia.error);
100
+ expect(isToolFailure(findPaymentMethod(olivia, "gift_card_7794233"))).toBe(false);
108
101
  // aarav's card is not olivia's.
109
- expect(isError(findPaymentMethod(olivia, "gift_card_7245904"))).toBe(true);
102
+ expect(isToolFailure(findPaymentMethod(olivia, "gift_card_7245904"))).toBe(true);
110
103
  });
111
104
 
112
105
  test("isGiftCard narrows", () => {
113
106
  const state = createDefaultState();
114
107
  const olivia = findUser(state, "olivia_ito_3591");
115
- if (isError(olivia)) throw new Error(olivia.error);
108
+ if (isToolFailure(olivia)) throw new Error(olivia.error);
116
109
  const card = olivia.payment_methods.gift_card_7794233;
117
110
  const paypal = olivia.payment_methods.paypal_8049766;
118
111
  expect(card && isGiftCard(card)).toBe(true);
@@ -124,15 +117,15 @@ describe("ownership and authentication guards", () => {
124
117
  test("authenticatedUser errors before authentication", () => {
125
118
  const state = createDefaultState();
126
119
  const result = authenticatedUser(state);
127
- expect(isError(result) && result.error.toLowerCase()).toContain("find_user_id_by_email");
120
+ expect(isToolFailure(result) && result.error.toLowerCase()).toContain("find_user_id_by_email");
128
121
  });
129
122
 
130
123
  test("requireOwnOrder refuses another customer's order without revealing it", () => {
131
124
  const state = createDefaultState();
132
125
  state.authenticatedUserId = "olivia_ito_3591";
133
126
  const result = requireOwnOrder(state, "#W4316152"); // aarav's
134
- expect(isError(result)).toBe(true);
135
- if (!isError(result)) throw new Error("expected refusal");
127
+ expect(isToolFailure(result)).toBe(true);
128
+ if (!isToolFailure(result)) throw new Error("expected refusal");
136
129
  expect(result.error).not.toContain("aarav");
137
130
  expect(result.error).not.toContain("Tea Kettle");
138
131
  });
@@ -144,8 +137,8 @@ describe("ownership and authentication guards", () => {
144
137
  const missingId = "#W0000000";
145
138
  const foreign = requireOwnOrder(state, foreignId);
146
139
  const missing = requireOwnOrder(state, missingId);
147
- expect(isError(foreign) && isError(missing)).toBe(true);
148
- if (!(isError(foreign) && isError(missing))) throw new Error("expected refusals");
140
+ expect(isToolFailure(foreign) && isToolFailure(missing)).toBe(true);
141
+ if (!(isToolFailure(foreign) && isToolFailure(missing))) throw new Error("expected refusals");
149
142
  // Structural, not literal, equality: the messages legitimately differ by
150
143
  // the id the caller themselves passed in (that's not information about
151
144
  // the store — they already knew it), so comparing byte-for-byte would be
@@ -161,7 +154,7 @@ describe("ownership and authentication guards", () => {
161
154
  const state = createDefaultState();
162
155
  state.authenticatedUserId = "olivia_ito_3591";
163
156
  const result = requireOwnOrder(state, "#W5866402");
164
- expect(isError(result) ? null : result.order_id).toBe("#W5866402");
157
+ expect(isToolFailure(result) ? null : result.order_id).toBe("#W5866402");
165
158
  });
166
159
  });
167
160
 
@@ -196,7 +189,7 @@ describe("retailTool", () => {
196
189
  const ctx = makeCtx();
197
190
  await echo.execute({ value: "a" }, ctx);
198
191
  await echo.execute({ value: "b" }, ctx);
199
- const state = getState(ctx);
192
+ const state = retailSlot.get(ctx);
200
193
  expect(state.callSeq).toBe(2);
201
194
  expect(state.activity.map((a) => a.summary)).toEqual(["echoed a", "echoed b"]);
202
195
  expect(state.activity.map((a) => a.tool)).toEqual(["echo", "echo"]);
@@ -205,28 +198,28 @@ describe("retailTool", () => {
205
198
  test("a repeated identical call still changes the projection", async () => {
206
199
  const ctx = makeCtx();
207
200
  await echo.execute({ value: "same" }, ctx);
208
- const first = getState(ctx).callSeq;
201
+ const first = retailSlot.get(ctx).callSeq;
209
202
  await echo.execute({ value: "same" }, ctx);
210
- expect(getState(ctx).callSeq).toBeGreaterThan(first);
203
+ expect(retailSlot.get(ctx).callSeq).toBeGreaterThan(first);
211
204
  });
212
205
 
213
206
  test("defaults to requiring authentication and does not run execute when blocked", async () => {
214
207
  const ctx = makeCtx();
215
208
  const result = await gated.execute({}, ctx);
216
- expect(isError(result) && result.error).toContain("find_user_id_by_email");
209
+ expect(isToolFailure(result) && result.error).toContain("find_user_id_by_email");
217
210
  });
218
211
 
219
212
  test("a blocked call is still logged and still bumps callSeq", async () => {
220
213
  const ctx = makeCtx();
221
214
  await gated.execute({}, ctx);
222
- const state = getState(ctx);
215
+ const state = retailSlot.get(ctx);
223
216
  expect(state.callSeq).toBe(1);
224
217
  expect(state.activity[0]?.summary).toContain("blocked");
225
218
  });
226
219
 
227
220
  test("runs once authenticated", async () => {
228
221
  const ctx = makeCtx();
229
- getState(ctx).authenticatedUserId = "olivia_ito_3591";
222
+ retailSlot.get(ctx).authenticatedUserId = "olivia_ito_3591";
230
223
  const result = await gated.execute({}, ctx);
231
224
  expect(result).toEqual({ ok: true });
232
225
  });
@@ -234,13 +227,13 @@ describe("retailTool", () => {
234
227
  test("an error result is logged as an error, not through summary()", async () => {
235
228
  const ctx = makeCtx();
236
229
  await failing.execute({}, ctx);
237
- expect(getState(ctx).activity[0]?.summary).toBe("error: nope");
230
+ expect(retailSlot.get(ctx).activity[0]?.summary).toBe("error: nope");
238
231
  });
239
232
 
240
233
  test("activity is capped so a long call cannot grow the payload", async () => {
241
234
  const ctx = makeCtx();
242
235
  for (let i = 0; i < 15; i++) await echo.execute({ value: String(i) }, ctx);
243
- const state = getState(ctx);
236
+ const state = retailSlot.get(ctx);
244
237
  expect(state.callSeq).toBe(15);
245
238
  expect(state.activity).toHaveLength(10);
246
239
  expect(state.activity[0]?.summary).toBe("echoed 5");
@@ -249,7 +242,7 @@ describe("retailTool", () => {
249
242
  test("concurrent calls serialize — no lost increments", async () => {
250
243
  const ctx = makeCtx();
251
244
  await Promise.all(Array.from({ length: 8 }, (_, i) => echo.execute({ value: String(i) }, ctx)));
252
- const state = getState(ctx);
245
+ const state = retailSlot.get(ctx);
253
246
  expect(state.callSeq).toBe(8);
254
247
  expect(new Set(state.activity.map((a) => a.seq)).size).toBe(state.activity.length);
255
248
  });
@@ -1,5 +1,5 @@
1
- import type { ToolContext } from "@alexkroman1/aai";
2
- import { createKeyedLock, tool, withLock } from "@alexkroman1/aai";
1
+ import type { ToolContext, ToolFailure } from "@alexkroman1/aai";
2
+ import { isToolFailure, pushCapped, type SlotStateOf, sessionSlot, tool } from "@alexkroman1/aai";
3
3
  import type { z } from "zod";
4
4
  import seedJson from "./seed.json";
5
5
  import type {
@@ -8,12 +8,11 @@ import type {
8
8
  PaymentMethod,
9
9
  Product,
10
10
  RetailState,
11
- StateSlot,
12
11
  Store,
13
12
  User,
14
13
  Variant,
15
14
  } from "./shared.ts";
16
- import { MAX_ACTIVITY } from "./shared.ts";
15
+ import { emptyRetailState, MAX_ACTIVITY } from "./shared.ts";
17
16
 
18
17
  /**
19
18
  * The JSON import's inferred type has `status: string` where `Order` wants a
@@ -23,12 +22,6 @@ import { MAX_ACTIVITY } from "./shared.ts";
23
22
  */
24
23
  const SEED = seedJson as unknown as Store;
25
24
 
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
25
  /** Round to cents. Gift-card balances and price differences are compared for
33
26
  * equality, and raw float arithmetic makes that a coin toss. */
34
27
  export function money(n: number): number {
@@ -45,22 +38,24 @@ export function isGiftCard(method: PaymentMethod): method is GiftCard {
45
38
  * module-level object shared by every session in the process, so a mutation
46
39
  * without it would let one caller's cancellation show up in another's. */
47
40
  export function createDefaultState(): RetailState {
48
- return {
49
- store: structuredClone(SEED),
50
- authenticatedUserId: null,
51
- callSeq: 0,
52
- activity: [],
53
- focus: {},
54
- };
41
+ return { ...emptyRetailState(), store: structuredClone(SEED) };
55
42
  }
56
43
 
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
- }
44
+ /**
45
+ * The session's store, as one typed slot inside `ctx.state`.
46
+ *
47
+ * Every mutating tool goes through `retailSlot.update` (via `retailTool`),
48
+ * which serializes per session: the LLM loop can run a step's tool calls
49
+ * concurrently, and two interleaving async mutators would each observe the
50
+ * other's half-applied changes.
51
+ *
52
+ * No `after` hook — unlike dispatch-center, this store has no derived field to
53
+ * recalculate, and its one growth cap (`activity`) is held on append by
54
+ * `record` below.
55
+ */
56
+ export const retailSlot = sessionSlot("retail", createDefaultState);
57
+
58
+ export type StateSlot = SlotStateOf<typeof retailSlot>;
64
59
 
65
60
  export function setFocus(
66
61
  state: RetailState,
@@ -71,15 +66,15 @@ export function setFocus(
71
66
 
72
67
  // ─── Lookups ─────────────────────────────────────────────────────────────────
73
68
 
74
- export function findUser(state: RetailState, userId: string): User | ErrorResult {
69
+ export function findUser(state: RetailState, userId: string): User | ToolFailure {
75
70
  return state.store.users[userId] ?? { error: `User ${userId} not found.` };
76
71
  }
77
72
 
78
- export function findOrder(state: RetailState, orderId: string): Order | ErrorResult {
73
+ export function findOrder(state: RetailState, orderId: string): Order | ToolFailure {
79
74
  return state.store.orders[orderId] ?? { error: `Order ${orderId} not found.` };
80
75
  }
81
76
 
82
- export function findProduct(state: RetailState, productId: string): Product | ErrorResult {
77
+ export function findProduct(state: RetailState, productId: string): Product | ToolFailure {
83
78
  return (
84
79
  state.store.products[productId] ?? {
85
80
  error: `Product ${productId} not found. Note a product id is not an item id.`,
@@ -87,7 +82,7 @@ export function findProduct(state: RetailState, productId: string): Product | Er
87
82
  );
88
83
  }
89
84
 
90
- export function findVariant(product: Product, itemId: string): Variant | ErrorResult {
85
+ export function findVariant(product: Product, itemId: string): Variant | ToolFailure {
91
86
  return (
92
87
  product.variants[itemId] ?? {
93
88
  error: `Item ${itemId} is not a variant of ${product.name} (${product.product_id}).`,
@@ -99,7 +94,7 @@ export function findVariant(product: Product, itemId: string): Variant | ErrorRe
99
94
  export function findItem(
100
95
  state: RetailState,
101
96
  itemId: string,
102
- ): { product: Product; variant: Variant } | ErrorResult {
97
+ ): { product: Product; variant: Variant } | ToolFailure {
103
98
  for (const product of Object.values(state.store.products)) {
104
99
  const variant = product.variants[itemId];
105
100
  if (variant) return { product, variant };
@@ -107,7 +102,7 @@ export function findItem(
107
102
  return { error: `Item ${itemId} not found. Note an item id is not a product id.` };
108
103
  }
109
104
 
110
- export function findPaymentMethod(user: User, methodId: string): PaymentMethod | ErrorResult {
105
+ export function findPaymentMethod(user: User, methodId: string): PaymentMethod | ToolFailure {
111
106
  return (
112
107
  user.payment_methods[methodId] ?? {
113
108
  error: `Payment method ${methodId} is not on this customer's profile. Available: ${Object.keys(
@@ -123,7 +118,7 @@ const NOT_AUTHENTICATED =
123
118
  "Not authenticated. Identify the customer first with find_user_id_by_email, " +
124
119
  "or find_user_id_by_name_zip if they cannot remember their email.";
125
120
 
126
- export function authenticatedUser(state: RetailState): User | ErrorResult {
121
+ export function authenticatedUser(state: RetailState): User | ToolFailure {
127
122
  if (!state.authenticatedUserId) return { error: NOT_AUTHENTICATED };
128
123
  return findUser(state, state.authenticatedUserId);
129
124
  }
@@ -139,9 +134,9 @@ export function authenticatedUser(state: RetailState): User | ErrorResult {
139
134
  * matches every sibling lookup (`findUser`, `findOrder`, `findProduct`, …),
140
135
  * which all echo the id precisely so an LLM can target a repair retry.
141
136
  */
142
- export function requireOwnOrder(state: RetailState, orderId: string): Order | ErrorResult {
137
+ export function requireOwnOrder(state: RetailState, orderId: string): Order | ToolFailure {
143
138
  const user = authenticatedUser(state);
144
- if (isError(user)) return user;
139
+ if (isToolFailure(user)) return user;
145
140
  const order = state.store.orders[orderId];
146
141
  if (!order || order.user_id !== user.user_id) {
147
142
  return { error: `Order ${orderId} was not found on this customer's account.` };
@@ -149,32 +144,6 @@ export function requireOwnOrder(state: RetailState, orderId: string): Order | Er
149
144
  return order;
150
145
  }
151
146
 
152
- // ─── Serialized state updates ────────────────────────────────────────────────
153
-
154
- const sessionLock = createKeyedLock();
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. Holding the session's key runs each update against the previous
162
- * one's finished result. `createKeyedLock` is the SDK's primitive for exactly
163
- * this; the hand-rolled promise chain it replaced also had to remember to drop
164
- * each key's entry by ownership once idle.
165
- *
166
- * NOT exported, unlike dispatch-center's equivalent. `retailTool` is the only
167
- * caller, so a tool body cannot re-enter it — which would wait on a key only
168
- * the outer call can release, i.e. deadlock. A hazard you can't reach beats a
169
- * hazard you documented.
170
- */
171
- function updateState<R>(
172
- ctx: ToolContext,
173
- mutator: (state: RetailState) => R | Promise<R>,
174
- ): Promise<R> {
175
- return withLock(sessionLock, ctx.sessionId, async () => mutator(getState(ctx)));
176
- }
177
-
178
147
  // ─── The tool wrapper ────────────────────────────────────────────────────────
179
148
 
180
149
  interface RetailToolSpec<S extends z.ZodType<Record<string, unknown>>, R> {
@@ -193,10 +162,11 @@ interface RetailToolSpec<S extends z.ZodType<Record<string, unknown>>, R> {
193
162
 
194
163
  function record(state: RetailState, name: string, summary: string): void {
195
164
  state.callSeq += 1;
196
- state.activity.push({ seq: state.callSeq, tool: name, summary, at: Date.now() });
197
- if (state.activity.length > MAX_ACTIVITY) {
198
- state.activity = state.activity.slice(-MAX_ACTIVITY);
199
- }
165
+ pushCapped(
166
+ state.activity,
167
+ { seq: state.callSeq, tool: name, summary, at: Date.now() },
168
+ MAX_ACTIVITY,
169
+ );
200
170
  }
201
171
 
202
172
  /**
@@ -218,8 +188,12 @@ export function retailTool<S extends z.ZodType<Record<string, unknown>>, R>(
218
188
  return tool({
219
189
  description: spec.description,
220
190
  inputSchema: spec.inputSchema,
191
+ // `retailSlot.update` is the only caller of the serialized region in this
192
+ // template, which is what keeps `update`'s non-reentrancy unreachable: a
193
+ // tool body cannot nest another `update` on this slot, because tool bodies
194
+ // are `spec.execute` and always run INSIDE this one.
221
195
  execute: (args, ctx) =>
222
- updateState(ctx, async (state) => {
196
+ retailSlot.update(ctx, async (state) => {
223
197
  const typedArgs = args as z.output<S>;
224
198
  if (requiresAuth && !state.authenticatedUserId) {
225
199
  record(state, spec.name, "blocked: not authenticated");
@@ -229,7 +203,7 @@ export function retailTool<S extends z.ZodType<Record<string, unknown>>, R>(
229
203
  record(
230
204
  state,
231
205
  spec.name,
232
- isError(result) ? `error: ${result.error}` : spec.summary(typedArgs, result),
206
+ isToolFailure(result) ? `error: ${result.error}` : spec.summary(typedArgs, result),
233
207
  );
234
208
  return result;
235
209
  }),
@@ -1,5 +1,6 @@
1
+ import { isToolFailure } from "@alexkroman1/aai";
1
2
  import { describe, expect, test } from "vitest";
2
- import { createDefaultState, findUser, isError } from "./store.ts";
3
+ import { createDefaultState, findUser } from "./store.ts";
3
4
  import { applySwap, assertCanCoverDiff, planItemSwap } from "./swap.ts";
4
5
 
5
6
  function fixture(orderId: string) {
@@ -15,7 +16,7 @@ describe("planItemSwap", () => {
15
16
  const plan = planItemSwap(state, order, ["1304426904"], ["4725166838"], {
16
17
  requireDifferent: true,
17
18
  });
18
- if (isError(plan)) throw new Error(plan.error);
19
+ if (isToolFailure(plan)) throw new Error(plan.error);
19
20
  expect(plan.diff).toBe(36.32);
20
21
  expect(plan.pairs).toHaveLength(1);
21
22
  expect(plan.pairs[0]?.newVariant.item_id).toBe("4725166838");
@@ -26,7 +27,7 @@ describe("planItemSwap", () => {
26
27
  const plan = planItemSwap(state, order, ["6242772310"], ["6200867091"], {
27
28
  requireDifferent: true,
28
29
  });
29
- expect(isError(plan) ? null : plan.diff).toBe(-40.86);
30
+ expect(isToolFailure(plan) ? null : plan.diff).toBe(-40.86);
30
31
  });
31
32
 
32
33
  test("handles a duplicate item id swapped twice", () => {
@@ -38,7 +39,7 @@ describe("planItemSwap", () => {
38
39
  ["3909406921", "3909406921"],
39
40
  { requireDifferent: true },
40
41
  );
41
- if (isError(plan)) throw new Error(plan.error);
42
+ if (isToolFailure(plan)) throw new Error(plan.error);
42
43
  expect(plan.diff).toBe(6.9);
43
44
  expect(plan.pairs.map((p) => p.index)).toEqual([0, 1]);
44
45
  });
@@ -48,7 +49,7 @@ describe("planItemSwap", () => {
48
49
  const plan = planItemSwap(state, order, ["7292993796"], ["3909406921"], {
49
50
  requireDifferent: true,
50
51
  });
51
- if (isError(plan)) throw new Error(plan.error);
52
+ if (isToolFailure(plan)) throw new Error(plan.error);
52
53
  expect(plan.pairs).toHaveLength(1);
53
54
  expect(plan.diff).toBe(3.45);
54
55
  });
@@ -62,7 +63,7 @@ describe("planItemSwap", () => {
62
63
  ["3909406921", "3909406921", "3909406921"],
63
64
  { requireDifferent: true },
64
65
  );
65
- expect(isError(plan) && plan.error).toContain("7292993796");
66
+ expect(isToolFailure(plan) && plan.error).toContain("7292993796");
66
67
  });
67
68
 
68
69
  test("refuses mismatched list lengths", () => {
@@ -70,12 +71,14 @@ describe("planItemSwap", () => {
70
71
  const plan = planItemSwap(state, order, ["7292993796"], ["3909406921", "3738831434"], {
71
72
  requireDifferent: true,
72
73
  });
73
- expect(isError(plan) && plan.error.toLowerCase()).toContain("same number");
74
+ expect(isToolFailure(plan) && plan.error.toLowerCase()).toContain("same number");
74
75
  });
75
76
 
76
77
  test("refuses an empty swap", () => {
77
78
  const { state, order } = fixture("#W4316152");
78
- expect(isError(planItemSwap(state, order, [], [], { requireDifferent: true }))).toBe(true);
79
+ expect(isToolFailure(planItemSwap(state, order, [], [], { requireDifferent: true }))).toBe(
80
+ true,
81
+ );
79
82
  });
80
83
 
81
84
  test("refuses an item the order does not contain", () => {
@@ -83,7 +86,7 @@ describe("planItemSwap", () => {
83
86
  const plan = planItemSwap(state, order, ["1304426904"], ["4725166838"], {
84
87
  requireDifferent: true,
85
88
  });
86
- expect(isError(plan)).toBe(true);
89
+ expect(isToolFailure(plan)).toBe(true);
87
90
  });
88
91
 
89
92
  test("refuses a target of a different product", () => {
@@ -91,7 +94,7 @@ describe("planItemSwap", () => {
91
94
  const plan = planItemSwap(state, order, ["7292993796"], ["4725166838"], {
92
95
  requireDifferent: true,
93
96
  });
94
- expect(isError(plan) && plan.error).toContain("Tea Kettle");
97
+ expect(isToolFailure(plan) && plan.error).toContain("Tea Kettle");
95
98
  });
96
99
 
97
100
  test("refuses an unavailable target", () => {
@@ -99,7 +102,7 @@ describe("planItemSwap", () => {
99
102
  const plan = planItemSwap(state, order, ["7292993796"], ["6454334990"], {
100
103
  requireDifferent: true,
101
104
  });
102
- expect(isError(plan) && plan.error.toLowerCase()).toContain("not available");
105
+ expect(isToolFailure(plan) && plan.error.toLowerCase()).toContain("not available");
103
106
  });
104
107
 
105
108
  test("requireDifferent refuses a no-op swap; exchange allows it", () => {
@@ -107,11 +110,11 @@ describe("planItemSwap", () => {
107
110
  const strict = planItemSwap(state, order, ["7292993796"], ["7292993796"], {
108
111
  requireDifferent: true,
109
112
  });
110
- expect(isError(strict)).toBe(true);
113
+ expect(isToolFailure(strict)).toBe(true);
111
114
  const loose = planItemSwap(state, order, ["7292993796"], ["7292993796"], {
112
115
  requireDifferent: false,
113
116
  });
114
- expect(isError(loose) ? null : loose.diff).toBe(0);
117
+ expect(isToolFailure(loose) ? null : loose.diff).toBe(0);
115
118
  });
116
119
  });
117
120
 
@@ -119,7 +122,7 @@ describe("assertCanCoverDiff", () => {
119
122
  function aarav() {
120
123
  const state = createDefaultState();
121
124
  const user = findUser(state, "aarav_anderson_8794");
122
- if (isError(user)) throw new Error(user.error);
125
+ if (isToolFailure(user)) throw new Error(user.error);
123
126
  return user;
124
127
  }
125
128
 
@@ -140,7 +143,7 @@ describe("assertCanCoverDiff", () => {
140
143
  test("does not gate a non-gift-card method on any balance", () => {
141
144
  const state = createDefaultState();
142
145
  const olivia = findUser(state, "olivia_ito_3591");
143
- if (isError(olivia)) throw new Error(olivia.error);
146
+ if (isToolFailure(olivia)) throw new Error(olivia.error);
144
147
  expect(assertCanCoverDiff(olivia, "credit_card_9753331", 100_000)).toBeNull();
145
148
  });
146
149
 
@@ -161,7 +164,7 @@ describe("applySwap", () => {
161
164
  ["4725166838", "3909406921"],
162
165
  { requireDifferent: true },
163
166
  );
164
- if (isError(plan)) throw new Error(plan.error);
167
+ if (isToolFailure(plan)) throw new Error(plan.error);
165
168
  applySwap(order, plan);
166
169
 
167
170
  const vacuum = order.items.find((i) => i.item_id === "4725166838");
@@ -179,7 +182,7 @@ describe("applySwap", () => {
179
182
  const plan = planItemSwap(state, order, ["7292993796"], ["3909406921"], {
180
183
  requireDifferent: true,
181
184
  });
182
- if (isError(plan)) throw new Error(plan.error);
185
+ if (isToolFailure(plan)) throw new Error(plan.error);
183
186
  applySwap(order, plan);
184
187
  expect(order.items.map((i) => i.item_id)).toEqual(["3909406921", "7292993796"]);
185
188
  });
@@ -1,13 +1,6 @@
1
+ import { isToolFailure, type ToolFailure } from "@alexkroman1/aai";
1
2
  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";
3
+ import { findPaymentMethod, findProduct, findVariant, isGiftCard, money } from "./store.ts";
11
4
 
12
5
  export interface SwapPair {
13
6
  /** Index into `order.items`. Matching by index rather than by "first item
@@ -37,7 +30,7 @@ export function planItemSwap(
37
30
  itemIds: string[],
38
31
  newItemIds: string[],
39
32
  opts: { requireDifferent: boolean },
40
- ): SwapPlan | ErrorResult {
33
+ ): SwapPlan | ToolFailure {
41
34
  if (itemIds.length === 0) {
42
35
  return { error: "No items were listed to change." };
43
36
  }
@@ -89,10 +82,10 @@ export function planItemSwap(
89
82
  consumed.add(index);
90
83
 
91
84
  const product = findProduct(state, item.product_id);
92
- if (isError(product)) return product;
85
+ if (isToolFailure(product)) return product;
93
86
 
94
87
  const newVariant = findVariant(product, newItemId);
95
- if (isError(newVariant)) {
88
+ if (isToolFailure(newVariant)) {
96
89
  return {
97
90
  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
91
  };
@@ -114,9 +107,9 @@ export function planItemSwap(
114
107
  * Gate the price difference on the chosen payment method. Only a gift card has
115
108
  * a balance to run out of; a negative difference is a refund and never gated.
116
109
  */
117
- export function assertCanCoverDiff(user: User, methodId: string, diff: number): ErrorResult | null {
110
+ export function assertCanCoverDiff(user: User, methodId: string, diff: number): ToolFailure | null {
118
111
  const method = findPaymentMethod(user, methodId);
119
- if (isError(method)) return method;
112
+ if (isToolFailure(method)) return method;
120
113
  if (isGiftCard(method) && method.balance < diff) {
121
114
  return {
122
115
  error: `Gift card ${methodId}'s balance ($${method.balance.toFixed(2)}) does not cover the $${diff.toFixed(2)} difference. Ask for another payment method.`,
@@ -1,7 +1,8 @@
1
+ import { isToolFailure } from "@alexkroman1/aai";
1
2
  import { z } from "zod";
2
3
  import { creditRefund, REFUND_DELAY_NOTE, REFUND_IMMEDIATE_NOTE } from "../refund.ts";
3
4
  import { resolveOrder } from "../resolve.ts";
4
- import { authenticatedUser, getState, isError, retailTool, setFocus } from "../store.ts";
5
+ import { authenticatedUser, retailSlot, retailTool, setFocus } from "../store.ts";
5
6
 
6
7
  /** tau2 accepts exactly these two. Anything else is refused. */
7
8
  const CANCEL_REASONS = ["no longer needed", "ordered by mistake"] as const;
@@ -27,12 +28,12 @@ export const cancelPendingOrder = retailTool({
27
28
  // source order — with `summary` first, `result` in its signature can't be
28
29
  // inferred and silently falls back to `unknown`.
29
30
  execute: (args, ctx) => {
30
- const state = getState(ctx);
31
+ const state = retailSlot.get(ctx);
31
32
  const user = authenticatedUser(state);
32
- if (isError(user)) return user;
33
+ if (isToolFailure(user)) return user;
33
34
 
34
35
  const order = resolveOrder(state, args.order_id);
35
- if (isError(order)) return order;
36
+ if (isToolFailure(order)) return order;
36
37
  setFocus(state, { orderId: order.order_id });
37
38
 
38
39
  if (order.status !== "pending") {