@alexkroman1/aai-cli 5.13.1 → 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,4 +1,5 @@
|
|
|
1
|
-
import type
|
|
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
|
-
|
|
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("
|
|
27
|
+
test("the slot seeds lazily and returns the same object on re-entry", () => {
|
|
35
28
|
const ctx = makeCtx();
|
|
36
|
-
const a =
|
|
37
|
-
const b =
|
|
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 =
|
|
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 =
|
|
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(
|
|
63
|
+
expect(isToolFailure(findUser(state, "olivia_ito_3591"))).toBe(false);
|
|
71
64
|
const miss = findUser(state, "nobody_1");
|
|
72
|
-
expect(
|
|
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(
|
|
79
|
-
expect(
|
|
80
|
-
expect(
|
|
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 (
|
|
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(
|
|
82
|
+
expect(isToolFailure(variant) ? null : variant.price).toBe(98.25);
|
|
90
83
|
// A real item id, but of a different product.
|
|
91
|
-
expect(
|
|
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 (
|
|
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(
|
|
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 (
|
|
107
|
-
expect(
|
|
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(
|
|
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 (
|
|
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(
|
|
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(
|
|
135
|
-
if (!
|
|
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(
|
|
148
|
-
if (!(
|
|
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(
|
|
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 =
|
|
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 =
|
|
201
|
+
const first = retailSlot.get(ctx).callSeq;
|
|
209
202
|
await echo.execute({ value: "same" }, ctx);
|
|
210
|
-
expect(
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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 =
|
|
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 {
|
|
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
|
-
/**
|
|
58
|
-
*
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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 |
|
|
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 |
|
|
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 |
|
|
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 |
|
|
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 } |
|
|
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 |
|
|
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 |
|
|
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 |
|
|
137
|
+
export function requireOwnOrder(state: RetailState, orderId: string): Order | ToolFailure {
|
|
143
138
|
const user = authenticatedUser(state);
|
|
144
|
-
if (
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
state.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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(
|
|
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 (
|
|
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 (
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
113
|
+
expect(isToolFailure(strict)).toBe(true);
|
|
111
114
|
const loose = planItemSwap(state, order, ["7292993796"], ["7292993796"], {
|
|
112
115
|
requireDifferent: false,
|
|
113
116
|
});
|
|
114
|
-
expect(
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 |
|
|
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 (
|
|
85
|
+
if (isToolFailure(product)) return product;
|
|
93
86
|
|
|
94
87
|
const newVariant = findVariant(product, newItemId);
|
|
95
|
-
if (
|
|
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):
|
|
110
|
+
export function assertCanCoverDiff(user: User, methodId: string, diff: number): ToolFailure | null {
|
|
118
111
|
const method = findPaymentMethod(user, methodId);
|
|
119
|
-
if (
|
|
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,
|
|
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 =
|
|
31
|
+
const state = retailSlot.get(ctx);
|
|
31
32
|
const user = authenticatedUser(state);
|
|
32
|
-
if (
|
|
33
|
+
if (isToolFailure(user)) return user;
|
|
33
34
|
|
|
34
35
|
const order = resolveOrder(state, args.order_id);
|
|
35
|
-
if (
|
|
36
|
+
if (isToolFailure(order)) return order;
|
|
36
37
|
setFocus(state, { orderId: order.order_id });
|
|
37
38
|
|
|
38
39
|
if (order.status !== "pending") {
|