@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,7 +1,6 @@
1
1
  import { agent } from "@alexkroman1/aai";
2
- import type { StateSlot } from "./shared.ts";
3
2
  import { storeView } from "./shared.ts";
4
- import { createDefaultState } from "./store.ts";
3
+ import { retailSlot, type StateSlot } from "./store.ts";
5
4
  import systemPrompt from "./system-prompt.md?raw";
6
5
  import { cancelPendingOrder } from "./tools/cancel_pending_order.ts";
7
6
  import { exchangeDeliveredOrderItems } from "./tools/exchange_delivered_order_items.ts";
@@ -27,18 +26,16 @@ export default agent({
27
26
  // overridden below; `llm` and `tts` take the defaults.
28
27
 
29
28
  // The store lives in ctx.state, one pristine copy per session — callers must
30
- // not see each other's cancellations. Wrapped in the `StateSlot` shape
31
- // `store.ts`'s `getState`/`shared.ts`'s `storeView` both key off `agent()`
32
- // infers its session-state type parameter from BOTH `state` and `syncState`,
33
- // and `StateSlot`'s `retail` field is optional (a "weak type"), so handing
34
- // it a bare `RetailState` with no properties in common fails that inference
35
- // rather than an ordinary structural check.
36
- state: (): StateSlot => ({ retail: createDefaultState() }),
29
+ // not see each other's cancellations. Declaring `state` (rather than letting
30
+ // the slot install itself on first access) means the session's store exists
31
+ // before the first tool call, so a resumed connection has something to
32
+ // project; the slot owns the shape either way.
33
+ state: (): StateSlot => ({ [retailSlot.key]: retailSlot.create() }),
37
34
 
38
35
  // One projection pushed after every tool call. It is a projection, not a
39
36
  // flag, because the state holds all six seeded customers and only the
40
37
  // authenticated one may reach the browser.
41
- syncState: storeView,
38
+ syncState: retailSlot.projection(storeView),
42
39
 
43
40
  // Callers read order numbers and ten-digit item numbers in bursts with pauses
44
41
  // inside one utterance ("W seven six seven … eight oh seven two"). The default
@@ -1,5 +1,5 @@
1
+ import type { ToolFailure } from "@alexkroman1/aai";
1
2
  import type { RetailState, User } from "./shared.ts";
2
- import type { ErrorResult } from "./store.ts";
3
3
 
4
4
  export interface AuthResult {
5
5
  user_id: string;
@@ -16,7 +16,7 @@ export interface AuthResult {
16
16
  * order you want. Re-running a finder for the SAME customer succeeds, because a
17
17
  * caller repeating their email should not hit an error.
18
18
  */
19
- export function authenticateAs(state: RetailState, user: User): AuthResult | ErrorResult {
19
+ export function authenticateAs(state: RetailState, user: User): AuthResult | ToolFailure {
20
20
  const current = state.authenticatedUserId;
21
21
  if (current && current !== user.user_id) {
22
22
  return {
@@ -1,8 +1,7 @@
1
1
  import "@alexkroman1/aai-ui/styles.css";
2
2
  import type { ChatMessage } from "@alexkroman1/aai-ui";
3
- import { client, useAgentState, useSession } from "@alexkroman1/aai-ui";
3
+ import { AutoScroll, client, useAgentState, useSession } from "@alexkroman1/aai-ui";
4
4
  import type { ReactNode } from "react";
5
- import { useEffect, useRef } from "react";
6
5
  import type {
7
6
  OrderStatus,
8
7
  OrderView,
@@ -10,7 +9,7 @@ import type {
10
9
  StoreView,
11
10
  SwapOptionView,
12
11
  } from "./shared.ts";
13
- import { DEMO_PERSONAS, storeView } from "./shared.ts";
12
+ import { DEMO_PERSONAS, emptyRetailState, storeView } from "./shared.ts";
14
13
 
15
14
  const CSS = `
16
15
  @keyframes rt-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.45; } }
@@ -38,8 +37,11 @@ const statusColors: Record<string, string> = {
38
37
  } satisfies Record<OrderStatus, string>;
39
38
 
40
39
  // The sidebar before the first tool call, derived from the projection itself so
41
- // a new StoreView field can't miss the pre-first-call render.
42
- const EMPTY_VIEW: StoreView = storeView({});
40
+ // a new StoreView field can't miss the pre-first-call render. Built from
41
+ // `emptyRetailState` rather than `retailSlot.projection(...)(undefined)`,
42
+ // because the slot's factory lives in `store.ts` and pulls the 107 KB seed —
43
+ // importing it here would ship the whole catalog to the browser.
44
+ const EMPTY_VIEW: StoreView = storeView(emptyRetailState());
43
45
 
44
46
  function stateColor(state: string): string {
45
47
  if (state === "listening" || state === "ready") return "#16a34a";
@@ -193,13 +195,8 @@ function SwapOptions({ option }: { option: SwapOptionView }) {
193
195
 
194
196
  function App() {
195
197
  const session = useSession();
196
- const messagesEndRef = useRef<HTMLDivElement>(null);
197
198
  // The agent's own store, projected by `syncState` after every tool call.
198
- const view = useAgentState<StoreView>() ?? EMPTY_VIEW;
199
-
200
- useEffect(() => {
201
- messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
202
- }, [session.messages]);
199
+ const view = useAgentState<StoreView>(EMPTY_VIEW);
203
200
 
204
201
  const lastAction = view.activity.at(-1);
205
202
 
@@ -255,7 +252,10 @@ function App() {
255
252
  className="flex flex-col overflow-hidden"
256
253
  style={{ borderRight: "1px solid #e4e4e7" }}
257
254
  >
258
- <div className="rt-scroll flex-1 overflow-y-auto p-4 flex flex-col gap-2">
255
+ <AutoScroll
256
+ scrollClassName="rt-scroll overflow-y-auto"
257
+ contentClassName="p-4 flex flex-col gap-2"
258
+ >
259
259
  {session.messages.length === 0 && (
260
260
  <div className="text-center p-10 text-[13px]" style={{ color: "#a1a1aa" }}>
261
261
  Press start, then read one of the emails from “Who to be” to the agent.
@@ -277,8 +277,7 @@ function App() {
277
277
  {message.content}
278
278
  </div>
279
279
  ))}
280
- <div ref={messagesEndRef} />
281
- </div>
280
+ </AutoScroll>
282
281
 
283
282
  {session.userTranscript !== null && (
284
283
  <div
@@ -1,7 +1,7 @@
1
- import type { ToolContext } from "@alexkroman1/aai";
1
+ import { isToolFailure, type ToolContext } from "@alexkroman1/aai";
2
2
  import { describe, expect, test } from "vitest";
3
3
  import retailAgent from "./agent.ts";
4
- import { getState, isError } from "./store.ts";
4
+ import { retailSlot } from "./store.ts";
5
5
 
6
6
  /** Tools that legitimately run before the caller is identified. Everything
7
7
  * else must refuse. Listed here so ADDING an unauthenticated tool is a
@@ -126,9 +126,9 @@ describe("the UI-update invariant", () => {
126
126
  // retailTool(): it would work, and the sidebar would sit still through it.
127
127
  test.each(registry)("%s increments callSeq and logs activity", async (name, def) => {
128
128
  const ctx = makeCtx();
129
- const before = getState(ctx).callSeq;
129
+ const before = retailSlot.get(ctx).callSeq;
130
130
  await def.execute(SAMPLE_ARGS[name] ?? {}, ctx);
131
- const state = getState(ctx);
131
+ const state = retailSlot.get(ctx);
132
132
  expect(state.callSeq, `${name} did not bump callSeq`).toBe(before + 1);
133
133
  expect(state.activity.at(-1)?.tool, `${name} logged the wrong tool name`).toBe(name);
134
134
  expect(state.activity.at(-1)?.summary).toBeTruthy();
@@ -139,7 +139,7 @@ describe("the UI-update invariant", () => {
139
139
  // disagree — the activity feed would then attribute calls to the wrong tool.
140
140
  const ctx = makeCtx();
141
141
  await def.execute(SAMPLE_ARGS[name] ?? {}, ctx);
142
- expect(getState(ctx).activity.at(-1)?.tool).toBe(name);
142
+ expect(retailSlot.get(ctx).activity.at(-1)?.tool).toBe(name);
143
143
  });
144
144
  });
145
145
 
@@ -148,8 +148,8 @@ describe("the authentication gate", () => {
148
148
  "%s refuses before the caller is identified",
149
149
  async (name, def) => {
150
150
  const result = await def.execute(SAMPLE_ARGS[name] ?? {}, makeCtx());
151
- expect(isError(result), `${name} did not refuse`).toBe(true);
152
- if (!isError(result)) return;
151
+ expect(isToolFailure(result), `${name} did not refuse`).toBe(true);
152
+ if (!isToolFailure(result)) return;
153
153
  expect(result.error, `${name} refused for the wrong reason`).toContain(
154
154
  "find_user_id_by_email",
155
155
  );
@@ -162,9 +162,9 @@ describe("the authentication gate", () => {
162
162
  const result = await def.execute(SAMPLE_ARGS[name] ?? {}, makeCtx());
163
163
  // A public tool may still fail on its own (deliberately bogus) arguments;
164
164
  // it must not fail on the GATE. Written as one unconditional assertion —
165
- // an `if (isError(result))` wrapper would pass vacuously for the tools
165
+ // an `if (isToolFailure(result))` wrapper would pass vacuously for the tools
166
166
  // that succeed, which is precisely the set most at risk of regressing.
167
- const blockedByGate = isError(result) && result.error.includes("find_user_id_by_email");
167
+ const blockedByGate = isToolFailure(result) && result.error.includes("find_user_id_by_email");
168
168
  expect(blockedByGate, `${name} was blocked by the auth gate`).toBe(false);
169
169
  },
170
170
  );
@@ -1,6 +1,7 @@
1
+ import { isToolFailure } from "@alexkroman1/aai";
1
2
  import { describe, expect, test } from "vitest";
2
3
  import { normalizeItemId, normalizeOrderId, resolveOrder, resolveVariantId } from "./resolve.ts";
3
- import { createDefaultState, findProduct, isError } from "./store.ts";
4
+ import { createDefaultState, findProduct } from "./store.ts";
4
5
 
5
6
  function stateFor(userId: string) {
6
7
  const state = createDefaultState();
@@ -39,13 +40,13 @@ describe("resolveOrder — canonical ids", () => {
39
40
  const state = stateFor("olivia_ito_3591");
40
41
  for (const spoken of ["#W5866402", "W 5866402", "5866402"]) {
41
42
  const order = resolveOrder(state, spoken);
42
- expect.soft(isError(order) ? null : order.order_id, spoken).toBe("#W5866402");
43
+ expect.soft(isToolFailure(order) ? null : order.order_id, spoken).toBe("#W5866402");
43
44
  }
44
45
  });
45
46
 
46
47
  test("refuses another customer's order id", () => {
47
48
  const state = stateFor("olivia_ito_3591");
48
- expect(isError(resolveOrder(state, "#W4316152"))).toBe(true);
49
+ expect(isToolFailure(resolveOrder(state, "#W4316152"))).toBe(true);
49
50
  });
50
51
  });
51
52
 
@@ -54,14 +55,14 @@ describe("resolveOrder — shorthand", () => {
54
55
  const state = stateFor("olivia_ito_3591");
55
56
  for (const spoken of ["the delivered one", "my delivered order", "delivered"]) {
56
57
  const order = resolveOrder(state, spoken);
57
- expect.soft(isError(order) ? null : order.order_id, spoken).toBe("#W5866402");
58
+ expect.soft(isToolFailure(order) ? null : order.order_id, spoken).toBe("#W5866402");
58
59
  }
59
60
  });
60
61
 
61
62
  test("three pending orders make bare 'my pending order' ambiguous, and it lists them", () => {
62
63
  const state = stateFor("olivia_ito_3591");
63
64
  const result = resolveOrder(state, "my pending order");
64
- if (!isError(result)) throw new Error("expected ambiguity");
65
+ if (!isToolFailure(result)) throw new Error("expected ambiguity");
65
66
  for (const id of ["#W5442520", "#W7941031", "#W3657213"]) {
66
67
  expect.soft(result.error, `ambiguity message omits ${id}`).toContain(id);
67
68
  }
@@ -71,38 +72,38 @@ describe("resolveOrder — shorthand", () => {
71
72
  const state = stateFor("olivia_ito_3591");
72
73
  const first = resolveOrder(state, "my first pending order");
73
74
  const second = resolveOrder(state, "the second pending order");
74
- expect(isError(first) ? null : first.order_id).toBe("#W5442520");
75
- expect(isError(second) ? null : second.order_id).toBe("#W7941031");
75
+ expect(isToolFailure(first) ? null : first.order_id).toBe("#W5442520");
76
+ expect(isToolFailure(second) ? null : second.order_id).toBe("#W7941031");
76
77
  });
77
78
 
78
79
  test("'last' picks the final one", () => {
79
80
  const state = stateFor("olivia_ito_3591");
80
81
  const result = resolveOrder(state, "my last pending order");
81
- expect(isError(result) ? null : result.order_id).toBe("#W3657213");
82
+ expect(isToolFailure(result) ? null : result.order_id).toBe("#W3657213");
82
83
  });
83
84
 
84
85
  test("a bare ordinal with no status word indexes all of the caller's orders", () => {
85
86
  const state = stateFor("emma_smith_8564");
86
87
  const result = resolveOrder(state, "the first one");
87
- expect(isError(result) ? null : result.order_id).toBe("#W2417020");
88
+ expect(isToolFailure(result) ? null : result.order_id).toBe("#W2417020");
88
89
  });
89
90
 
90
91
  test("a status the caller has none of says what they do have", () => {
91
92
  const state = stateFor("harper_brown_7363");
92
93
  const result = resolveOrder(state, "my cancelled order");
93
- if (!isError(result)) throw new Error("expected refusal");
94
+ if (!isToolFailure(result)) throw new Error("expected refusal");
94
95
  expect(result.error).toContain("delivered");
95
96
  expect(result.error).toContain("pending");
96
97
  });
97
98
 
98
99
  test("an out-of-range ordinal is refused, not clamped", () => {
99
100
  const state = stateFor("emma_smith_8564");
100
- expect(isError(resolveOrder(state, "the fourth pending order"))).toBe(true);
101
+ expect(isToolFailure(resolveOrder(state, "the fourth pending order"))).toBe(true);
101
102
  });
102
103
 
103
104
  test("unresolvable input is refused before authentication too", () => {
104
105
  const state = createDefaultState();
105
- expect(isError(resolveOrder(state, "my pending order"))).toBe(true);
106
+ expect(isToolFailure(resolveOrder(state, "my pending order"))).toBe(true);
106
107
  });
107
108
  });
108
109
 
@@ -110,7 +111,7 @@ describe("resolveVariantId", () => {
110
111
  const state = createDefaultState();
111
112
  function teaKettle() {
112
113
  const product = findProduct(state, "9832717871");
113
- if (isError(product)) throw new Error(product.error);
114
+ if (isToolFailure(product)) throw new Error(product.error);
114
115
  return product;
115
116
  }
116
117
 
@@ -120,7 +121,7 @@ describe("resolveVariantId", () => {
120
121
  });
121
122
 
122
123
  test("refuses an item id belonging to a different product", () => {
123
- expect(isError(resolveVariantId(teaKettle(), "4725166838"))).toBe(true);
124
+ expect(isToolFailure(resolveVariantId(teaKettle(), "4725166838"))).toBe(true);
124
125
  });
125
126
 
126
127
  test("matches a spoken option phrase", () => {
@@ -135,13 +136,13 @@ describe("resolveVariantId", () => {
135
136
 
136
137
  test("an ambiguous phrase lists the candidates with their options", () => {
137
138
  const result = resolveVariantId(teaKettle(), "glass");
138
- if (!isError(result)) throw new Error("expected ambiguity");
139
+ if (!isToolFailure(result)) throw new Error("expected ambiguity");
139
140
  expect(result.error).toContain("glass");
140
141
  expect(result.error.match(/\d{10}/g)?.length).toBeGreaterThan(1);
141
142
  });
142
143
 
143
144
  test("a phrase matching nothing is refused", () => {
144
- expect(isError(resolveVariantId(teaKettle(), "titanium"))).toBe(true);
145
+ expect(isToolFailure(resolveVariantId(teaKettle(), "titanium"))).toBe(true);
145
146
  });
146
147
 
147
148
  test("availableOnly excludes unavailable variants from matching", () => {
@@ -1,5 +1,6 @@
1
+ import { isToolFailure, type ToolFailure } from "@alexkroman1/aai";
1
2
  import type { Order, OrderStatus, Product, RetailState } from "./shared.ts";
2
- import { authenticatedUser, type ErrorResult, isError } from "./store.ts";
3
+ import { authenticatedUser } from "./store.ts";
3
4
 
4
5
  /** `#W5866402` from anything STT plausibly produces for it. */
5
6
  export function normalizeOrderId(spoken: string): string {
@@ -53,9 +54,9 @@ function describeOrder(order: Order): string {
53
54
  * Ambiguity is always an error listing the candidates, never a guess — the
54
55
  * consequence of guessing here is cancelling the wrong order.
55
56
  */
56
- export function resolveOrder(state: RetailState, spoken: string): Order | ErrorResult {
57
+ export function resolveOrder(state: RetailState, spoken: string): Order | ToolFailure {
57
58
  const user = authenticatedUser(state);
58
- if (isError(user)) return user;
59
+ if (isToolFailure(user)) return user;
59
60
 
60
61
  const owned = user.orders
61
62
  .map((id) => state.store.orders[id])
@@ -110,7 +111,7 @@ export function resolveVariantId(
110
111
  product: Product,
111
112
  spoken: string,
112
113
  opts: { availableOnly?: boolean } = {},
113
- ): string | ErrorResult {
114
+ ): string | ToolFailure {
114
115
  if (LOOKS_LIKE_ITEM_ID.test(spoken)) {
115
116
  const itemId = normalizeItemId(spoken);
116
117
  const variant = product.variants[itemId];
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, test } from "vitest";
2
2
  import seedJson from "./seed.json";
3
3
  import type { RetailState, Store } from "./shared.ts";
4
- import { buildScriptBullets, DEMO_PERSONAS, storeView } from "./shared.ts";
4
+ import { buildScriptBullets, DEMO_PERSONAS, emptyRetailState, storeView } from "./shared.ts";
5
5
 
6
6
  function makeState(authenticatedUserId: string | null): RetailState {
7
7
  return {
@@ -15,20 +15,21 @@ function makeState(authenticatedUserId: string | null): RetailState {
15
15
 
16
16
  describe("storeView", () => {
17
17
  test("projects nothing identifying before authentication", () => {
18
- const view = storeView({ retail: makeState(null) });
18
+ const view = storeView(makeState(null));
19
19
  expect(view.customer).toBeNull();
20
20
  expect(view.orders).toEqual([]);
21
21
  });
22
22
 
23
- test("an absent state slot degrades to an empty view", () => {
24
- const view = storeView({});
23
+ test("an untouched session projects an empty view, not undefined", () => {
24
+ // The value `client.tsx` hoists as its pre-first-tool-call fallback.
25
+ const view = storeView(emptyRetailState());
25
26
  expect(view.customer).toBeNull();
26
27
  expect(view.orders).toEqual([]);
27
28
  expect(view.callSeq).toBe(0);
28
29
  });
29
30
 
30
31
  test("projects the authenticated customer and only their orders", () => {
31
- const view = storeView({ retail: makeState("olivia_ito_3591") });
32
+ const view = storeView(makeState("olivia_ito_3591"));
32
33
  expect(view.customer?.name).toBe("Olivia Ito");
33
34
  expect(view.customer?.email).toBe("olivia.ito5204@example.com");
34
35
  expect(view.orders.map((o) => o.orderId).sort((a, b) => a.localeCompare(b))).toEqual(
@@ -40,13 +41,13 @@ describe("storeView", () => {
40
41
 
41
42
  test("projects live gift-card balances, since refunds move them", () => {
42
43
  const state = makeState("olivia_ito_3591");
43
- const view = storeView({ retail: state });
44
+ const view = storeView(state);
44
45
  const card = view.customer?.paymentMethods.find((m) => m.id === "gift_card_7794233");
45
46
  expect(card?.balance).toBe(56);
46
47
  });
47
48
 
48
49
  test("no other customer's email, address, or payment method reaches the client", () => {
49
- const view = storeView({ retail: makeState("olivia_ito_3591") });
50
+ const view = storeView(makeState("olivia_ito_3591"));
50
51
  const json = JSON.stringify(view);
51
52
  for (const leaked of [
52
53
  "aarav.anderson9752@example.com",
@@ -64,14 +65,14 @@ describe("storeView", () => {
64
65
  });
65
66
 
66
67
  test("carries callSeq, activity and focus through", () => {
67
- const view = storeView({ retail: makeState("olivia_ito_3591") });
68
+ const view = storeView(makeState("olivia_ito_3591"));
68
69
  expect(view.callSeq).toBe(3);
69
70
  expect(view.activity).toHaveLength(1);
70
71
  expect(view.focus.orderId).toBe("#W5866402");
71
72
  });
72
73
 
73
74
  test("order totals sum the payments, not the items", () => {
74
- const view = storeView({ retail: makeState("olivia_ito_3591") });
75
+ const view = storeView(makeState("olivia_ito_3591"));
75
76
  const delivered = view.orders.find((o) => o.orderId === "#W5866402");
76
77
  expect(delivered?.total).toBe(3203.78);
77
78
  });
@@ -105,12 +106,12 @@ describe("swapOptions", () => {
105
106
  test("empty when no order is focused", () => {
106
107
  const state = makeState("olivia_ito_3591");
107
108
  state.focus = {};
108
- expect(storeView({ retail: state }).swapOptions).toEqual([]);
109
+ expect(storeView(state).swapOptions).toEqual([]);
109
110
  });
110
111
 
111
112
  test("lists available alternatives for the focused order's items", () => {
112
113
  // makeState focuses #W5866402 — Espresso Machine + Sneakers.
113
- const view = storeView({ retail: makeState("olivia_ito_3591") });
114
+ const view = storeView(makeState("olivia_ito_3591"));
114
115
  expect(view.swapOptions).toHaveLength(2);
115
116
  const espresso = view.swapOptions.find((s) => s.itemId === "6242772310");
116
117
  expect(espresso?.itemName).toBe("Espresso Machine");
@@ -121,7 +122,7 @@ describe("swapOptions", () => {
121
122
  test("never offers an unavailable variant as a target", () => {
122
123
  const state = makeState("aarav_anderson_8794");
123
124
  state.focus = { orderId: "#W4316152" };
124
- const view = storeView({ retail: state });
125
+ const view = storeView(state);
125
126
  const ids = view.swapOptions.flatMap((s) => s.alternatives.map((a) => a.itemId));
126
127
  // 6454334990 is a Tea Kettle variant with available: false.
127
128
  expect(ids).not.toContain("6454334990");
@@ -130,7 +131,7 @@ describe("swapOptions", () => {
130
131
  test("caps both axes so the payload stays bounded", () => {
131
132
  const state = makeState("aarav_anderson_8794");
132
133
  state.focus = { orderId: "#W9311069" }; // five items
133
- const view = storeView({ retail: state });
134
+ const view = storeView(state);
134
135
  expect(view.swapOptions.length).toBeLessThanOrEqual(3);
135
136
  for (const option of view.swapOptions) {
136
137
  expect(option.alternatives.length).toBeLessThanOrEqual(4);
@@ -140,7 +141,7 @@ describe("swapOptions", () => {
140
141
  test("a focus on another customer's order projects nothing", () => {
141
142
  const state = makeState("olivia_ito_3591");
142
143
  state.focus = { orderId: "#W4316152" }; // aarav's
143
- const view = storeView({ retail: state });
144
+ const view = storeView(state);
144
145
  expect(view.swapOptions).toEqual([]);
145
146
  expect(JSON.stringify(view)).not.toContain("#W4316152");
146
147
  });
@@ -153,8 +154,8 @@ describe("buildScriptBullets", () => {
153
154
  expect(bullets.join(" ")).toContain(DEMO_PERSONAS[0]?.zip ?? "");
154
155
  });
155
156
 
156
- test("undefined state yields the pre-auth bullets", () => {
157
- expect(buildScriptBullets(undefined).length).toBeGreaterThan(0);
157
+ test("an empty state yields the pre-auth bullets", () => {
158
+ expect(buildScriptBullets(emptyRetailState()).length).toBeGreaterThan(0);
158
159
  });
159
160
 
160
161
  test("a customer with pending orders is offered cancel and address change", () => {
@@ -129,7 +129,24 @@ export interface RetailState {
129
129
  focus: { orderId?: string; productId?: string };
130
130
  }
131
131
 
132
- export type StateSlot = { retail?: RetailState };
132
+ /**
133
+ * A pristine, EMPTY store — no seed data.
134
+ *
135
+ * `store.ts`'s `createDefaultState` builds the real seeded state on top of
136
+ * this, so the shape is declared once. It lives here, apart from that, because
137
+ * `client.tsx` needs a `RetailState` for its pre-first-call fallback and must
138
+ * not import `store.ts`: that module pulls the 107 KB `seed.json`, which would
139
+ * then land in the browser bundle.
140
+ */
141
+ export function emptyRetailState(): RetailState {
142
+ return {
143
+ store: { users: {}, orders: {}, products: {} },
144
+ authenticatedUserId: null,
145
+ callSeq: 0,
146
+ activity: [],
147
+ focus: {},
148
+ };
149
+ }
133
150
 
134
151
  // ─── View types — exactly what crosses the wire ──────────────────────────────
135
152
 
@@ -321,16 +338,20 @@ function swapOptionsFor(state: RetailState, order: Order | undefined): SwapOptio
321
338
  *
322
339
  * Only the authenticated customer is projected. The other five seeded
323
340
  * 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;
341
+ * which is why `syncState` takes a projection rather than a boolean.
342
+ *
343
+ * Takes the state itself, not the slot: `retailSlot.projection` supplies a
344
+ * real one even before the first tool call, so the optional chaining this
345
+ * used to carry which read as security gating and was not — is gone. The
346
+ * gating that IS security is still here, on `user`. */
347
+ export function storeView(state: RetailState): StoreView {
348
+ const userId = state.authenticatedUserId;
349
+ const user = userId ? state.store.users[userId] : undefined;
350
+ const focusedOrderId = state.focus.orderId;
330
351
  // Gated on `user`: an order is only ever projected for the customer on the
331
352
  // call, so a stale focus from before authentication cannot leak one.
332
353
  const focusedOrder =
333
- state && user && focusedOrderId && user.orders.includes(focusedOrderId)
354
+ user && focusedOrderId && user.orders.includes(focusedOrderId)
334
355
  ? state.store.orders[focusedOrderId]
335
356
  : undefined;
336
357
 
@@ -346,7 +367,7 @@ export function storeView(slot: StateSlot): StoreView {
346
367
  : null,
347
368
  orders: user
348
369
  ? user.orders
349
- .map((id) => state?.store.orders[id])
370
+ .map((id) => state.store.orders[id])
350
371
  .filter((o): o is Order => o !== undefined)
351
372
  .map(orderView)
352
373
  : [],
@@ -356,13 +377,13 @@ export function storeView(slot: StateSlot): StoreView {
356
377
  // though its details were already withheld from `orders`/`swapOptions`.
357
378
  focus: {
358
379
  ...(focusedOrder && focusedOrderId ? { orderId: focusedOrderId } : {}),
359
- ...(state?.focus.productId ? { productId: state.focus.productId } : {}),
380
+ ...(state.focus.productId ? { productId: state.focus.productId } : {}),
360
381
  },
361
- swapOptions: state ? swapOptionsFor(state, focusedOrder) : [],
362
- callSeq: state?.callSeq ?? 0,
363
- activity: state?.activity.slice(-MAX_ACTIVITY) ?? [],
382
+ swapOptions: swapOptionsFor(state, focusedOrder),
383
+ callSeq: state.callSeq,
384
+ activity: state.activity.slice(-MAX_ACTIVITY),
364
385
  scriptBullets: buildScriptBullets(state),
365
- productCount: Object.keys(state?.store.products ?? {}).length,
386
+ productCount: Object.keys(state.store.products).length,
366
387
  };
367
388
  }
368
389
 
@@ -375,13 +396,13 @@ const MAX_BULLETS = 6;
375
396
  * that offers actions the data can't support — "return an item" to someone
376
397
  * with no delivered order — is worse than no list.
377
398
  */
378
- export function buildScriptBullets(state: RetailState | undefined): string[] {
379
- const userId = state?.authenticatedUserId;
380
- const user = userId ? state?.store.users[userId] : undefined;
399
+ export function buildScriptBullets(state: RetailState): string[] {
400
+ const userId = state.authenticatedUserId;
401
+ const user = userId ? state.store.users[userId] : undefined;
381
402
 
382
403
  // Pre-auth: only the two ways in. The personas panel (a constant, rendered
383
404
  // straight from DEMO_PERSONAS) covers WHICH customer to be.
384
- if (!(state && user)) {
405
+ if (!user) {
385
406
  const first = DEMO_PERSONAS[0];
386
407
  return [
387
408
  `"My email is ${first?.email ?? ""}"`,