@alexkroman1/aai-cli 5.13.2 → 5.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/scaffold/package.json +3 -3
  2. package/dist/scaffold/pnpm-workspace.yaml +24 -0
  3. package/dist/templates/dispatch-center/agent.test.ts +10 -17
  4. package/dist/templates/dispatch-center/agent.ts +2 -2
  5. package/dist/templates/dispatch-center/client.tsx +9 -13
  6. package/dist/templates/dispatch-center/shared.ts +36 -63
  7. package/dist/templates/dispatch-center/tools/incident_add_note.ts +4 -4
  8. package/dist/templates/dispatch-center/tools/incident_create.ts +2 -2
  9. package/dist/templates/dispatch-center/tools/incident_escalate.ts +4 -4
  10. package/dist/templates/dispatch-center/tools/incident_get.ts +9 -4
  11. package/dist/templates/dispatch-center/tools/incident_triage.ts +4 -4
  12. package/dist/templates/dispatch-center/tools/incident_update_status.ts +4 -4
  13. package/dist/templates/dispatch-center/tools/ops_dashboard.ts +2 -2
  14. package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +2 -2
  15. package/dist/templates/dispatch-center/tools/resources_dispatch.ts +4 -4
  16. package/dist/templates/dispatch-center/tools/resources_get_available.ts +2 -2
  17. package/dist/templates/dispatch-center/tools/resources_update_status.ts +2 -2
  18. package/dist/templates/infocom-adventure/agent.ts +10 -10
  19. package/dist/templates/infocom-adventure/client.tsx +5 -17
  20. package/dist/templates/infocom-adventure/shared.ts +17 -14
  21. package/dist/templates/pizza-ordering/agent.test.ts +27 -31
  22. package/dist/templates/pizza-ordering/agent.ts +8 -8
  23. package/dist/templates/pizza-ordering/client.tsx +4 -4
  24. package/dist/templates/pizza-ordering/shared.ts +14 -20
  25. package/dist/templates/retail/agent.test.ts +137 -138
  26. package/dist/templates/retail/agent.ts +7 -10
  27. package/dist/templates/retail/authenticate.ts +2 -2
  28. package/dist/templates/retail/client.tsx +13 -14
  29. package/dist/templates/retail/registry.test.ts +9 -9
  30. package/dist/templates/retail/resolve.test.ts +17 -16
  31. package/dist/templates/retail/resolve.ts +5 -4
  32. package/dist/templates/retail/shared.test.ts +17 -16
  33. package/dist/templates/retail/shared.ts +39 -18
  34. package/dist/templates/retail/store.test.ts +40 -47
  35. package/dist/templates/retail/store.ts +39 -65
  36. package/dist/templates/retail/swap.test.ts +20 -17
  37. package/dist/templates/retail/swap.ts +7 -14
  38. package/dist/templates/retail/tools/cancel_pending_order.ts +5 -4
  39. package/dist/templates/retail/tools/exchange_delivered_order_items.ts +6 -5
  40. package/dist/templates/retail/tools/find_user_id_by_email.ts +2 -2
  41. package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +2 -2
  42. package/dist/templates/retail/tools/get_item_details.ts +4 -3
  43. package/dist/templates/retail/tools/get_order_details.ts +4 -3
  44. package/dist/templates/retail/tools/get_product_details.ts +4 -3
  45. package/dist/templates/retail/tools/get_user_details.ts +4 -3
  46. package/dist/templates/retail/tools/list_all_product_types.ts +2 -2
  47. package/dist/templates/retail/tools/modify_pending_order_address.ts +4 -3
  48. package/dist/templates/retail/tools/modify_pending_order_items.ts +6 -6
  49. package/dist/templates/retail/tools/modify_pending_order_payment.ts +6 -6
  50. package/dist/templates/retail/tools/modify_user_address.ts +4 -3
  51. package/dist/templates/retail/tools/return_delivered_order_items.ts +6 -6
  52. package/dist/templates/solo-rpg/agent.test.ts +26 -34
  53. package/dist/templates/solo-rpg/agent.ts +2 -2
  54. package/dist/templates/solo-rpg/client.tsx +1 -1
  55. package/dist/templates/solo-rpg/shared.ts +6 -14
  56. package/dist/templates/solo-rpg/tools/action_roll.ts +2 -2
  57. package/dist/templates/solo-rpg/tools/burn_momentum.ts +2 -2
  58. package/dist/templates/solo-rpg/tools/check_state.ts +2 -2
  59. package/dist/templates/solo-rpg/tools/load_game.ts +2 -2
  60. package/dist/templates/solo-rpg/tools/oracle.ts +3 -3
  61. package/dist/templates/solo-rpg/tools/save_game.ts +2 -2
  62. package/dist/templates/solo-rpg/tools/setup_character.ts +2 -2
  63. package/dist/templates/solo-rpg/tools/update_state.ts +12 -11
  64. package/package.json +3 -3
@@ -1,6 +1,6 @@
1
1
  import { agent, tool } from "@alexkroman1/aai";
2
2
  import { z } from "zod";
3
- import { getGameState, resetGameState } from "./shared.ts";
3
+ import { gameSlot, recordCommand } from "./shared.ts";
4
4
  import systemPrompt from "./system-prompt.md?raw";
5
5
 
6
6
  export default agent({
@@ -21,7 +21,7 @@ export default agent({
21
21
  value: z.string().describe("Item name to drop"),
22
22
  }),
23
23
  async execute(args, ctx) {
24
- const g = getGameState(ctx);
24
+ const g = gameSlot.get(ctx);
25
25
  g.inventory = g.inventory.filter((i) => i !== args.value);
26
26
  return { inventory: g.inventory };
27
27
  },
@@ -33,7 +33,7 @@ export default agent({
33
33
  value: z.string().describe("Flag name to set"),
34
34
  }),
35
35
  async execute(args, ctx) {
36
- const g = getGameState(ctx);
36
+ const g = gameSlot.get(ctx);
37
37
  g.flags[args.value] = true;
38
38
  return { flags: g.flags };
39
39
  },
@@ -43,7 +43,7 @@ export default agent({
43
43
  description:
44
44
  "Read the current game state including inventory, current room, score, moves, flags, and recent history.",
45
45
  async execute(_args, ctx) {
46
- const g = getGameState(ctx);
46
+ const g = gameSlot.get(ctx);
47
47
  return {
48
48
  currentRoom: g.currentRoom,
49
49
  inventory: g.inventory,
@@ -61,8 +61,8 @@ export default agent({
61
61
  value: z.string().describe("Command text to log"),
62
62
  }),
63
63
  async execute(args, ctx) {
64
- const g = getGameState(ctx);
65
- g.history.push(args.value);
64
+ const g = gameSlot.get(ctx);
65
+ recordCommand(g, args.value);
66
66
  g.moves++;
67
67
  return { moves: g.moves, recentHistory: g.history.slice(-5) };
68
68
  },
@@ -74,7 +74,7 @@ export default agent({
74
74
  value: z.string().describe("Room name to move to"),
75
75
  }),
76
76
  async execute(args, ctx) {
77
- const g = getGameState(ctx);
77
+ const g = gameSlot.get(ctx);
78
78
  g.currentRoom = args.value;
79
79
  g.moves++;
80
80
  return { currentRoom: g.currentRoom, moves: g.moves };
@@ -85,7 +85,7 @@ export default agent({
85
85
  description:
86
86
  "Reset the game to the beginning: empty inventory, zero score and moves, all flags cleared. Use when the player asks to restart, quit, or start a new game.",
87
87
  async execute(_args, ctx) {
88
- const g = resetGameState(ctx);
88
+ const g = gameSlot.reset(ctx);
89
89
  return { restarted: true, currentRoom: g.currentRoom };
90
90
  },
91
91
  }),
@@ -96,7 +96,7 @@ export default agent({
96
96
  value: z.number().describe("Points to add"),
97
97
  }),
98
98
  async execute(args, ctx) {
99
- const g = getGameState(ctx);
99
+ const g = gameSlot.get(ctx);
100
100
  g.score += args.value;
101
101
  return { score: g.score };
102
102
  },
@@ -108,7 +108,7 @@ export default agent({
108
108
  value: z.string().describe("Item name to take"),
109
109
  }),
110
110
  async execute(args, ctx) {
111
- const g = getGameState(ctx);
111
+ const g = gameSlot.get(ctx);
112
112
  if (!g.inventory.includes(args.value)) g.inventory.push(args.value);
113
113
  return { inventory: g.inventory };
114
114
  },
@@ -1,7 +1,6 @@
1
1
  import "@alexkroman1/aai-ui/styles.css";
2
2
  import type { ChatMessage } from "@alexkroman1/aai-ui";
3
- import { client, useSession } from "@alexkroman1/aai-ui";
4
- import { useEffect, useRef } from "react";
3
+ import { AutoScroll, client, useSession } from "@alexkroman1/aai-ui";
5
4
 
6
5
  const CSS = `
7
6
  @keyframes ic-flicker {
@@ -55,17 +54,6 @@ const CYAN = "#00ccff";
55
54
 
56
55
  function InfocomAdventure() {
57
56
  const session = useSession();
58
- const bottom = useRef<HTMLDivElement>(null);
59
-
60
- // Bumps whenever a message lands or the live transcript changes, so the
61
- // view follows the conversation instead of scrolling once on mount.
62
- const contentVersion = session.messages.length + (session.userTranscript?.length ?? 0);
63
- useEffect(() => {
64
- // Never true — contentVersion only drives the dependency array, and this
65
- // reference keeps the exhaustive-deps lint satisfied.
66
- if (contentVersion < 0) return;
67
- bottom.current?.scrollIntoView({ behavior: "smooth" });
68
- }, [contentVersion]);
69
57
 
70
58
  const stateLabel =
71
59
  session.state === "listening"
@@ -184,8 +172,9 @@ function InfocomAdventure() {
184
172
  )}
185
173
 
186
174
  {/* Messages */}
187
- <div
188
- className="ic-messages flex-1 overflow-y-auto p-5"
175
+ <AutoScroll
176
+ scrollClassName="ic-messages overflow-y-auto"
177
+ contentClassName="p-5"
189
178
  style={{ scrollbarWidth: "thin", scrollbarColor: `${GREEN} #001a00` }}
190
179
  >
191
180
  {session.messages.map((msg: ChatMessage, i: number) => (
@@ -211,8 +200,7 @@ function InfocomAdventure() {
211
200
  {session.userTranscript === "" ? "..." : session.userTranscript}
212
201
  </div>
213
202
  )}
214
- <div ref={bottom} />
215
- </div>
203
+ </AutoScroll>
216
204
 
217
205
  {/* Footer controls */}
218
206
  <div
@@ -1,4 +1,4 @@
1
- import type { ToolContext } from "@alexkroman1/aai";
1
+ import { pushCapped, type SlotStateOf, sessionSlot } from "@alexkroman1/aai";
2
2
 
3
3
  export type GameState = {
4
4
  inventory: string[];
@@ -18,21 +18,24 @@ export const DEFAULT_GAME_STATE: GameState = {
18
18
  history: [],
19
19
  };
20
20
 
21
+ /**
22
+ * How many player commands the game remembers. The history is append-only and
23
+ * lives in `ctx.state` for the length of the call, so it needs a cap — and
24
+ * only the last few are ever read (`game_state_get` reports five), so the
25
+ * older ones are cost without a reader.
26
+ */
27
+ export const MAX_HISTORY = 50;
28
+
21
29
  // The game lives in `ctx.state`, the agent's per-session mutable state — each
22
30
  // session is its own playthrough, so concurrent players never see each
23
- // other's game and a fresh session starts a fresh adventure.
24
- type StateSlot = { game?: GameState };
31
+ // other's game and a fresh session starts a fresh adventure. The clone is
32
+ // load-bearing: `DEFAULT_GAME_STATE` is one module-level object shared by
33
+ // every session in the process.
34
+ export const gameSlot = sessionSlot("game", () => structuredClone(DEFAULT_GAME_STATE));
25
35
 
26
- /** The session's live game. Mutations to the returned object stick — it is
27
- * the object stored in `ctx.state`. */
28
- export function getGameState(ctx: ToolContext): GameState {
29
- const slot = ctx.state as StateSlot;
30
- slot.game ??= structuredClone(DEFAULT_GAME_STATE);
31
- return slot.game;
32
- }
36
+ export type StateSlot = SlotStateOf<typeof gameSlot>;
33
37
 
34
- export function resetGameState(ctx: ToolContext): GameState {
35
- const slot = ctx.state as StateSlot;
36
- slot.game = structuredClone(DEFAULT_GAME_STATE);
37
- return slot.game;
38
+ /** Log a player command, holding {@link MAX_HISTORY}. */
39
+ export function recordCommand(game: GameState, command: string): void {
40
+ pushCapped(game.history, command, MAX_HISTORY);
38
41
  }
@@ -1,34 +1,25 @@
1
- import type { Db, ToolContext, ToolDef } from "@alexkroman1/aai";
1
+ import type { ToolContext, ToolDef } from "@alexkroman1/aai";
2
+ import { createToolContext } from "@alexkroman1/aai/testing";
2
3
  import { describe, expect, test } from "vitest";
3
4
  import agentDef from "./agent.ts";
4
- import { calculateTotal, orderView, type Pizza, pizzaPrice, type StateSlot } from "./shared.ts";
5
+ import {
6
+ calculateTotal,
7
+ orderSlot,
8
+ orderView,
9
+ type Pizza,
10
+ pizzaPrice,
11
+ type StateSlot,
12
+ } from "./shared.ts";
5
13
 
6
14
  // ─── Test doubles ────────────────────────────────────────────────────────────
7
15
 
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
- // Never aborts — a test has no turn to cancel. `ctx.signal` is always
25
- // present at runtime, so a stub supplies one rather than omitting it.
26
- signal: new AbortController().signal,
27
- send: (event, data) => {
28
- sent.push({ event, data });
29
- },
30
- };
31
- return { ctx, sent };
16
+ /** Each context is one session — the cart is session-scoped by construction,
17
+ * and `createToolContext` mints a distinct session id per call. Its default
18
+ * `db` rejects every query, which is right here: this template keeps its cart
19
+ * in ctx.state and must never touch storage. */
20
+ function makeCtx(sessionId?: string) {
21
+ const ctx = createToolContext<StateSlot>(sessionId ? { sessionId } : {});
22
+ return { ctx, sent: ctx.sent };
32
23
  }
33
24
 
34
25
  function getTool(name: string): ToolDef {
@@ -204,7 +195,7 @@ describe("orderView projection", () => {
204
195
  ctx,
205
196
  );
206
197
 
207
- const view = orderView(ctx.state as StateSlot);
198
+ const view = orderView(orderSlot.read(ctx.state));
208
199
  expect(view.orderPlaced).toBe(false);
209
200
  const item = view.pizzas[0];
210
201
  if (!item) throw new Error("no pizza in the projection");
@@ -226,15 +217,20 @@ describe("orderView projection", () => {
226
217
  await run("add_pizza", { size: "small", crust: "thin", toppings: [], quantity: 1 }, ctx);
227
218
  await run("place_order", {}, ctx);
228
219
 
229
- const view = orderView(ctx.state as StateSlot);
220
+ const view = orderView(orderSlot.read(ctx.state));
230
221
  expect(view.orderPlaced).toBe(true);
231
222
  expect(view.pizzas).toEqual([]);
232
223
  expect(view.estimatedMinutes).toBe(20);
233
224
  expect(view.total).toMatch(/^\$\d+\.\d{2}$/);
234
225
  });
235
226
 
236
- test("an untouched session projects an empty cart, not undefined", async () => {
237
- // The client renders before any tool has run; `state.order` is absent.
238
- expect(orderView({})).toMatchObject({ pizzas: [], total: "$0.00", orderPlaced: false });
227
+ test("an untouched session projects an empty cart, not undefined", () => {
228
+ // The client renders before any tool has run, so `state.order` is absent
229
+ // this is exactly the value `client.tsx` hoists as its fallback.
230
+ expect(orderSlot.projection(orderView)(undefined)).toMatchObject({
231
+ pizzas: [],
232
+ total: "$0.00",
233
+ orderPlaced: false,
234
+ });
239
235
  });
240
236
  });
@@ -4,8 +4,8 @@ import {
4
4
  CRUSTS,
5
5
  calculateTotal,
6
6
  formatPrice,
7
- getOrder,
8
7
  menuText,
8
+ orderSlot,
9
9
  orderView,
10
10
  type Pizza,
11
11
  resetOrder,
@@ -24,7 +24,7 @@ export default agent({
24
24
  // The cart, pushed to the client after every tool call. Replaces a
25
25
  // `ctx.send("order", ...)` in each of the five order tools, and the
26
26
  // event-diffing the client had to do to rebuild the cart from them.
27
- syncState: orderView,
27
+ syncState: orderSlot.projection(orderView),
28
28
  // The menu section is generated from MENU so the prompt can never quote a
29
29
  // price the pricing code doesn't charge.
30
30
  systemPrompt: `${systemPrompt}\n${menuText()}`,
@@ -43,7 +43,7 @@ export default agent({
43
43
  quantity: z.number().int().min(1).default(1),
44
44
  }),
45
45
  async execute(args, ctx) {
46
- const order = getOrder(ctx);
46
+ const order = orderSlot.get(ctx);
47
47
 
48
48
  const pizza: Pizza = {
49
49
  id: order.nextId,
@@ -67,7 +67,7 @@ export default agent({
67
67
  description:
68
68
  "Place the final order. Use when the customer confirms they are done and ready to order.",
69
69
  async execute(_args, ctx) {
70
- const order = getOrder(ctx);
70
+ const order = orderSlot.get(ctx);
71
71
  const pizzas = order.pizzas;
72
72
  if (pizzas.length === 0) return { error: "Cannot place an empty order." };
73
73
 
@@ -96,7 +96,7 @@ export default agent({
96
96
  pizza_id: z.number().describe("The pizza ID to remove"),
97
97
  }),
98
98
  async execute(args, ctx) {
99
- const order = getOrder(ctx);
99
+ const order = orderSlot.get(ctx);
100
100
  const idx = order.pizzas.findIndex((p) => p.id === args.pizza_id);
101
101
  if (idx === -1) return { error: "Pizza not found in the order." };
102
102
 
@@ -116,7 +116,7 @@ export default agent({
116
116
  name: z.string(),
117
117
  }),
118
118
  async execute(args, ctx) {
119
- getOrder(ctx).customerName = args.name;
119
+ orderSlot.get(ctx).customerName = args.name;
120
120
  return { name: args.name };
121
121
  },
122
122
  }),
@@ -131,7 +131,7 @@ export default agent({
131
131
  quantity: z.number().int().min(1).optional(),
132
132
  }),
133
133
  async execute(args, ctx) {
134
- const order = getOrder(ctx);
134
+ const order = orderSlot.get(ctx);
135
135
  const idx = order.pizzas.findIndex((p) => p.id === args.pizza_id);
136
136
  if (idx === -1) return { error: "Pizza not found in the order." };
137
137
 
@@ -153,7 +153,7 @@ export default agent({
153
153
  view_order: tool({
154
154
  description: "View the current order summary with all pizzas and total price.",
155
155
  async execute(_args, ctx) {
156
- const pizzas = getOrder(ctx).pizzas;
156
+ const pizzas = orderSlot.get(ctx).pizzas;
157
157
  if (pizzas.length === 0) return { message: "The order is empty." };
158
158
 
159
159
  return {
@@ -1,11 +1,11 @@
1
1
  import "@alexkroman1/aai-ui/styles.css";
2
2
  import { client, useAgentState, useTheme } from "@alexkroman1/aai-ui";
3
3
  import type { OrderView } from "./shared.ts";
4
- import { formatPrice, orderView, pizzaPrice } from "./shared.ts";
4
+ import { formatPrice, orderSlot, orderView, pizzaPrice } from "./shared.ts";
5
5
 
6
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({});
7
+ // the pre-first-tool-call render — `projection` runs it over an empty cart.
8
+ const EMPTY_ORDER: OrderView = orderSlot.projection(orderView)(undefined);
9
9
 
10
10
  function PizzaIcon({ size }: { size: string }) {
11
11
  const dim = size === "small" ? 36 : size === "large" ? 52 : 44;
@@ -30,7 +30,7 @@ function OrderSidebar() {
30
30
  // tool call. This replaced ~45 lines that rebuilt the cart by diffing
31
31
  // added/removed/updated events — where one missed event desynced the view
32
32
  // for the rest of the session.
33
- const order = useAgentState<OrderView>() ?? EMPTY_ORDER;
33
+ const order = useAgentState<OrderView>(EMPTY_ORDER);
34
34
 
35
35
  if (order.orderPlaced) {
36
36
  return (
@@ -1,4 +1,4 @@
1
- import type { ToolContext } from "@alexkroman1/aai";
1
+ import { type SlotStateOf, sessionSlot, type ToolContext } from "@alexkroman1/aai";
2
2
 
3
3
  export const SIZES = ["small", "medium", "large"] as const;
4
4
  export const CRUSTS = ["thin", "regular", "thick", "stuffed"] as const;
@@ -89,19 +89,14 @@ export interface OrderState {
89
89
  placed?: { orderNumber: number; total: string; estimatedMinutes: number };
90
90
  }
91
91
 
92
- export type StateSlot = { order?: OrderState };
93
-
94
- function emptyOrder(): OrderState {
92
+ export function emptyOrder(): OrderState {
95
93
  return { pizzas: [], nextId: 1, customerName: null };
96
94
  }
97
95
 
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
- }
96
+ /** The session's cart, as one typed slot inside `ctx.state`. */
97
+ export const orderSlot = sessionSlot("order", emptyOrder);
98
+
99
+ export type StateSlot = SlotStateOf<typeof orderSlot>;
105
100
 
106
101
  /**
107
102
  * Clear the cart after checkout so a follow-up order starts fresh. The
@@ -110,12 +105,11 @@ export function getOrder(ctx: ToolContext): OrderState {
110
105
  * order that was just submitted.
111
106
  */
112
107
  export function resetOrder(ctx: ToolContext, placed?: OrderState["placed"]): void {
113
- const slot = ctx.state as StateSlot;
114
- slot.order = { ...emptyOrder(), ...(placed ? { placed } : {}) };
108
+ orderSlot.set(ctx, { ...emptyOrder(), ...(placed ? { placed } : {}) });
115
109
  }
116
110
 
117
111
  /**
118
- * What the browser sees. A projection rather than the raw slot: the client
112
+ * What the browser sees. A projection rather than the raw state: the client
119
113
  * needs the cart and its total, and `syncState` is where you decide what
120
114
  * leaves the server.
121
115
  */
@@ -127,13 +121,13 @@ export interface OrderView {
127
121
  estimatedMinutes?: number;
128
122
  }
129
123
 
130
- export function orderView(state: StateSlot): OrderView {
131
- const order = state.order;
132
- const pizzas = order?.pizzas ?? [];
133
- const placed = order?.placed;
124
+ /** Takes the cart itself, not the slot — `orderSlot.projection` supplies a real
125
+ * one even before the first tool call, so there is nothing to optional-chain. */
126
+ export function orderView(order: OrderState): OrderView {
127
+ const placed = order.placed;
134
128
  return {
135
- pizzas,
136
- total: placed?.total ?? formatPrice(calculateTotal(pizzas)),
129
+ pizzas: order.pizzas,
130
+ total: placed?.total ?? formatPrice(calculateTotal(order.pizzas)),
137
131
  orderPlaced: Boolean(placed),
138
132
  ...(placed
139
133
  ? { orderNumber: placed.orderNumber, estimatedMinutes: placed.estimatedMinutes }