@alexkroman1/aai-cli 5.3.0 → 5.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +51 -0
  2. package/dist/{_agent-D8zBb3M5.mjs → _agent-DMyOab9_.mjs} +2 -2
  3. package/dist/{_bundler-DFS4xxqE.mjs → _bundler-Cjaxa2wi.mjs} +1 -1
  4. package/dist/{_config-D3F9km8X.mjs → _config-5AEqhh-O.mjs} +2 -2
  5. package/dist/{_dev-server-BBUWo0sb.mjs → _dev-server-vV05Fnki.mjs} +3 -3
  6. package/dist/{_init-DU-sXH6S.mjs → _init-BZ9t_Kz-.mjs} +3 -56
  7. package/dist/{_slug-api-19R5kZ0U.mjs → _slug-api-DaqQJHk8.mjs} +1 -1
  8. package/dist/_templates-Bv8CR800.mjs +69 -0
  9. package/dist/_templates.d.ts +6 -0
  10. package/dist/{build-Xq1xxoSg.mjs → build-D_PgQOD4.mjs} +1 -1
  11. package/dist/cli.mjs +59 -17
  12. package/dist/{client-bundler-BWDkUeEP.mjs → client-bundler-yiWoXrgb.mjs} +3 -0
  13. package/dist/client-bundler.d.ts +9 -0
  14. package/dist/client-bundler.mjs +1 -1
  15. package/dist/{delete-kSXFLxek.mjs → delete-DXilFBb1.mjs} +1 -1
  16. package/dist/{deploy-Ci0X1gp2.mjs → deploy-1eaXcfUw.mjs} +3 -3
  17. package/dist/{dev-g7sGt8qX.mjs → dev-gVNdGFYY.mjs} +1 -1
  18. package/dist/{init-BjeK8crW.mjs → init-DoU4_txp.mjs} +3 -3
  19. package/dist/login-C59ZHzuO.mjs +109 -0
  20. package/dist/login.d.ts +34 -0
  21. package/dist/scaffold/CLAUDE.md +190 -120
  22. package/dist/scaffold/package.json +3 -3
  23. package/dist/{secret-DOva9OGk.mjs → secret-CGAIAbUx.mjs} +1 -1
  24. package/dist/{storage-vM6HjDZZ.mjs → storage-CnhOayhm.mjs} +1 -1
  25. package/dist/templates/code-interpreter/agent.ts +1 -2
  26. package/dist/templates/dispatch-center/agent.test.ts +3 -3
  27. package/dist/templates/dispatch-center/agent.ts +7 -7
  28. package/dist/templates/dispatch-center/client.tsx +7 -2
  29. package/dist/templates/dispatch-center/tools/incident_add_note.ts +1 -1
  30. package/dist/templates/dispatch-center/tools/incident_create.ts +1 -1
  31. package/dist/templates/dispatch-center/tools/incident_escalate.ts +1 -1
  32. package/dist/templates/dispatch-center/tools/incident_get.ts +1 -1
  33. package/dist/templates/dispatch-center/tools/incident_triage.ts +1 -1
  34. package/dist/templates/dispatch-center/tools/incident_update_status.ts +1 -1
  35. package/dist/templates/dispatch-center/tools/ops_protocols.ts +1 -1
  36. package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +1 -1
  37. package/dist/templates/dispatch-center/tools/resources_dispatch.ts +1 -1
  38. package/dist/templates/dispatch-center/tools/resources_get_available.ts +1 -1
  39. package/dist/templates/dispatch-center/tools/resources_update_status.ts +1 -1
  40. package/dist/templates/embedded-assets/agent.ts +2 -3
  41. package/dist/templates/health-assistant/agent.ts +3 -4
  42. package/dist/templates/infocom-adventure/agent.ts +10 -12
  43. package/dist/templates/infocom-adventure/client.tsx +5 -5
  44. package/dist/templates/math-buddy/agent.ts +7 -6
  45. package/dist/templates/night-owl/agent.ts +2 -3
  46. package/dist/templates/personal-finance/agent.ts +1 -2
  47. package/dist/templates/pipeline-simple/agent.test.ts +13 -13
  48. package/dist/templates/pipeline-simple/agent.ts +5 -6
  49. package/dist/templates/pizza-ordering/agent.test.ts +13 -11
  50. package/dist/templates/pizza-ordering/agent.ts +5 -6
  51. package/dist/templates/retail/address.ts +29 -0
  52. package/dist/templates/retail/agent.test.ts +1030 -0
  53. package/dist/templates/retail/agent.ts +71 -0
  54. package/dist/templates/retail/authenticate.ts +34 -0
  55. package/dist/templates/retail/client.tsx +459 -0
  56. package/dist/templates/retail/refund.ts +19 -0
  57. package/dist/templates/retail/registry.test.ts +182 -0
  58. package/dist/templates/retail/resolve.test.ts +158 -0
  59. package/dist/templates/retail/resolve.ts +158 -0
  60. package/dist/templates/retail/seed.json +4260 -0
  61. package/dist/templates/retail/seed.test.ts +224 -0
  62. package/dist/templates/retail/shared.test.ts +192 -0
  63. package/dist/templates/retail/shared.ts +430 -0
  64. package/dist/templates/retail/store.test.ts +256 -0
  65. package/dist/templates/retail/store.ts +243 -0
  66. package/dist/templates/retail/swap.test.ts +186 -0
  67. package/dist/templates/retail/swap.ts +138 -0
  68. package/dist/templates/retail/system-prompt.md +111 -0
  69. package/dist/templates/retail/tools/cancel_pending_order.ts +82 -0
  70. package/dist/templates/retail/tools/exchange_delivered_order_items.ts +83 -0
  71. package/dist/templates/retail/tools/find_user_id_by_email.ts +32 -0
  72. package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +38 -0
  73. package/dist/templates/retail/tools/get_item_details.ts +29 -0
  74. package/dist/templates/retail/tools/get_order_details.ts +41 -0
  75. package/dist/templates/retail/tools/get_product_details.ts +33 -0
  76. package/dist/templates/retail/tools/get_user_details.ts +44 -0
  77. package/dist/templates/retail/tools/list_all_product_types.ts +24 -0
  78. package/dist/templates/retail/tools/modify_pending_order_address.ts +46 -0
  79. package/dist/templates/retail/tools/modify_pending_order_items.ts +102 -0
  80. package/dist/templates/retail/tools/modify_pending_order_payment.ts +103 -0
  81. package/dist/templates/retail/tools/modify_user_address.ts +38 -0
  82. package/dist/templates/retail/tools/return_delivered_order_items.ts +101 -0
  83. package/dist/templates/retail/tools/transfer_to_human_agents.ts +22 -0
  84. package/dist/templates/simple/agent.test.ts +18 -0
  85. package/dist/templates/simple/agent.ts +3 -0
  86. package/dist/templates/solo-rpg/agent.test.ts +2 -2
  87. package/dist/templates/solo-rpg/agent.ts +1 -2
  88. package/dist/templates/solo-rpg/tools/action_roll.ts +1 -1
  89. package/dist/templates/solo-rpg/tools/load_game.ts +1 -1
  90. package/dist/templates/solo-rpg/tools/oracle.ts +1 -1
  91. package/dist/templates/solo-rpg/tools/save_game.ts +1 -1
  92. package/dist/templates/solo-rpg/tools/setup_character.ts +1 -1
  93. package/dist/templates/solo-rpg/tools/update_state.ts +1 -1
  94. package/dist/templates/web-researcher/agent.ts +1 -2
  95. package/dist/typecheck.d.ts +18 -0
  96. package/dist/typecheck.mjs +8 -0
  97. package/dist/worker-bundler.d.ts +9 -1
  98. package/dist/worker-bundler.mjs +8 -0
  99. package/package.json +3 -3
@@ -0,0 +1,182 @@
1
+ import type { ToolContext } from "@alexkroman1/aai";
2
+ import { describe, expect, test } from "vitest";
3
+ import retailAgent from "./agent.ts";
4
+ import { getState, isError } from "./store.ts";
5
+
6
+ /** Tools that legitimately run before the caller is identified. Everything
7
+ * else must refuse. Listed here so ADDING an unauthenticated tool is a
8
+ * deliberate edit to this file, not a silent gap. */
9
+ const PUBLIC_TOOLS = new Set([
10
+ "find_user_id_by_email",
11
+ "find_user_id_by_name_zip",
12
+ "get_product_details",
13
+ "get_item_details",
14
+ "list_all_product_types",
15
+ "transfer_to_human_agents",
16
+ ]);
17
+
18
+ const registry = Object.entries(retailAgent.tools ?? {});
19
+
20
+ let sessionCounter = 0;
21
+ function makeCtx(): ToolContext {
22
+ return {
23
+ sessionId: `registry-test-${++sessionCounter}`,
24
+ send: () => {},
25
+ env: {},
26
+ state: {},
27
+ messages: [],
28
+ } as unknown as ToolContext;
29
+ }
30
+
31
+ /** Minimal args satisfying each tool's schema. Deliberately plausible-shaped
32
+ * but wrong — these calls are expected to fail; what is asserted is that they
33
+ * still moved the UI. */
34
+ const SAMPLE_ARGS: Record<string, Record<string, unknown>> = {
35
+ cancel_pending_order: { order_id: "#W0000000", reason: "no longer needed" },
36
+ exchange_delivered_order_items: {
37
+ order_id: "#W0000000",
38
+ item_ids: ["0000000000"],
39
+ new_item_ids: ["1111111111"],
40
+ payment_method_id: "gift_card_0000000",
41
+ },
42
+ find_user_id_by_email: { email: "nobody@example.com" },
43
+ find_user_id_by_name_zip: { first_name: "No", last_name: "Body", zip: "00000" },
44
+ get_item_details: { item_id: "0000000000" },
45
+ get_order_details: { order_id: "#W0000000" },
46
+ get_product_details: { product_id: "0000000000" },
47
+ get_user_details: { user_id: "nobody_0" },
48
+ list_all_product_types: {},
49
+ modify_pending_order_address: {
50
+ order_id: "#W0000000",
51
+ address1: "1 A St",
52
+ address2: "",
53
+ city: "Springfield",
54
+ state: "OR",
55
+ country: "USA",
56
+ zip: "97477",
57
+ },
58
+ modify_pending_order_items: {
59
+ order_id: "#W0000000",
60
+ item_ids: ["0000000000"],
61
+ new_item_ids: ["1111111111"],
62
+ payment_method_id: "gift_card_0000000",
63
+ },
64
+ modify_pending_order_payment: {
65
+ order_id: "#W0000000",
66
+ payment_method_id: "gift_card_0000000",
67
+ },
68
+ modify_user_address: {
69
+ user_id: "nobody_0",
70
+ address1: "1 A St",
71
+ address2: "",
72
+ city: "Springfield",
73
+ state: "OR",
74
+ country: "USA",
75
+ zip: "97477",
76
+ },
77
+ return_delivered_order_items: {
78
+ order_id: "#W0000000",
79
+ item_ids: ["0000000000"],
80
+ payment_method_id: "gift_card_0000000",
81
+ },
82
+ transfer_to_human_agents: { summary: "test" },
83
+ };
84
+
85
+ describe("tool registry", () => {
86
+ test("registers all fifteen tau2 retail tools", () => {
87
+ expect(registry.map(([name]) => name).sort()).toEqual(
88
+ [
89
+ "cancel_pending_order",
90
+ "exchange_delivered_order_items",
91
+ "find_user_id_by_email",
92
+ "find_user_id_by_name_zip",
93
+ "get_item_details",
94
+ "get_order_details",
95
+ "get_product_details",
96
+ "get_user_details",
97
+ "list_all_product_types",
98
+ "modify_pending_order_address",
99
+ "modify_pending_order_items",
100
+ "modify_pending_order_payment",
101
+ "modify_user_address",
102
+ "return_delivered_order_items",
103
+ "transfer_to_human_agents",
104
+ ].sort(),
105
+ );
106
+ });
107
+
108
+ test("every registered tool has sample args, so the sweeps below cover it", () => {
109
+ for (const [name] of registry) {
110
+ expect(SAMPLE_ARGS[name], `add SAMPLE_ARGS["${name}"]`).toBeDefined();
111
+ }
112
+ });
113
+
114
+ test("every tool declares a description the model can act on", () => {
115
+ for (const [name, def] of registry) {
116
+ expect(def.description, name).toBeTruthy();
117
+ expect(def.description.length, name).toBeGreaterThan(40);
118
+ }
119
+ });
120
+ });
121
+
122
+ describe("the UI-update invariant", () => {
123
+ // This is the one that fails if a future tool is built with tool() instead of
124
+ // retailTool(): it would work, and the sidebar would sit still through it.
125
+ test.each(registry)("%s increments callSeq and logs activity", async (name, def) => {
126
+ const ctx = makeCtx();
127
+ const before = getState(ctx).callSeq;
128
+ await def.execute(SAMPLE_ARGS[name] ?? {}, ctx);
129
+ const state = getState(ctx);
130
+ expect(state.callSeq, `${name} did not bump callSeq`).toBe(before + 1);
131
+ expect(state.activity.at(-1)?.tool, `${name} logged the wrong tool name`).toBe(name);
132
+ expect(state.activity.at(-1)?.summary).toBeTruthy();
133
+ });
134
+
135
+ test.each(registry)("%s logs its own registry key as its name", async (name, def) => {
136
+ // Catches a copy-paste where the retailTool `name` and the registry key
137
+ // disagree — the activity feed would then attribute calls to the wrong tool.
138
+ const ctx = makeCtx();
139
+ await def.execute(SAMPLE_ARGS[name] ?? {}, ctx);
140
+ expect(getState(ctx).activity.at(-1)?.tool).toBe(name);
141
+ });
142
+ });
143
+
144
+ describe("the authentication gate", () => {
145
+ test.each(registry.filter(([name]) => !PUBLIC_TOOLS.has(name)))(
146
+ "%s refuses before the caller is identified",
147
+ async (name, def) => {
148
+ const result = await def.execute(SAMPLE_ARGS[name] ?? {}, makeCtx());
149
+ expect(isError(result), `${name} did not refuse`).toBe(true);
150
+ if (!isError(result)) return;
151
+ expect(result.error, `${name} refused for the wrong reason`).toContain(
152
+ "find_user_id_by_email",
153
+ );
154
+ },
155
+ );
156
+
157
+ test.each(registry.filter(([name]) => PUBLIC_TOOLS.has(name)))(
158
+ "%s does not require authentication",
159
+ async (name, def) => {
160
+ const result = await def.execute(SAMPLE_ARGS[name] ?? {}, makeCtx());
161
+ // A public tool may still fail on its own (deliberately bogus) arguments;
162
+ // it must not fail on the GATE. Written as one unconditional assertion —
163
+ // an `if (isError(result))` wrapper would pass vacuously for the tools
164
+ // that succeed, which is precisely the set most at risk of regressing.
165
+ const blockedByGate = isError(result) && result.error.includes("find_user_id_by_email");
166
+ expect(blockedByGate, `${name} was blocked by the auth gate`).toBe(false);
167
+ },
168
+ );
169
+ });
170
+
171
+ describe("agent config", () => {
172
+ test("declares a syncState projection — without it the UI never updates", () => {
173
+ expect(typeof retailAgent.syncState).toBe("function");
174
+ });
175
+
176
+ test("declares a state factory, so sessions do not share a store", () => {
177
+ expect(typeof retailAgent.state).toBe("function");
178
+ const a = retailAgent.state?.();
179
+ const b = retailAgent.state?.();
180
+ expect(a).not.toBe(b);
181
+ });
182
+ });
@@ -0,0 +1,158 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { normalizeItemId, normalizeOrderId, resolveOrder, resolveVariantId } from "./resolve.ts";
3
+ import { createDefaultState, findProduct, isError } from "./store.ts";
4
+
5
+ function stateFor(userId: string) {
6
+ const state = createDefaultState();
7
+ state.authenticatedUserId = userId;
8
+ return state;
9
+ }
10
+
11
+ describe("normalizeOrderId", () => {
12
+ test("accepts every form STT plausibly produces", () => {
13
+ for (const spoken of [
14
+ "#W5866402",
15
+ "W5866402",
16
+ "w5866402",
17
+ "W 5866402",
18
+ "w-5866402",
19
+ "5866402",
20
+ " #w 586 6402 ",
21
+ ]) {
22
+ expect(normalizeOrderId(spoken), spoken).toBe("#W5866402");
23
+ }
24
+ });
25
+ });
26
+
27
+ describe("normalizeItemId", () => {
28
+ test("strips spoken separators from a digit run", () => {
29
+ expect(normalizeItemId("3909406921")).toBe("3909406921");
30
+ expect(normalizeItemId("390 940 6921")).toBe("3909406921");
31
+ expect(normalizeItemId("3909-406-921")).toBe("3909406921");
32
+ });
33
+ });
34
+
35
+ describe("resolveOrder — canonical ids", () => {
36
+ test("resolves the caller's own order in any spoken form", () => {
37
+ const state = stateFor("olivia_ito_3591");
38
+ for (const spoken of ["#W5866402", "W 5866402", "5866402"]) {
39
+ const order = resolveOrder(state, spoken);
40
+ expect(isError(order) ? null : order.order_id, spoken).toBe("#W5866402");
41
+ }
42
+ });
43
+
44
+ test("refuses another customer's order id", () => {
45
+ const state = stateFor("olivia_ito_3591");
46
+ expect(isError(resolveOrder(state, "#W4316152"))).toBe(true);
47
+ });
48
+ });
49
+
50
+ describe("resolveOrder — shorthand", () => {
51
+ test("a single delivered order resolves from a status word", () => {
52
+ const state = stateFor("olivia_ito_3591");
53
+ for (const spoken of ["the delivered one", "my delivered order", "delivered"]) {
54
+ const order = resolveOrder(state, spoken);
55
+ expect(isError(order) ? null : order.order_id, spoken).toBe("#W5866402");
56
+ }
57
+ });
58
+
59
+ test("three pending orders make bare 'my pending order' ambiguous, and it lists them", () => {
60
+ const state = stateFor("olivia_ito_3591");
61
+ const result = resolveOrder(state, "my pending order");
62
+ if (!isError(result)) throw new Error("expected ambiguity");
63
+ for (const id of ["#W5442520", "#W7941031", "#W3657213"]) {
64
+ expect(result.error).toContain(id);
65
+ }
66
+ });
67
+
68
+ test("an ordinal picks one out of several pending orders", () => {
69
+ const state = stateFor("olivia_ito_3591");
70
+ const first = resolveOrder(state, "my first pending order");
71
+ const second = resolveOrder(state, "the second pending order");
72
+ expect(isError(first) ? null : first.order_id).toBe("#W5442520");
73
+ expect(isError(second) ? null : second.order_id).toBe("#W7941031");
74
+ });
75
+
76
+ test("'last' picks the final one", () => {
77
+ const state = stateFor("olivia_ito_3591");
78
+ const result = resolveOrder(state, "my last pending order");
79
+ expect(isError(result) ? null : result.order_id).toBe("#W3657213");
80
+ });
81
+
82
+ test("a bare ordinal with no status word indexes all of the caller's orders", () => {
83
+ const state = stateFor("emma_smith_8564");
84
+ const result = resolveOrder(state, "the first one");
85
+ expect(isError(result) ? null : result.order_id).toBe("#W2417020");
86
+ });
87
+
88
+ test("a status the caller has none of says what they do have", () => {
89
+ const state = stateFor("harper_brown_7363");
90
+ const result = resolveOrder(state, "my cancelled order");
91
+ if (!isError(result)) throw new Error("expected refusal");
92
+ expect(result.error).toContain("delivered");
93
+ expect(result.error).toContain("pending");
94
+ });
95
+
96
+ test("an out-of-range ordinal is refused, not clamped", () => {
97
+ const state = stateFor("emma_smith_8564");
98
+ expect(isError(resolveOrder(state, "the fourth pending order"))).toBe(true);
99
+ });
100
+
101
+ test("unresolvable input is refused before authentication too", () => {
102
+ const state = createDefaultState();
103
+ expect(isError(resolveOrder(state, "my pending order"))).toBe(true);
104
+ });
105
+ });
106
+
107
+ describe("resolveVariantId", () => {
108
+ const state = createDefaultState();
109
+ function teaKettle() {
110
+ const product = findProduct(state, "9832717871");
111
+ if (isError(product)) throw new Error(product.error);
112
+ return product;
113
+ }
114
+
115
+ test("passes a canonical item id through", () => {
116
+ expect(resolveVariantId(teaKettle(), "3909406921")).toBe("3909406921");
117
+ expect(resolveVariantId(teaKettle(), "390 940 6921")).toBe("3909406921");
118
+ });
119
+
120
+ test("refuses an item id belonging to a different product", () => {
121
+ expect(isError(resolveVariantId(teaKettle(), "4725166838"))).toBe(true);
122
+ });
123
+
124
+ test("matches a spoken option phrase", () => {
125
+ // 3738831434 is the only stainless steel 1.5 liter kettle. The seed's
126
+ // capacity value is the digit form "1.5 liters" — the matcher is a plain
127
+ // substring test, so the spoken text has to contain it verbatim, not the
128
+ // spelled-out "one point five liters" an STT transcript never produces
129
+ // for a written spec like this either.
130
+ const result = resolveVariantId(teaKettle(), "the stainless steel 1.5 liters");
131
+ expect(result).toBe("3738831434");
132
+ });
133
+
134
+ test("an ambiguous phrase lists the candidates with their options", () => {
135
+ const result = resolveVariantId(teaKettle(), "glass");
136
+ if (!isError(result)) throw new Error("expected ambiguity");
137
+ expect(result.error).toContain("glass");
138
+ expect(result.error.match(/\d{10}/g)?.length).toBeGreaterThan(1);
139
+ });
140
+
141
+ test("a phrase matching nothing is refused", () => {
142
+ expect(isError(resolveVariantId(teaKettle(), "titanium"))).toBe(true);
143
+ });
144
+
145
+ test("availableOnly excludes unavailable variants from matching", () => {
146
+ // 6454334990 is glass / 1.5 liters / induction and unavailable. "glass"
147
+ // is required in the phrase too — capacity + stovetop alone also match
148
+ // the available stainless-steel 1.5L induction variant (3738831434) at
149
+ // the same score, which would make the "loose" match ambiguous instead
150
+ // of the unique glass one this test needs.
151
+ const loose = resolveVariantId(teaKettle(), "glass 1.5 liters induction");
152
+ expect(loose).toBe("6454334990");
153
+ const strict = resolveVariantId(teaKettle(), "glass 1.5 liters induction", {
154
+ availableOnly: true,
155
+ });
156
+ expect(strict).not.toBe("6454334990");
157
+ });
158
+ });
@@ -0,0 +1,158 @@
1
+ import type { Order, OrderStatus, Product, RetailState } from "./shared.ts";
2
+ import { authenticatedUser, type ErrorResult, isError } from "./store.ts";
3
+
4
+ /** `#W5866402` from anything STT plausibly produces for it. */
5
+ export function normalizeOrderId(spoken: string): string {
6
+ const compact = spoken.toUpperCase().replace(/[^A-Z0-9]/g, "");
7
+ return `#W${compact.replace(/^W/, "")}`;
8
+ }
9
+
10
+ /** Item ids are 10 digits; callers read them in groups. */
11
+ export function normalizeItemId(spoken: string): string {
12
+ return spoken.replace(/\D/g, "");
13
+ }
14
+
15
+ /** A digit run long enough to be an id rather than an ordinal. */
16
+ const LOOKS_LIKE_ORDER_ID = /\d[\d\s-]{5,}/;
17
+ const LOOKS_LIKE_ITEM_ID = /(?:\d[\s-]*){8,}/;
18
+
19
+ const ORDINALS: Record<string, number> = {
20
+ first: 0,
21
+ "1st": 0,
22
+ second: 1,
23
+ "2nd": 1,
24
+ third: 2,
25
+ "3rd": 2,
26
+ fourth: 3,
27
+ "4th": 3,
28
+ fifth: 4,
29
+ "5th": 4,
30
+ last: -1,
31
+ };
32
+
33
+ /** Spoken status words → the statuses they select. `pending` deliberately
34
+ * excludes `pending (item modified)`, which no tool will accept anyway. */
35
+ const STATUS_WORDS: [string, OrderStatus[]][] = [
36
+ ["delivered", ["delivered"]],
37
+ ["pending", ["pending"]],
38
+ ["processed", ["processed"]],
39
+ ["cancelled", ["cancelled"]],
40
+ ["canceled", ["cancelled"]],
41
+ ["returned", ["return requested"]],
42
+ ["exchanged", ["exchange requested"]],
43
+ ];
44
+
45
+ function describeOrder(order: Order): string {
46
+ const items = order.items.map((i) => i.name).join(", ");
47
+ return `${order.order_id} (${order.status}: ${items})`;
48
+ }
49
+
50
+ /**
51
+ * Resolve a spoken order reference against the authenticated customer's orders.
52
+ *
53
+ * Ambiguity is always an error listing the candidates, never a guess — the
54
+ * consequence of guessing here is cancelling the wrong order.
55
+ */
56
+ export function resolveOrder(state: RetailState, spoken: string): Order | ErrorResult {
57
+ const user = authenticatedUser(state);
58
+ if (isError(user)) return user;
59
+
60
+ const owned = user.orders
61
+ .map((id) => state.store.orders[id])
62
+ .filter((o): o is Order => o !== undefined);
63
+
64
+ if (LOOKS_LIKE_ORDER_ID.test(spoken)) {
65
+ const orderId = normalizeOrderId(spoken);
66
+ const match = owned.find((o) => o.order_id === orderId);
67
+ return match ?? { error: `Order ${orderId} was not found on this customer's account.` };
68
+ }
69
+
70
+ const text = spoken.toLowerCase();
71
+ const statusEntry = STATUS_WORDS.find(([word]) => text.includes(word));
72
+ const candidates = statusEntry ? owned.filter((o) => statusEntry[1].includes(o.status)) : owned;
73
+
74
+ if (candidates.length === 0) {
75
+ const have = [...new Set(owned.map((o) => o.status))].join(", ");
76
+ return {
77
+ error: `This customer has no ${statusEntry?.[0] ?? "matching"} order. They have: ${have}.`,
78
+ };
79
+ }
80
+
81
+ const ordinalWord = Object.keys(ORDINALS).find((word) => new RegExp(`\\b${word}\\b`).test(text));
82
+ if (ordinalWord) {
83
+ const index = ORDINALS[ordinalWord] ?? 0;
84
+ const picked = index === -1 ? candidates.at(-1) : candidates[index];
85
+ if (!picked) {
86
+ return {
87
+ error: `There is no ${ordinalWord} such order — the customer has ${candidates.length}: ${candidates
88
+ .map(describeOrder)
89
+ .join("; ")}.`,
90
+ };
91
+ }
92
+ return picked;
93
+ }
94
+
95
+ const only = candidates[0];
96
+ if (candidates.length === 1 && only) return only;
97
+
98
+ return {
99
+ error: `That is ambiguous — ${candidates.length} orders match. Ask which one: ${candidates
100
+ .map(describeOrder)
101
+ .join("; ")}.`,
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Resolve a spoken variant reference within one product: a 10-digit item id, or
107
+ * an option phrase like "the blue medium".
108
+ */
109
+ export function resolveVariantId(
110
+ product: Product,
111
+ spoken: string,
112
+ opts: { availableOnly?: boolean } = {},
113
+ ): string | ErrorResult {
114
+ if (LOOKS_LIKE_ITEM_ID.test(spoken)) {
115
+ const itemId = normalizeItemId(spoken);
116
+ const variant = product.variants[itemId];
117
+ if (!variant) {
118
+ return {
119
+ error: `Item ${itemId} is not a variant of ${product.name} (${product.product_id}). Its variants are: ${Object.values(
120
+ product.variants,
121
+ )
122
+ .map((v) => `${v.item_id} (${Object.values(v.options).join(", ")})`)
123
+ .join("; ")}.`,
124
+ };
125
+ }
126
+ return itemId;
127
+ }
128
+
129
+ const text = spoken.toLowerCase();
130
+ const pool = Object.values(product.variants).filter((v) => !opts.availableOnly || v.available);
131
+
132
+ let best = 0;
133
+ const scored = pool.map((variant) => {
134
+ const score = Object.values(variant.options).filter((value) =>
135
+ text.includes(value.toLowerCase()),
136
+ ).length;
137
+ best = Math.max(best, score);
138
+ return { variant, score };
139
+ });
140
+
141
+ if (best === 0) {
142
+ return {
143
+ error: `No ${product.name} option matches "${spoken}". Options available: ${pool
144
+ .map((v) => `${v.item_id} (${Object.values(v.options).join(", ")})`)
145
+ .join("; ")}.`,
146
+ };
147
+ }
148
+
149
+ const winners = scored.filter((s) => s.score === best);
150
+ const winner = winners[0]?.variant;
151
+ if (winners.length === 1 && winner) return winner.item_id;
152
+
153
+ return {
154
+ error: `"${spoken}" matches ${winners.length} ${product.name} options. Ask which: ${winners
155
+ .map((s) => `${s.variant.item_id} (${Object.values(s.variant.options).join(", ")})`)
156
+ .join("; ")}.`,
157
+ };
158
+ }