@alexkroman1/aai-cli 5.1.1 → 5.2.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/dist/_agent.d.ts +39 -0
  2. package/dist/_api-client.d.ts +37 -0
  3. package/dist/_bundler.d.ts +46 -0
  4. package/dist/_config.d.ts +57 -0
  5. package/dist/_default-html.d.ts +12 -0
  6. package/dist/_deploy.d.ts +28 -0
  7. package/dist/_dev-server.d.ts +74 -0
  8. package/dist/{_init-B-LEVTHx.mjs → _init-DU-sXH6S.mjs} +44 -65
  9. package/dist/_init.d.ts +7 -0
  10. package/dist/_mock-api.d.ts +22 -0
  11. package/dist/_mock-registry.d.ts +17 -0
  12. package/dist/_output.d.ts +50 -0
  13. package/dist/_server-common.d.ts +17 -0
  14. package/dist/_slug-api.d.ts +15 -0
  15. package/dist/_templates.d.ts +21 -0
  16. package/dist/_typecheck-gate.d.ts +7 -0
  17. package/dist/_ui.d.ts +22 -0
  18. package/dist/_utils.d.ts +48 -0
  19. package/dist/_vite-env.d.ts +8 -0
  20. package/dist/build.d.ts +16 -0
  21. package/dist/cli.d.ts +1 -0
  22. package/dist/cli.mjs +1 -1
  23. package/dist/client-bundler.d.ts +21 -0
  24. package/dist/delete.d.ts +18 -0
  25. package/dist/deploy.d.ts +15 -0
  26. package/dist/dev.d.ts +13 -0
  27. package/dist/{init-tc3Rbgrf.mjs → init-BjeK8crW.mjs} +1 -1
  28. package/dist/init.d.ts +24 -0
  29. package/dist/scaffold/.env.example +11 -0
  30. package/dist/scaffold/CLAUDE.md +919 -0
  31. package/dist/scaffold/global.d.ts +1 -0
  32. package/dist/scaffold/package.json +32 -0
  33. package/dist/scaffold/pnpm-workspace.yaml +13 -0
  34. package/dist/scaffold/tsconfig.json +19 -0
  35. package/dist/scaffold/vite.config.ts +14 -0
  36. package/dist/scaffold/vitest.config.ts +24 -0
  37. package/dist/secret.d.ts +25 -0
  38. package/dist/storage.d.ts +22 -0
  39. package/dist/templates/code-interpreter/agent.ts +11 -0
  40. package/dist/templates/code-interpreter/system-prompt.md +18 -0
  41. package/dist/templates/dispatch-center/agent.test.ts +153 -0
  42. package/dist/templates/dispatch-center/agent.ts +50 -0
  43. package/dist/templates/dispatch-center/client.tsx +378 -0
  44. package/dist/templates/dispatch-center/shared.ts +631 -0
  45. package/dist/templates/dispatch-center/system-prompt.md +39 -0
  46. package/dist/templates/dispatch-center/tools/incident_add_note.ts +27 -0
  47. package/dist/templates/dispatch-center/tools/incident_create.ts +79 -0
  48. package/dist/templates/dispatch-center/tools/incident_escalate.ts +88 -0
  49. package/dist/templates/dispatch-center/tools/incident_get.ts +36 -0
  50. package/dist/templates/dispatch-center/tools/incident_triage.ts +78 -0
  51. package/dist/templates/dispatch-center/tools/incident_update_status.ts +68 -0
  52. package/dist/templates/dispatch-center/tools/ops_dashboard.ts +52 -0
  53. package/dist/templates/dispatch-center/tools/ops_protocols.ts +27 -0
  54. package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +149 -0
  55. package/dist/templates/dispatch-center/tools/resources_dispatch.ts +97 -0
  56. package/dist/templates/dispatch-center/tools/resources_get_available.ts +36 -0
  57. package/dist/templates/dispatch-center/tools/resources_update_status.ts +62 -0
  58. package/dist/templates/embedded-assets/agent.ts +55 -0
  59. package/dist/templates/embedded-assets/knowledge.json +20 -0
  60. package/dist/templates/health-assistant/agent.ts +174 -0
  61. package/dist/templates/health-assistant/system-prompt.md +17 -0
  62. package/dist/templates/infocom-adventure/agent.ts +119 -0
  63. package/dist/templates/infocom-adventure/client.tsx +279 -0
  64. package/dist/templates/infocom-adventure/shared.ts +38 -0
  65. package/dist/templates/infocom-adventure/system-prompt.md +43 -0
  66. package/dist/templates/math-buddy/agent.ts +16 -0
  67. package/dist/templates/math-buddy/system-prompt.md +12 -0
  68. package/dist/templates/night-owl/agent.ts +76 -0
  69. package/dist/templates/night-owl/client.tsx +130 -0
  70. package/dist/templates/night-owl/shared.ts +5 -0
  71. package/dist/templates/personal-finance/agent.ts +11 -0
  72. package/dist/templates/personal-finance/system-prompt.md +17 -0
  73. package/dist/templates/pipeline-simple/agent.test.ts +32 -0
  74. package/dist/templates/pipeline-simple/agent.ts +13 -0
  75. package/dist/templates/pizza-ordering/agent.test.ts +235 -0
  76. package/dist/templates/pizza-ordering/agent.ts +174 -0
  77. package/dist/templates/pizza-ordering/client.tsx +120 -0
  78. package/dist/templates/pizza-ordering/shared.ts +142 -0
  79. package/dist/templates/pizza-ordering/system-prompt.md +17 -0
  80. package/dist/templates/simple/agent.ts +5 -0
  81. package/dist/templates/solo-rpg/agent.test.ts +477 -0
  82. package/dist/templates/solo-rpg/agent.ts +37 -0
  83. package/dist/templates/solo-rpg/client.tsx +809 -0
  84. package/dist/templates/solo-rpg/shared.ts +747 -0
  85. package/dist/templates/solo-rpg/system-prompt.md +115 -0
  86. package/dist/templates/solo-rpg/tools/action_roll.ts +98 -0
  87. package/dist/templates/solo-rpg/tools/burn_momentum.ts +69 -0
  88. package/dist/templates/solo-rpg/tools/check_state.ts +11 -0
  89. package/dist/templates/solo-rpg/tools/load_game.ts +25 -0
  90. package/dist/templates/solo-rpg/tools/oracle.ts +248 -0
  91. package/dist/templates/solo-rpg/tools/save_game.ts +20 -0
  92. package/dist/templates/solo-rpg/tools/setup_character.ts +208 -0
  93. package/dist/templates/solo-rpg/tools/update_state.ts +170 -0
  94. package/dist/templates/web-researcher/agent.ts +11 -0
  95. package/dist/test.d.ts +39 -0
  96. package/dist/tsdown.config.d.ts +2 -0
  97. package/dist/typecheck.d.ts +26 -0
  98. package/dist/worker-bundler.d.ts +29 -0
  99. package/package.json +9 -6
@@ -0,0 +1,13 @@
1
+ import { agent, assemblyAIPipeline } from "@alexkroman1/aai";
2
+ import { anthropic } from "@alexkroman1/aai/llm";
3
+
4
+ export default agent({
5
+ name: "pipeline-simple",
6
+ greeting: "Hi! I'm running in pipeline mode. What can I help with?",
7
+ // The preset fills all three stages with AssemblyAI, billed to the one key
8
+ // a published agent is guaranteed to have. Spread it, then override the one
9
+ // stage you want elsewhere — here the LLM. Anything not overridden stays
10
+ // AssemblyAI, so swapping a stage never means restating the other two.
11
+ ...assemblyAIPipeline(),
12
+ llm: anthropic({ model: "claude-haiku-4-5" }),
13
+ });
@@ -0,0 +1,235 @@
1
+ import type { Db, ToolContext, ToolDef } from "@alexkroman1/aai";
2
+ import { describe, expect, test } from "vitest";
3
+ import agentDef from "./agent.ts";
4
+ import { calculateTotal, orderView, type Pizza, pizzaPrice, type StateSlot } from "./shared.ts";
5
+
6
+ // ─── Test doubles ────────────────────────────────────────────────────────────
7
+
8
+ /** This template keeps its cart in ctx.state — the db must never be touched. */
9
+ const noDb: Db = {
10
+ query: () => Promise.reject(new Error("db not used by this template")),
11
+ };
12
+
13
+ /** ToolContext stub: per-session ctx.state, and captured ctx.send events.
14
+ * Each stub is one session — the cart is session-scoped by construction. */
15
+ function makeCtx(sessionId = "session-a") {
16
+ const sent: Array<{ event: string; data: unknown }> = [];
17
+ const ctx: ToolContext = {
18
+ env: {},
19
+ state: {},
20
+ db: noDb,
21
+ generate: () => Promise.reject(new Error("generate not available in tests")),
22
+ messages: [],
23
+ sessionId,
24
+ send: (event, data) => {
25
+ sent.push({ event, data });
26
+ },
27
+ };
28
+ return { ctx, sent };
29
+ }
30
+
31
+ function getTool(name: string): ToolDef {
32
+ const def = agentDef.tools[name];
33
+ if (!def) throw new Error(`tool ${name} not defined on agent`);
34
+ return def;
35
+ }
36
+
37
+ async function run(name: string, args: Record<string, unknown>, ctx: ToolContext) {
38
+ return await getTool(name).execute(args, ctx);
39
+ }
40
+
41
+ const margherita: Omit<Pizza, "id"> = {
42
+ size: "medium",
43
+ crust: "regular",
44
+ toppings: [],
45
+ quantity: 1,
46
+ };
47
+
48
+ // ─── 1. Pure pricing ─────────────────────────────────────────────────────────
49
+
50
+ describe("pricing (shared.ts)", () => {
51
+ test("base price + crust upcharge + toppings, times quantity", () => {
52
+ const p: Pizza = {
53
+ id: 1,
54
+ size: "large", // 14.99
55
+ crust: "stuffed", // +2.00
56
+ toppings: ["pepperoni", "mushrooms"], // 1.50 + 1.00
57
+ quantity: 2,
58
+ };
59
+ expect(pizzaPrice(p)).toBeCloseTo((14.99 + 2.0 + 1.5 + 1.0) * 2, 5);
60
+ });
61
+
62
+ test("cheese-only pizza costs base + crust", () => {
63
+ expect(pizzaPrice({ id: 1, ...margherita })).toBeCloseTo(11.99, 5);
64
+ });
65
+
66
+ test("unknown topping falls back to the $1.00 default", () => {
67
+ const known = pizzaPrice({ id: 1, ...margherita, toppings: ["onions"] }); // $1.00 topping
68
+ const unknown = pizzaPrice({ id: 1, ...margherita, toppings: ["dragonfruit"] });
69
+ expect(unknown).toBeCloseTo(known, 5);
70
+ });
71
+
72
+ test("calculateTotal sums all pizzas (empty order = 0)", () => {
73
+ expect(calculateTotal([])).toBe(0);
74
+ const a: Pizza = { id: 1, size: "small", crust: "thin", toppings: [], quantity: 1 };
75
+ const b: Pizza = { id: 2, size: "small", crust: "thin", toppings: [], quantity: 3 };
76
+ expect(calculateTotal([a, b])).toBeCloseTo(8.99 * 4, 5);
77
+ });
78
+
79
+ test("add_pizza schema defaults quantity to 1 and rejects non-positive quantities", () => {
80
+ const schema = getTool("add_pizza").parameters;
81
+ if (!schema) throw new Error("add_pizza has no parameters schema");
82
+ const parsed = schema.parse({ size: "small", crust: "thin", toppings: [] });
83
+ expect(parsed.quantity).toBe(1);
84
+ expect(() =>
85
+ schema.parse({ size: "small", crust: "thin", toppings: [], quantity: 0 }),
86
+ ).toThrow();
87
+ expect(() =>
88
+ schema.parse({ size: "small", crust: "thin", toppings: [], quantity: 1.5 }),
89
+ ).toThrow();
90
+ });
91
+ });
92
+
93
+ // ─── 2. Tool flow round-trip ─────────────────────────────────────────────────
94
+
95
+ describe("tool flow (add → update → remove → place_order)", () => {
96
+ test("full ordering round-trip keeps state, totals, and IDs consistent", async () => {
97
+ const { ctx } = makeCtx();
98
+
99
+ // Empty order guards
100
+ expect(await run("view_order", {}, ctx)).toEqual({ message: "The order is empty." });
101
+ expect(await run("place_order", {}, ctx)).toEqual({ error: "Cannot place an empty order." });
102
+
103
+ // Add two pizzas — IDs increment
104
+ const first = (await run(
105
+ "add_pizza",
106
+ { size: "large", crust: "thin", toppings: ["pepperoni"], quantity: 1 },
107
+ ctx,
108
+ )) as { added: Pizza; orderTotal: string; itemCount: number };
109
+ expect(first.added.id).toBe(1);
110
+ expect(first.itemCount).toBe(1);
111
+ expect(first.orderTotal).toBe(`$${(14.99 + 1.5).toFixed(2)}`);
112
+
113
+ const second = (await run(
114
+ "add_pizza",
115
+ { size: "small", crust: "stuffed", toppings: [], quantity: 2 },
116
+ ctx,
117
+ )) as { added: Pizza; itemCount: number };
118
+ expect(second.added.id).toBe(2);
119
+ expect(second.itemCount).toBe(2);
120
+
121
+ // Update only the provided fields
122
+ const updated = (await run("update_pizza", { pizza_id: 2, quantity: 1 }, ctx)) as {
123
+ updated: Pizza;
124
+ };
125
+ expect(updated.updated).toMatchObject({ id: 2, size: "small", crust: "stuffed", quantity: 1 });
126
+ expect(await run("update_pizza", { pizza_id: 99, quantity: 1 }, ctx)).toEqual({
127
+ error: "Pizza not found in the order.",
128
+ });
129
+
130
+ // Remove the first pizza
131
+ const removed = (await run("remove_pizza", { pizza_id: 1 }, ctx)) as {
132
+ removed: Pizza;
133
+ itemCount: number;
134
+ };
135
+ expect(removed.removed.id).toBe(1);
136
+ expect(removed.itemCount).toBe(1);
137
+ expect(await run("remove_pizza", { pizza_id: 1 }, ctx)).toEqual({
138
+ error: "Pizza not found in the order.",
139
+ });
140
+
141
+ // Name + place the order
142
+ await run("set_customer_name", { name: "Alex" }, ctx);
143
+ const placed = (await run("place_order", {}, ctx)) as {
144
+ orderNumber: number;
145
+ customerName: string;
146
+ pizzas: number;
147
+ total: string;
148
+ };
149
+ expect(placed.customerName).toBe("Alex");
150
+ expect(placed.pizzas).toBe(1);
151
+ expect(placed.total).toBe(`$${(8.99 + 2.0).toFixed(2)}`);
152
+
153
+ // The cart is cleared after placing — a follow-up order starts fresh.
154
+ expect(await run("view_order", {}, ctx)).toEqual({ message: "The order is empty." });
155
+ expect(await run("place_order", {}, ctx)).toEqual({ error: "Cannot place an empty order." });
156
+ });
157
+
158
+ test("carts are scoped per session — two sessions never share pizzas or names", async () => {
159
+ // ctx.state is per-session by construction — each session has its own.
160
+ const { ctx: sessionA } = makeCtx("session-a");
161
+ const { ctx: sessionB } = makeCtx("session-b");
162
+
163
+ await run(
164
+ "add_pizza",
165
+ { size: "large", crust: "thin", toppings: ["bacon"], quantity: 1 },
166
+ sessionA,
167
+ );
168
+ await run("set_customer_name", { name: "Alice" }, sessionA);
169
+
170
+ expect(await run("view_order", {}, sessionB)).toEqual({ message: "The order is empty." });
171
+
172
+ await run("add_pizza", { size: "small", crust: "thin", toppings: [], quantity: 1 }, sessionB);
173
+ const placedB = (await run("place_order", {}, sessionB)) as {
174
+ customerName: string;
175
+ pizzas: number;
176
+ };
177
+ // Session B never sees session A's customer name or pizzas.
178
+ expect(placedB.customerName).toBe("Guest");
179
+ expect(placedB.pizzas).toBe(1);
180
+
181
+ // Session A's cart survives session B's checkout untouched.
182
+ const viewA = (await run("view_order", {}, sessionA)) as { pizzas: unknown[] };
183
+ expect(viewA.pizzas).toHaveLength(1);
184
+ });
185
+ });
186
+
187
+ // ─── 3. The projection contract with client.tsx ─────────────────────────────
188
+ //
189
+ // `syncState: orderView` is now the ONLY thing the sidebar reads, which makes
190
+ // the contract a pure function of state rather than an if/else chain over
191
+ // event shapes. What used to need six event-shape assertions is three.
192
+
193
+ describe("orderView projection", () => {
194
+ test("reflects the live cart", async () => {
195
+ const { ctx } = makeCtx();
196
+ await run(
197
+ "add_pizza",
198
+ { size: "large", crust: "stuffed", toppings: ["pepperoni", "extra_cheese"], quantity: 2 },
199
+ ctx,
200
+ );
201
+
202
+ const view = orderView(ctx.state as StateSlot);
203
+ expect(view.orderPlaced).toBe(false);
204
+ const item = view.pizzas[0];
205
+ if (!item) throw new Error("no pizza in the projection");
206
+ // The client renders these fields directly and calls pizzaPrice on them.
207
+ expect(item).toMatchObject({
208
+ id: 1,
209
+ size: "large",
210
+ crust: "stuffed",
211
+ toppings: ["pepperoni", "extra_cheese"],
212
+ quantity: 2,
213
+ });
214
+ expect(view.total).toBe(`$${pizzaPrice(item).toFixed(2)}`);
215
+ });
216
+
217
+ test("survives checkout, which clears the cart but keeps the confirmation", async () => {
218
+ // The reason `placed` lives in state at all: the cart is emptied on
219
+ // checkout, and the UI still has to show the order that was just placed.
220
+ const { ctx } = makeCtx();
221
+ await run("add_pizza", { size: "small", crust: "thin", toppings: [], quantity: 1 }, ctx);
222
+ await run("place_order", {}, ctx);
223
+
224
+ const view = orderView(ctx.state as StateSlot);
225
+ expect(view.orderPlaced).toBe(true);
226
+ expect(view.pizzas).toEqual([]);
227
+ expect(view.estimatedMinutes).toBe(20);
228
+ expect(view.total).toMatch(/^\$\d+\.\d{2}$/);
229
+ });
230
+
231
+ test("an untouched session projects an empty cart, not undefined", async () => {
232
+ // The client renders before any tool has run; `state.order` is absent.
233
+ expect(orderView({})).toMatchObject({ pizzas: [], total: "$0.00", orderPlaced: false });
234
+ });
235
+ });
@@ -0,0 +1,174 @@
1
+ import { agent, assemblyAIPipeline, tool } from "@alexkroman1/aai";
2
+ import { z } from "zod";
3
+ import {
4
+ CRUSTS,
5
+ calculateTotal,
6
+ formatPrice,
7
+ getOrder,
8
+ menuText,
9
+ orderView,
10
+ type Pizza,
11
+ resetOrder,
12
+ SIZES,
13
+ } from "./shared.ts";
14
+ import systemPrompt from "./system-prompt.md?raw";
15
+
16
+ const sizes = z.enum(SIZES);
17
+ const crusts = z.enum(CRUSTS);
18
+
19
+ // The in-progress order lives in `ctx.state` (see shared.ts) — per-session by
20
+ // construction, so concurrent customers each get their own cart.
21
+
22
+ export default agent({
23
+ name: "Pizza Palace",
24
+ ...assemblyAIPipeline(),
25
+ // The cart, pushed to the client after every tool call. Replaces a
26
+ // `ctx.send("order", ...)` in each of the five order tools, and the
27
+ // event-diffing the client had to do to rebuild the cart from them.
28
+ syncState: orderView,
29
+ // The menu section is generated from MENU so the prompt can never quote a
30
+ // price the pricing code doesn't charge.
31
+ systemPrompt: `${systemPrompt}\n${menuText()}`,
32
+ greeting:
33
+ "Welcome to Pizza Palace. I can help you build your perfect pizza. What would you like to order?",
34
+
35
+ tools: {
36
+ add_pizza: tool({
37
+ description: "Add a pizza to the order. Use when the customer has decided on a pizza.",
38
+ parameters: z.object({
39
+ size: sizes,
40
+ crust: crusts,
41
+ toppings: z
42
+ .array(z.string())
43
+ .describe("List of topping names, e.g. ['pepperoni', 'mushrooms']"),
44
+ quantity: z.number().int().min(1).default(1),
45
+ }),
46
+ async execute(args, ctx) {
47
+ const order = getOrder(ctx);
48
+
49
+ const pizza: Pizza = {
50
+ id: order.nextId,
51
+ size: args.size,
52
+ crust: args.crust,
53
+ toppings: args.toppings,
54
+ quantity: args.quantity,
55
+ };
56
+ order.pizzas.push(pizza);
57
+ order.nextId++;
58
+
59
+ return {
60
+ added: pizza,
61
+ orderTotal: formatPrice(calculateTotal(order.pizzas)),
62
+ itemCount: order.pizzas.length,
63
+ };
64
+ },
65
+ }),
66
+
67
+ place_order: tool({
68
+ description:
69
+ "Place the final order. Use when the customer confirms they are done and ready to order.",
70
+ async execute(_args, ctx) {
71
+ const order = getOrder(ctx);
72
+ const pizzas = order.pizzas;
73
+ if (pizzas.length === 0) return { error: "Cannot place an empty order." };
74
+
75
+ const customerName = order.customerName ?? "Guest";
76
+ const total = formatPrice(calculateTotal(pizzas));
77
+ const orderNumber = Math.floor(1000 + Math.random() * 9000);
78
+
79
+ const estimatedMinutes = 15 + pizzas.length * 5;
80
+ // The order is submitted — clear the cart so a follow-up order starts
81
+ // fresh, but keep the confirmation in state so the UI can show it.
82
+ resetOrder(ctx, { orderNumber, total, estimatedMinutes });
83
+
84
+ return {
85
+ orderNumber,
86
+ customerName,
87
+ pizzas: pizzas.length,
88
+ total,
89
+ estimatedMinutes,
90
+ };
91
+ },
92
+ }),
93
+
94
+ remove_pizza: tool({
95
+ description: "Remove a pizza from the order by its ID.",
96
+ parameters: z.object({
97
+ pizza_id: z.number().describe("The pizza ID to remove"),
98
+ }),
99
+ async execute(args, ctx) {
100
+ const order = getOrder(ctx);
101
+ const idx = order.pizzas.findIndex((p) => p.id === args.pizza_id);
102
+ if (idx === -1) return { error: "Pizza not found in the order." };
103
+
104
+ const [removed] = order.pizzas.splice(idx, 1);
105
+
106
+ return {
107
+ removed,
108
+ orderTotal: formatPrice(calculateTotal(order.pizzas)),
109
+ itemCount: order.pizzas.length,
110
+ };
111
+ },
112
+ }),
113
+
114
+ set_customer_name: tool({
115
+ description: "Set the customer name for the order.",
116
+ parameters: z.object({
117
+ name: z.string(),
118
+ }),
119
+ async execute(args, ctx) {
120
+ getOrder(ctx).customerName = args.name;
121
+ return { name: args.name };
122
+ },
123
+ }),
124
+
125
+ update_pizza: tool({
126
+ description: "Update an existing pizza in the order. Only provided fields are changed.",
127
+ parameters: z.object({
128
+ pizza_id: z.number(),
129
+ size: sizes.optional(),
130
+ crust: crusts.optional(),
131
+ toppings: z.array(z.string()).optional(),
132
+ quantity: z.number().int().min(1).optional(),
133
+ }),
134
+ async execute(args, ctx) {
135
+ const order = getOrder(ctx);
136
+ const idx = order.pizzas.findIndex((p) => p.id === args.pizza_id);
137
+ if (idx === -1) return { error: "Pizza not found in the order." };
138
+
139
+ const pizza: Pizza = { ...order.pizzas[idx]! };
140
+ if (args.size !== undefined) pizza.size = args.size;
141
+ if (args.crust !== undefined) pizza.crust = args.crust;
142
+ if (args.toppings !== undefined) pizza.toppings = args.toppings;
143
+ if (args.quantity !== undefined) pizza.quantity = args.quantity;
144
+
145
+ order.pizzas[idx] = pizza;
146
+
147
+ return {
148
+ updated: pizza,
149
+ orderTotal: formatPrice(calculateTotal(order.pizzas)),
150
+ };
151
+ },
152
+ }),
153
+
154
+ view_order: tool({
155
+ description: "View the current order summary with all pizzas and total price.",
156
+ async execute(_args, ctx) {
157
+ const pizzas = getOrder(ctx).pizzas;
158
+ if (pizzas.length === 0) return { message: "The order is empty." };
159
+
160
+ return {
161
+ pizzas: pizzas.map((p) => ({
162
+ id: p.id,
163
+ description: `${p.quantity}x ${p.size} ${p.crust} crust with ${p.toppings.length > 0 ? p.toppings.join(", ") : "cheese only"}`,
164
+ size: p.size,
165
+ crust: p.crust,
166
+ toppings: p.toppings,
167
+ quantity: p.quantity,
168
+ })),
169
+ orderTotal: formatPrice(calculateTotal(pizzas)),
170
+ };
171
+ },
172
+ }),
173
+ },
174
+ });
@@ -0,0 +1,120 @@
1
+ import "@alexkroman1/aai-ui/styles.css";
2
+ import { client, useAgentState, useTheme } from "@alexkroman1/aai-ui";
3
+ import type { OrderView } from "./shared.ts";
4
+ import { formatPrice, orderView, pizzaPrice } from "./shared.ts";
5
+
6
+ // Derived from the projection so a new OrderView field can't silently miss
7
+ // the pre-first-tool-call render.
8
+ const EMPTY_ORDER = orderView({});
9
+
10
+ function PizzaIcon({ size }: { size: string }) {
11
+ const dim = size === "small" ? 36 : size === "large" ? 52 : 44;
12
+ return (
13
+ <svg width={dim} height={dim} viewBox="0 0 100 100" className="shrink-0">
14
+ <circle cx="50" cy="50" r="48" fill="#F4C542" stroke="#D4A017" strokeWidth="3" />
15
+ <circle cx="50" cy="50" r="42" fill="#E8A025" />
16
+ <circle cx="35" cy="35" r="7" fill="#C0392B" opacity="0.9" />
17
+ <circle cx="60" cy="30" r="6" fill="#C0392B" opacity="0.9" />
18
+ <circle cx="55" cy="55" r="7" fill="#C0392B" opacity="0.9" />
19
+ <circle cx="30" cy="58" r="6" fill="#C0392B" opacity="0.9" />
20
+ <circle cx="65" cy="65" r="5" fill="#C0392B" opacity="0.9" />
21
+ <circle cx="45" cy="68" r="4" fill="#27AE60" opacity="0.7" />
22
+ <circle cx="70" cy="42" r="4" fill="#27AE60" opacity="0.7" />
23
+ </svg>
24
+ );
25
+ }
26
+
27
+ function OrderSidebar() {
28
+ const theme = useTheme();
29
+ // The agent's own cart, projected by `syncState` and pushed after every
30
+ // tool call. This replaced ~45 lines that rebuilt the cart by diffing
31
+ // added/removed/updated events — where one missed event desynced the view
32
+ // for the rest of the session.
33
+ const order = useAgentState<OrderView>() ?? EMPTY_ORDER;
34
+
35
+ if (order.orderPlaced) {
36
+ return (
37
+ <div
38
+ className="flex flex-col items-center gap-4 p-6 text-center"
39
+ style={{ color: theme.text }}
40
+ >
41
+ <div className="text-5xl">&#10003;</div>
42
+ <h2 className="text-lg font-bold">Order Placed</h2>
43
+ {order.orderNumber && <p className="opacity-70">Order #{order.orderNumber}</p>}
44
+ <p className="font-bold text-xl" style={{ color: theme.primary }}>
45
+ {order.total}
46
+ </p>
47
+ {order.estimatedMinutes && (
48
+ <p className="opacity-60 text-sm">Ready in ~{order.estimatedMinutes} minutes</p>
49
+ )}
50
+ </div>
51
+ );
52
+ }
53
+
54
+ if (order.pizzas.length === 0) {
55
+ return (
56
+ <div className="flex flex-col items-center gap-3 p-6 text-center opacity-50">
57
+ <PizzaIcon size="large" />
58
+ <p className="text-sm" style={{ color: theme.text }}>
59
+ Your order is empty. Tell me what you'd like.
60
+ </p>
61
+ </div>
62
+ );
63
+ }
64
+
65
+ return (
66
+ <div className="flex flex-col gap-3 p-4" style={{ color: theme.text }}>
67
+ <h3 className="text-sm font-bold opacity-60 uppercase tracking-wide">Your Order</h3>
68
+ {order.pizzas.map((p) => (
69
+ <div
70
+ key={p.id}
71
+ className="flex items-center gap-3 p-3 rounded-lg"
72
+ style={{ background: theme.surface }}
73
+ >
74
+ <PizzaIcon size={p.size} />
75
+ <div className="flex-1 min-w-0">
76
+ <p className="text-sm font-medium">
77
+ {p.quantity > 1 ? `${p.quantity}x ` : ""}
78
+ {p.size.charAt(0).toUpperCase() + p.size.slice(1)} {p.crust} crust
79
+ </p>
80
+ <p className="opacity-60 text-xs truncate">
81
+ {p.toppings.length > 0
82
+ ? p.toppings.map((t) => t.replace("_", " ")).join(", ")
83
+ : "cheese only"}
84
+ </p>
85
+ </div>
86
+ <p className="text-sm font-bold whitespace-nowrap" style={{ color: theme.primary }}>
87
+ {formatPrice(pizzaPrice(p))}
88
+ </p>
89
+ </div>
90
+ ))}
91
+ <div
92
+ className="flex justify-between items-center pt-3 mt-1 border-t"
93
+ style={{ borderColor: theme.border }}
94
+ >
95
+ <span className="font-bold">Total</span>
96
+ <span className="font-bold text-lg" style={{ color: theme.primary }}>
97
+ {order.total}
98
+ </span>
99
+ </div>
100
+ </div>
101
+ );
102
+ }
103
+
104
+ client({
105
+ name: "Pizza Palace",
106
+ sidebar: OrderSidebar,
107
+ theme: {
108
+ bg: "#1a1008",
109
+ primary: "#E8A025",
110
+ text: "#f5f0e8",
111
+ surface: "#2a1f10",
112
+ border: "#3d2e18",
113
+ },
114
+ tools: {
115
+ add_pizza: { icon: "\u{1F355}", label: "Adding pizza" },
116
+ remove_pizza: { icon: "\u{1F5D1}", label: "Removing pizza" },
117
+ update_pizza: { icon: "\u{270F}", label: "Updating pizza" },
118
+ place_order: { icon: "\u{2705}", label: "Placing order" },
119
+ },
120
+ });
@@ -0,0 +1,142 @@
1
+ import type { ToolContext } from "@alexkroman1/aai";
2
+
3
+ export const SIZES = ["small", "medium", "large"] as const;
4
+ export const CRUSTS = ["thin", "regular", "thick", "stuffed"] as const;
5
+
6
+ export interface Pizza {
7
+ id: number;
8
+ size: (typeof SIZES)[number];
9
+ crust: (typeof CRUSTS)[number];
10
+ toppings: string[];
11
+ quantity: number;
12
+ }
13
+
14
+ export const MENU = {
15
+ sizes: { small: 8.99, medium: 11.99, large: 14.99 },
16
+ crusts: { thin: 0, regular: 0, thick: 1.0, stuffed: 2.0 },
17
+ toppings: {
18
+ pepperoni: 1.5,
19
+ sausage: 1.5,
20
+ mushrooms: 1.0,
21
+ onions: 1.0,
22
+ green_peppers: 1.0,
23
+ black_olives: 1.0,
24
+ bacon: 2.0,
25
+ ham: 1.5,
26
+ pineapple: 1.0,
27
+ jalapenos: 1.0,
28
+ extra_cheese: 1.5,
29
+ spinach: 1.0,
30
+ tomatoes: 1.0,
31
+ anchovies: 1.5,
32
+ chicken: 2.0,
33
+ },
34
+ } as const;
35
+
36
+ export function calculateTotal(pizzas: Pizza[]): number {
37
+ return pizzas.reduce((total, pizza) => total + pizzaPrice(pizza), 0);
38
+ }
39
+
40
+ /** The one money format. Tool results, the projection, and the sidebar all
41
+ * show prices through this, so they can never disagree on rounding. */
42
+ export function formatPrice(amount: number): string {
43
+ return `$${amount.toFixed(2)}`;
44
+ }
45
+
46
+ /**
47
+ * The menu as prompt prose, generated from `MENU` so the agent can never
48
+ * quote a price the pricing code doesn't charge.
49
+ */
50
+ export function menuText(): string {
51
+ const price = (amount: number, upcharge = false) =>
52
+ amount === 0 ? "free" : `${upcharge ? "+" : ""}${formatPrice(amount)}`;
53
+ const list = (items: Record<string, number>, upcharge = false) =>
54
+ Object.entries(items)
55
+ .map(([name, amount]) => `${name.replaceAll("_", " ")} (${price(amount, upcharge)})`)
56
+ .join(", ");
57
+ return [
58
+ "Menu info:",
59
+ `- Sizes: ${list(MENU.sizes)}`,
60
+ `- Crusts: ${list(MENU.crusts, true)}`,
61
+ `- Toppings: ${list(MENU.toppings)}`,
62
+ ].join("\n");
63
+ }
64
+
65
+ export function pizzaPrice(p: Pizza): number {
66
+ const base = MENU.sizes[p.size];
67
+ const crust = MENU.crusts[p.crust];
68
+ const toppings = p.toppings.reduce(
69
+ (sum, t) => sum + (MENU.toppings[t as keyof typeof MENU.toppings] ?? 1.0),
70
+ 0,
71
+ );
72
+ return (base + crust + toppings) * p.quantity;
73
+ }
74
+
75
+ // ── Order state ──────────────────────────────────────────────────────────────
76
+ // The in-progress order is session-scoped scratch, so it lives in `ctx.state`
77
+ // (the agent's per-session mutable state) — concurrent customers each get
78
+ // their own cart, and an abandoned cart vanishes with its session.
79
+
80
+ export interface OrderState {
81
+ pizzas: Pizza[];
82
+ nextId: number;
83
+ customerName: string | null;
84
+ /**
85
+ * The confirmation, once checkout happens. In state rather than only in
86
+ * `place_order`'s return value because the UI has to keep showing it after
87
+ * the cart is cleared — and `syncState` can only project what state holds.
88
+ */
89
+ placed?: { orderNumber: number; total: string; estimatedMinutes: number };
90
+ }
91
+
92
+ export type StateSlot = { order?: OrderState };
93
+
94
+ function emptyOrder(): OrderState {
95
+ return { pizzas: [], nextId: 1, customerName: null };
96
+ }
97
+
98
+ /** The session's live cart. Mutations to the returned object stick — it is
99
+ * the object stored in `ctx.state`. */
100
+ export function getOrder(ctx: ToolContext): OrderState {
101
+ const slot = ctx.state as StateSlot;
102
+ slot.order ??= emptyOrder();
103
+ return slot.order;
104
+ }
105
+
106
+ /**
107
+ * Clear the cart after checkout so a follow-up order starts fresh. The
108
+ * `placed` confirmation is passed through, because the UI is still showing
109
+ * it — clearing the pizzas is what "reset" means here, not forgetting the
110
+ * order that was just submitted.
111
+ */
112
+ export function resetOrder(ctx: ToolContext, placed?: OrderState["placed"]): void {
113
+ const slot = ctx.state as StateSlot;
114
+ slot.order = { ...emptyOrder(), ...(placed ? { placed } : {}) };
115
+ }
116
+
117
+ /**
118
+ * What the browser sees. A projection rather than the raw slot: the client
119
+ * needs the cart and its total, and `syncState` is where you decide what
120
+ * leaves the server.
121
+ */
122
+ export interface OrderView {
123
+ pizzas: Pizza[];
124
+ total: string;
125
+ orderPlaced: boolean;
126
+ orderNumber?: number;
127
+ estimatedMinutes?: number;
128
+ }
129
+
130
+ export function orderView(state: StateSlot): OrderView {
131
+ const order = state.order;
132
+ const pizzas = order?.pizzas ?? [];
133
+ const placed = order?.placed;
134
+ return {
135
+ pizzas,
136
+ total: placed?.total ?? formatPrice(calculateTotal(pizzas)),
137
+ orderPlaced: Boolean(placed),
138
+ ...(placed
139
+ ? { orderNumber: placed.orderNumber, estimatedMinutes: placed.estimatedMinutes }
140
+ : {}),
141
+ };
142
+ }