@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.
- package/dist/scaffold/package.json +3 -3
- package/dist/scaffold/pnpm-workspace.yaml +24 -0
- package/dist/templates/dispatch-center/agent.test.ts +10 -17
- package/dist/templates/dispatch-center/agent.ts +2 -2
- package/dist/templates/dispatch-center/client.tsx +9 -13
- package/dist/templates/dispatch-center/shared.ts +36 -63
- package/dist/templates/dispatch-center/tools/incident_add_note.ts +4 -4
- package/dist/templates/dispatch-center/tools/incident_create.ts +2 -2
- package/dist/templates/dispatch-center/tools/incident_escalate.ts +4 -4
- package/dist/templates/dispatch-center/tools/incident_get.ts +9 -4
- package/dist/templates/dispatch-center/tools/incident_triage.ts +4 -4
- package/dist/templates/dispatch-center/tools/incident_update_status.ts +4 -4
- package/dist/templates/dispatch-center/tools/ops_dashboard.ts +2 -2
- package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +2 -2
- package/dist/templates/dispatch-center/tools/resources_dispatch.ts +4 -4
- package/dist/templates/dispatch-center/tools/resources_get_available.ts +2 -2
- package/dist/templates/dispatch-center/tools/resources_update_status.ts +2 -2
- package/dist/templates/infocom-adventure/agent.ts +10 -10
- package/dist/templates/infocom-adventure/client.tsx +5 -17
- package/dist/templates/infocom-adventure/shared.ts +17 -14
- package/dist/templates/pizza-ordering/agent.test.ts +27 -31
- package/dist/templates/pizza-ordering/agent.ts +8 -8
- package/dist/templates/pizza-ordering/client.tsx +4 -4
- package/dist/templates/pizza-ordering/shared.ts +14 -20
- package/dist/templates/retail/agent.test.ts +137 -138
- package/dist/templates/retail/agent.ts +7 -10
- package/dist/templates/retail/authenticate.ts +2 -2
- package/dist/templates/retail/client.tsx +13 -14
- package/dist/templates/retail/registry.test.ts +9 -9
- package/dist/templates/retail/resolve.test.ts +17 -16
- package/dist/templates/retail/resolve.ts +5 -4
- package/dist/templates/retail/shared.test.ts +17 -16
- package/dist/templates/retail/shared.ts +39 -18
- package/dist/templates/retail/store.test.ts +40 -47
- package/dist/templates/retail/store.ts +39 -65
- package/dist/templates/retail/swap.test.ts +20 -17
- package/dist/templates/retail/swap.ts +7 -14
- package/dist/templates/retail/tools/cancel_pending_order.ts +5 -4
- package/dist/templates/retail/tools/exchange_delivered_order_items.ts +6 -5
- package/dist/templates/retail/tools/find_user_id_by_email.ts +2 -2
- package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +2 -2
- package/dist/templates/retail/tools/get_item_details.ts +4 -3
- package/dist/templates/retail/tools/get_order_details.ts +4 -3
- package/dist/templates/retail/tools/get_product_details.ts +4 -3
- package/dist/templates/retail/tools/get_user_details.ts +4 -3
- package/dist/templates/retail/tools/list_all_product_types.ts +2 -2
- package/dist/templates/retail/tools/modify_pending_order_address.ts +4 -3
- package/dist/templates/retail/tools/modify_pending_order_items.ts +6 -6
- package/dist/templates/retail/tools/modify_pending_order_payment.ts +6 -6
- package/dist/templates/retail/tools/modify_user_address.ts +4 -3
- package/dist/templates/retail/tools/return_delivered_order_items.ts +6 -6
- package/dist/templates/solo-rpg/agent.test.ts +26 -34
- package/dist/templates/solo-rpg/agent.ts +2 -2
- package/dist/templates/solo-rpg/client.tsx +1 -1
- package/dist/templates/solo-rpg/shared.ts +6 -14
- package/dist/templates/solo-rpg/tools/action_roll.ts +2 -2
- package/dist/templates/solo-rpg/tools/burn_momentum.ts +2 -2
- package/dist/templates/solo-rpg/tools/check_state.ts +2 -2
- package/dist/templates/solo-rpg/tools/load_game.ts +2 -2
- package/dist/templates/solo-rpg/tools/oracle.ts +3 -3
- package/dist/templates/solo-rpg/tools/save_game.ts +2 -2
- package/dist/templates/solo-rpg/tools/setup_character.ts +2 -2
- package/dist/templates/solo-rpg/tools/update_state.ts +12 -11
- 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 {
|
|
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.
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
|
|
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 |
|
|
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
|
-
|
|
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>()
|
|
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
|
-
<
|
|
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
|
-
|
|
281
|
-
</div>
|
|
280
|
+
</AutoScroll>
|
|
282
281
|
|
|
283
282
|
{session.userTranscript !== null && (
|
|
284
283
|
<div
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type
|
|
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 {
|
|
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 =
|
|
129
|
+
const before = retailSlot.get(ctx).callSeq;
|
|
130
130
|
await def.execute(SAMPLE_ARGS[name] ?? {}, ctx);
|
|
131
|
-
const state =
|
|
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(
|
|
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(
|
|
152
|
-
if (!
|
|
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 (
|
|
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 =
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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 (!
|
|
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(
|
|
75
|
-
expect(
|
|
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(
|
|
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(
|
|
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 (!
|
|
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(
|
|
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(
|
|
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 (
|
|
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(
|
|
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 (!
|
|
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(
|
|
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
|
|
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 |
|
|
57
|
+
export function resolveOrder(state: RetailState, spoken: string): Order | ToolFailure {
|
|
57
58
|
const user = authenticatedUser(state);
|
|
58
|
-
if (
|
|
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 |
|
|
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(
|
|
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
|
|
24
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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("
|
|
157
|
-
expect(buildScriptBullets(
|
|
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
|
-
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
380
|
+
...(state.focus.productId ? { productId: state.focus.productId } : {}),
|
|
360
381
|
},
|
|
361
|
-
swapOptions:
|
|
362
|
-
callSeq: state
|
|
363
|
-
activity: state
|
|
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
|
|
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
|
|
379
|
-
const userId = state
|
|
380
|
-
const user = userId ? state
|
|
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 (!
|
|
405
|
+
if (!user) {
|
|
385
406
|
const first = DEMO_PERSONAS[0];
|
|
386
407
|
return [
|
|
387
408
|
`"My email is ${first?.email ?? ""}"`,
|