@alexkroman1/aai-cli 7.0.0 → 8.0.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 (122) hide show
  1. package/README.md +1 -0
  2. package/dist/{_bundler-B4RqNF5Z.mjs → _bundler-CDuYl5Gb.mjs} +1 -1
  3. package/dist/{_dev-server-CiKFH9qw.mjs → _dev-server-Me06wgeF.mjs} +2 -2
  4. package/dist/{_init-CE4HKdgV.mjs → _init-BhEfQ6Yi.mjs} +1 -1
  5. package/dist/_resource-commands.d.ts +23 -0
  6. package/dist/{_slug-api-nRFaBEPJ.mjs → _slug-api-z-USxPtL.mjs} +14 -10
  7. package/dist/_slug-api.d.ts +5 -2
  8. package/dist/{_templates-FI3xPEMj.mjs → _templates-4WcKOjS5.mjs} +4 -5
  9. package/dist/_templates.d.ts +3 -4
  10. package/dist/{build-6jfaakgc.mjs → build-BBxf8vom.mjs} +2 -2
  11. package/dist/cli.mjs +338 -346
  12. package/dist/{deploy-DvYeD8ia.mjs → deploy-DRC8jWr8.mjs} +1 -1
  13. package/dist/{dev-B59JIfWO.mjs → dev-BvcctCn2.mjs} +1 -1
  14. package/dist/eval-DfQ8cm5p.mjs +56 -0
  15. package/dist/eval.d.ts +43 -0
  16. package/dist/{init-CpJVjEg0.mjs → init-CUo87gcI.mjs} +2 -2
  17. package/dist/{logs-9q-psa4q.mjs → logs-CQ2T46dr.mjs} +1 -1
  18. package/dist/scaffold/CLAUDE.md +409 -68
  19. package/dist/scaffold/package.json +5 -4
  20. package/dist/scaffold/server.mjs +10 -3
  21. package/dist/scaffold/tsconfig.json +0 -1
  22. package/dist/{secret-CVvSLIDV.mjs → secret-Ct34kdgU.mjs} +1 -1
  23. package/dist/{storage-BvUrnvM3.mjs → storage-i_79ZwAR.mjs} +1 -1
  24. package/dist/{studio--MUV0cid.mjs → studio-CzTzPqe1.mjs} +1 -1
  25. package/dist/templates/briefing-desk/agent.test.ts +350 -0
  26. package/dist/templates/briefing-desk/agent.ts +30 -0
  27. package/dist/templates/briefing-desk/shared.ts +200 -0
  28. package/dist/templates/briefing-desk/system-prompt.md +39 -0
  29. package/dist/templates/briefing-desk/tools/briefing_so_far.ts +35 -0
  30. package/dist/templates/briefing-desk/tools/research_topic.ts +88 -0
  31. package/dist/templates/briefing-desk/tools/verify_claim.ts +77 -0
  32. package/dist/templates/call-audit/agent.eval.test.ts +474 -0
  33. package/dist/templates/code-interpreter/agent.eval.test.ts +165 -0
  34. package/dist/templates/dispatch-center/agent.eval.test.ts +349 -0
  35. package/dist/templates/embedded-assets/agent.eval.test.ts +138 -0
  36. package/dist/templates/health-assistant/agent.eval.test.ts +167 -0
  37. package/dist/templates/infocom-adventure/agent.eval.test.ts +158 -0
  38. package/dist/templates/infocom-adventure/agent.test.ts +49 -18
  39. package/dist/templates/infocom-adventure/agent.ts +21 -0
  40. package/dist/templates/infocom-adventure/shared.ts +20 -9
  41. package/dist/templates/infocom-adventure/system-prompt.md +1 -1
  42. package/dist/templates/infocom-adventure/tools/game_state_move.ts +3 -2
  43. package/dist/templates/link-digest/agent.eval.test.ts +195 -0
  44. package/dist/templates/math-buddy/agent.eval.test.ts +146 -0
  45. package/dist/templates/night-owl/agent.eval.test.ts +272 -0
  46. package/dist/templates/personal-finance/agent.eval.test.ts +168 -0
  47. package/dist/templates/personal-finance/system-prompt.md +4 -0
  48. package/dist/templates/pipeline-simple/agent.eval.test.ts +78 -0
  49. package/dist/templates/pipeline-simple/agent.ts +2 -2
  50. package/dist/templates/pizza-ordering/agent.eval.test.ts +171 -0
  51. package/dist/templates/pizza-ordering/agent.test.ts +15 -0
  52. package/dist/templates/pizza-ordering/shared.ts +20 -1
  53. package/dist/templates/pizza-ordering/system-prompt.md +6 -0
  54. package/dist/templates/plan-and-execute/agent.eval.test.ts +209 -0
  55. package/dist/templates/podcast-digest/agent.eval.test.ts +411 -0
  56. package/dist/templates/podcast-digest/agent.test.ts +30 -59
  57. package/dist/templates/podcast-digest/agent.ts +1 -1
  58. package/dist/templates/podcast-digest/workflows/digest.ts +52 -19
  59. package/dist/templates/podcast-digest/workflows/slack.ts +48 -169
  60. package/dist/templates/recap-workflow/agent.eval.test.ts +366 -0
  61. package/dist/templates/redline/agent.eval.test.ts +209 -0
  62. package/dist/templates/research-workflow/agent.eval.test.ts +329 -0
  63. package/dist/templates/retail/address.ts +87 -1
  64. package/dist/templates/retail/agent.eval.test.ts +314 -0
  65. package/dist/templates/retail/agent.test.ts +171 -91
  66. package/dist/templates/retail/agent.ts +35 -1
  67. package/dist/templates/retail/cancel.ts +116 -0
  68. package/dist/templates/retail/client.tsx +39 -0
  69. package/dist/templates/retail/payment.ts +107 -0
  70. package/dist/templates/retail/pending.test.ts +196 -0
  71. package/dist/templates/retail/pending.ts +137 -0
  72. package/dist/templates/retail/registry.test.ts +119 -8
  73. package/dist/templates/retail/returns.ts +102 -0
  74. package/dist/templates/retail/shared.test.ts +42 -0
  75. package/dist/templates/retail/shared.ts +35 -0
  76. package/dist/templates/retail/store.test.ts +6 -4
  77. package/dist/templates/retail/store.ts +113 -57
  78. package/dist/templates/retail/swap.test.ts +4 -4
  79. package/dist/templates/retail/swap.ts +248 -15
  80. package/dist/templates/retail/system-prompt.md +62 -30
  81. package/dist/templates/retail/tools/cancel_change.ts +33 -0
  82. package/dist/templates/retail/tools/cancel_pending_order.ts +15 -59
  83. package/dist/templates/retail/tools/confirm_change.ts +42 -0
  84. package/dist/templates/retail/tools/exchange_delivered_order_items.ts +21 -66
  85. package/dist/templates/retail/tools/modify_pending_order_address.ts +12 -24
  86. package/dist/templates/retail/tools/modify_pending_order_items.ts +22 -60
  87. package/dist/templates/retail/tools/modify_pending_order_payment.ts +15 -77
  88. package/dist/templates/retail/tools/modify_user_address.ts +12 -15
  89. package/dist/templates/retail/tools/return_delivered_order_items.ts +16 -71
  90. package/dist/templates/retail/tools/transfer_to_human_agents.ts +4 -2
  91. package/dist/templates/simple/agent.eval.test.ts +54 -0
  92. package/dist/templates/solo-rpg/agent.eval.test.ts +320 -0
  93. package/dist/templates/spoken-summary/agent.eval.test.ts +342 -0
  94. package/dist/templates/support-line/agent.eval.test.ts +273 -0
  95. package/dist/templates/support-line/system-prompt.md +2 -1
  96. package/dist/templates/support-line/tools/answer_question.ts +56 -7
  97. package/dist/templates/transcription-workflow/agent.eval.test.ts +234 -0
  98. package/dist/templates/travel-concierge/agent.eval.test.ts +323 -0
  99. package/dist/templates/travel-concierge/agent.test.ts +110 -10
  100. package/dist/templates/travel-concierge/agent.ts +18 -9
  101. package/dist/templates/travel-concierge/shared.ts +110 -9
  102. package/dist/templates/travel-concierge/system-prompt.md +7 -1
  103. package/dist/templates/travel-concierge/tools/book_car_rental.ts +6 -3
  104. package/dist/templates/travel-concierge/tools/book_excursion.ts +6 -3
  105. package/dist/templates/travel-concierge/tools/book_hotel.ts +6 -3
  106. package/dist/templates/travel-concierge/tools/cancel_ticket.ts +6 -3
  107. package/dist/templates/travel-concierge/tools/search_car_rentals.ts +9 -5
  108. package/dist/templates/travel-concierge/tools/search_excursions.ts +9 -5
  109. package/dist/templates/travel-concierge/tools/search_flights.ts +9 -6
  110. package/dist/templates/travel-concierge/tools/search_hotels.ts +9 -5
  111. package/dist/templates/travel-concierge/tools/update_ticket.ts +6 -3
  112. package/dist/templates/web-researcher/agent.eval.test.ts +121 -0
  113. package/dist/templates/web-researcher/system-prompt.md +15 -4
  114. package/dist/{test-9kPKJI-w.mjs → test-BvyT6sAc.mjs} +24 -10
  115. package/dist/test.d.ts +29 -5
  116. package/dist/{worker-bundler-COxnqstQ.mjs → worker-bundler-CCVEDjm1.mjs} +45 -25
  117. package/dist/worker-bundler.mjs +1 -1
  118. package/dist/{workflow-D2AQf2Pl.mjs → workflow-BflATMmU.mjs} +19 -11
  119. package/package.json +5 -5
  120. package/dist/eject-C9WJyyr2.mjs +0 -116
  121. package/dist/eject.d.ts +0 -60
  122. package/dist/templates/infocom-adventure/tools/game_state_history.ts +0 -14
@@ -38,8 +38,8 @@ const registry = Object.entries(retailAgent.tools);
38
38
  // distinct session, which is what these per-tool cases assume.
39
39
  const makeCtx = (): ToolContext => createToolContext();
40
40
 
41
- /** A context whose call flow is in `serving`, so a `when: "serving"` tool can
42
- * reach its body. Moved through the FLOW rather than by writing
41
+ /** A context whose call flow is in `serving.helping`, so a `when: "serving"`
42
+ * tool can reach its body. Moved through the FLOW rather than by writing
43
43
  * `authenticatedUserId`, because the gate reads the machine. */
44
44
  function servingCtx(): ToolContext {
45
45
  const ctx = makeCtx();
@@ -47,11 +47,44 @@ function servingCtx(): ToolContext {
47
47
  return ctx;
48
48
  }
49
49
 
50
+ /** The two tools legal only while a change waits on the caller's yes. */
51
+ const SETTLING_TOOLS = new Set(["cancel_change", "confirm_change"]);
52
+
53
+ function toolNamed(name: string) {
54
+ const def = retailAgent.tools[name];
55
+ if (!def) throw new Error(`no tool named ${name}`);
56
+ return def;
57
+ }
58
+
59
+ /**
60
+ * A context in whatever state `name` needs to reach its BODY.
61
+ *
62
+ * The two settling tools are reached by really identifying a caller and really
63
+ * staging a change, rather than by sending `STAGED` at the machine: they read
64
+ * `state.pending`, and a position with nothing staged behind it is a state this
65
+ * template cannot actually be in. Everything else only needs `serving`.
66
+ */
67
+ async function bodyReachableCtx(name: string): Promise<ToolContext> {
68
+ if (!SETTLING_TOOLS.has(name)) return servingCtx();
69
+ const ctx = makeCtx();
70
+ await toolNamed("find_user_id_by_email").execute(
71
+ { email: "aarav.anderson9752@example.com" },
72
+ ctx,
73
+ );
74
+ await toolNamed("cancel_pending_order").execute(
75
+ { order_id: "#W9300146", reason: "no longer needed" },
76
+ ctx,
77
+ );
78
+ return ctx;
79
+ }
80
+
50
81
  /** Minimal args satisfying each tool's schema. Deliberately plausible-shaped
51
82
  * but wrong — these calls are expected to fail; what is asserted is that they
52
83
  * still moved the UI. */
53
84
  const SAMPLE_ARGS: Record<string, Record<string, unknown>> = {
85
+ cancel_change: {},
54
86
  cancel_pending_order: { order_id: "#W0000000", reason: "no longer needed" },
87
+ confirm_change: {},
55
88
  exchange_delivered_order_items: {
56
89
  order_id: "#W0000000",
57
90
  item_ids: ["0000000000"],
@@ -102,10 +135,17 @@ const SAMPLE_ARGS: Record<string, Record<string, unknown>> = {
102
135
  };
103
136
 
104
137
  describe("tool registry", () => {
105
- test("registers all fifteen tau2 retail tools", () => {
138
+ test("registers all seventeen tools", () => {
139
+ // Fifteen of these are tau2's retail tool set, which this template used to
140
+ // hold verbatim. `confirm_change` and `cancel_change` are the two it does
141
+ // not have: tau2's tools apply on their first call, and here nothing does —
142
+ // see `pending.ts`. Departing from that set is what buys the confirmation
143
+ // gate, and it is the reason this list is no longer a fidelity claim.
106
144
  expect(registry.map(([name]) => name).sort()).toEqual(
107
145
  [
146
+ "cancel_change",
108
147
  "cancel_pending_order",
148
+ "confirm_change",
109
149
  "exchange_delivered_order_items",
110
150
  "find_user_id_by_email",
111
151
  "find_user_id_by_name_zip",
@@ -144,10 +184,11 @@ describe("the UI-update invariant", () => {
144
184
  // This is the one that fails if a future tool is built with tool() instead of
145
185
  // retailTool(): it would work, and the sidebar would sit still through it.
146
186
  test.each(registry)("%s increments callSeq and logs activity", async (name, def) => {
147
- // `serving`, so the flow gate is not what these calls are testing: the
148
- // point is that a tool which reaches its BODY moves the sidebar. A refused
149
- // call deliberately does notsee `store.test.ts`.
150
- const ctx = servingCtx();
187
+ // In whichever state lets the body run, so the flow gate is not what these
188
+ // calls are testing: the point is that a tool which reaches its BODY moves
189
+ // the sidebar. A refused call never reaches onethe hook below is what
190
+ // moves the sidebar for those.
191
+ const ctx = await bodyReachableCtx(name);
151
192
  const before = retailSlot.get(ctx).callSeq;
152
193
  await def.execute(SAMPLE_ARGS[name] ?? {}, ctx);
153
194
  const state = retailSlot.get(ctx);
@@ -159,12 +200,82 @@ describe("the UI-update invariant", () => {
159
200
  test.each(registry)("%s logs its own registry key as its name", async (name, def) => {
160
201
  // Catches a copy-paste where the retailTool `name` and the registry key
161
202
  // disagree — the activity feed would then attribute calls to the wrong tool.
162
- const ctx = servingCtx();
203
+ const ctx = await bodyReachableCtx(name);
163
204
  await def.execute(SAMPLE_ARGS[name] ?? {}, ctx);
164
205
  expect(retailSlot.get(ctx).activity.at(-1)?.tool).toBe(name);
165
206
  });
166
207
  });
167
208
 
209
+ describe("the blocked-call hook", () => {
210
+ /**
211
+ * A `tool.called`, delivered the way the RUNTIME delivers it.
212
+ *
213
+ * The hook is a plain function on the def, so this needs no harness — and
214
+ * asserting on it here is the only way the blocked lines are covered at all:
215
+ * they are written by something no tool call executes.
216
+ */
217
+ const called = (name: string, ctx: ToolContext) =>
218
+ retailAgent.events?.["tool.called"]?.(
219
+ {
220
+ type: "tool.called",
221
+ toolCallId: "call_1",
222
+ toolName: name,
223
+ args: {},
224
+ meta: { id: "evt_1", at: 0 },
225
+ },
226
+ ctx,
227
+ );
228
+
229
+ test.each(registry.filter(([name]) => !PUBLIC_TOOLS.has(name)))(
230
+ "%s records a blocked line when the model tries it too early",
231
+ (name) => {
232
+ const ctx = makeCtx();
233
+ called(name, ctx);
234
+
235
+ // The regression this closes: the gate moved out of `retailTool` and the
236
+ // sidebar stopped showing the most interesting calls the model makes.
237
+ const state = retailSlot.get(ctx);
238
+ expect(state.callSeq, `${name} recorded no blocked line`).toBe(1);
239
+ expect(state.activity.at(-1)?.tool).toBe(name);
240
+ expect(state.activity.at(-1)?.summary).toContain("blocked");
241
+ expect(state.activity.at(-1)?.summary).toContain("identifying");
242
+ },
243
+ );
244
+
245
+ test.each(registry.filter(([name]) => PUBLIC_TOOLS.has(name)))(
246
+ "%s is left to the wrapper, because it is going to run",
247
+ (name) => {
248
+ const ctx = makeCtx();
249
+ called(name, ctx);
250
+ // The double-count this avoids: a tool that reaches its body records its
251
+ // own line from inside it, with a real summary.
252
+ expect(retailSlot.get(ctx).activity, `${name} was double-recorded`).toEqual([]);
253
+ },
254
+ );
255
+
256
+ test("a tool this template did not declare is ignored", () => {
257
+ const ctx = makeCtx();
258
+ called("web_search", ctx);
259
+ // `gateFor` answers `undefined` for anything not built through `retailTool`
260
+ // — a builtin, or a tool a future author adds outside the wrapper. Recording
261
+ // those would put lines in the feed for calls this gate has no opinion on.
262
+ expect(retailSlot.get(ctx).activity).toEqual([]);
263
+ });
264
+
265
+ test("the same tool stops being blocked once the caller is identified", async () => {
266
+ const ctx = makeCtx();
267
+ called("get_user_details", ctx);
268
+ expect(retailSlot.get(ctx).activity).toHaveLength(1);
269
+
270
+ // The hook asks the flow, so it follows the flow: same tool, same session,
271
+ // no line once the position allows it.
272
+ const authed = await bodyReachableCtx("get_user_details");
273
+ const before = retailSlot.get(authed).activity.length;
274
+ called("get_user_details", authed);
275
+ expect(retailSlot.get(authed).activity).toHaveLength(before);
276
+ });
277
+ });
278
+
168
279
  describe("the authentication gate", () => {
169
280
  test.each(registry.filter(([name]) => !PUBLIC_TOOLS.has(name)))(
170
281
  "%s refuses before the caller is identified",
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Returning items from a delivered order, as a plan and an apply (see
3
+ * `cancel.ts` for why every mutating action is split that way).
4
+ */
5
+
6
+ import { isToolFailure, type ToolFailure } from "@alexkroman1/aai";
7
+ import { resolveOrder } from "./resolve.ts";
8
+ import type { RetailState } from "./shared.ts";
9
+ import { authenticatedUser, findPaymentMethod, isGiftCard } from "./store.ts";
10
+
11
+ export interface ReturnPlan {
12
+ readBack: string;
13
+ orderId: string;
14
+ /** In the order the caller named them — duplicates are meaningful. */
15
+ itemIds: string[];
16
+ /** Item names, positionally aligned with `itemIds`, so the readback and the
17
+ * result can name things rather than read ten-digit numbers aloud. */
18
+ itemNames: string[];
19
+ paymentMethodId: string;
20
+ }
21
+
22
+ export function planReturn(
23
+ state: RetailState,
24
+ spokenOrderId: string,
25
+ itemIds: string[],
26
+ paymentMethodId: string,
27
+ ): ReturnPlan | ToolFailure {
28
+ const user = authenticatedUser(state);
29
+ if (isToolFailure(user)) return user;
30
+
31
+ const order = resolveOrder(state, spokenOrderId);
32
+ if (isToolFailure(order)) return order;
33
+
34
+ if (order.status !== "delivered") {
35
+ return {
36
+ error: `Order ${order.order_id} is ${order.status}. Only a delivered order can be returned, and only once.`,
37
+ };
38
+ }
39
+
40
+ const method = findPaymentMethod(user, paymentMethodId);
41
+ if (isToolFailure(method)) return method;
42
+
43
+ const originalMethodId = order.payment_history[0]?.payment_method_id;
44
+ if (!isGiftCard(method) && paymentMethodId !== originalMethodId) {
45
+ return {
46
+ error: `A refund must go to the original payment method (${originalMethodId}) or to a gift card. ${paymentMethodId} is neither.`,
47
+ };
48
+ }
49
+
50
+ if (itemIds.length === 0) {
51
+ return { error: "No items were listed to return." };
52
+ }
53
+ const held = new Map<string, number>();
54
+ for (const item of order.items) {
55
+ held.set(item.item_id, (held.get(item.item_id) ?? 0) + 1);
56
+ }
57
+ const asked = new Map<string, number>();
58
+ for (const itemId of itemIds) {
59
+ asked.set(itemId, (asked.get(itemId) ?? 0) + 1);
60
+ }
61
+ for (const [itemId, count] of asked) {
62
+ const available = held.get(itemId) ?? 0;
63
+ if (count > available) {
64
+ return {
65
+ error: `Order ${order.order_id} holds ${available} of item ${itemId}, but ${count} were listed for return.`,
66
+ };
67
+ }
68
+ }
69
+
70
+ const itemNames = itemIds.map(
71
+ (id) => order.items.find((item) => item.item_id === id)?.name ?? id,
72
+ );
73
+ return {
74
+ readBack:
75
+ `return ${itemNames.join(", ")} from order ${order.order_id}, ` +
76
+ `with the refund going to ${paymentMethodId}`,
77
+ orderId: order.order_id,
78
+ itemIds: [...itemIds],
79
+ itemNames,
80
+ paymentMethodId,
81
+ };
82
+ }
83
+
84
+ export function applyReturn(state: RetailState, plan: ReturnPlan) {
85
+ const order = state.store.orders[plan.orderId];
86
+
87
+ // In the order the caller named them, not sorted: tau2 sorted this list to
88
+ // match an expected end state, and nothing compares against one any more.
89
+ if (order) {
90
+ order.status = "return requested";
91
+ order.return_items = [...plan.itemIds];
92
+ order.return_payment_method_id = plan.paymentMethodId;
93
+ }
94
+
95
+ return {
96
+ order_id: plan.orderId,
97
+ status: "return requested" as const,
98
+ return_items: plan.itemIds,
99
+ refund_to: plan.paymentMethodId,
100
+ message: `Return requested on ${plan.orderId} for ${plan.itemNames.join(", ")}. The customer will get an email with return instructions, and the refund goes to ${plan.paymentMethodId} once the items arrive.`,
101
+ };
102
+ }
@@ -11,6 +11,7 @@ function makeState(authenticatedUserId: string | null): RetailState {
11
11
  callSeq: 3,
12
12
  activity: [{ seq: 3, tool: "get_order_details", summary: "read #W5866402", at: 0 }],
13
13
  focus: { orderId: "#W5866402" },
14
+ pending: null,
14
15
  };
15
16
  }
16
17
 
@@ -196,3 +197,44 @@ describe("buildScriptBullets", () => {
196
197
  expect(bullets.length).toBeLessThanOrEqual(6);
197
198
  });
198
199
  });
200
+
201
+ describe("the staged change in the projection", () => {
202
+ function stagedState(authenticatedUserId: string | null): RetailState {
203
+ const state = makeState(authenticatedUserId);
204
+ state.pending = {
205
+ kind: "cancel_pending_order",
206
+ plan: {
207
+ readBack: "cancel order #W5866402 and refund $12.00 to gift_card_7794233",
208
+ orderId: "#W5866402",
209
+ reason: "no longer needed",
210
+ refunds: [{ methodId: "gift_card_7794233", amount: 12 }],
211
+ total: 12,
212
+ },
213
+ };
214
+ return state;
215
+ }
216
+
217
+ test("carries the sentence the agent is supposed to be reading back", () => {
218
+ const view = storeView(stagedState("olivia_ito_3591"));
219
+ expect(view.pending?.kind).toBe("cancel_pending_order");
220
+ expect(view.pending?.readBack).toContain("#W5866402");
221
+ });
222
+
223
+ test("is null when nothing is waiting", () => {
224
+ expect(storeView(makeState("olivia_ito_3591")).pending).toBeNull();
225
+ });
226
+
227
+ test("is withheld before authentication, like everything else here", () => {
228
+ // Unreachable in practice — a change cannot be staged before the caller is
229
+ // identified — but the projection does not lean on that: it gates `pending`
230
+ // on `user` exactly as it gates the orders it describes.
231
+ expect(storeView(stagedState(null)).pending).toBeNull();
232
+ });
233
+
234
+ test("the script offers the answer to the question, not a new request", () => {
235
+ expect(buildScriptBullets(stagedState("olivia_ito_3591"))).toEqual([
236
+ '"Yes, go ahead"',
237
+ `"No, don't do that"`,
238
+ ]);
239
+ });
240
+ });
@@ -1,4 +1,5 @@
1
1
  import type { DeepReadonly } from "@alexkroman1/aai";
2
+ import type { PendingAction } from "./pending.ts";
2
3
 
3
4
  // ─── Store types ─────────────────────────────────────────────────────────────
4
5
  // Field names are tau2's snake_case verbatim, because `seed.json` is tau2's
@@ -129,6 +130,15 @@ export interface RetailState {
129
130
  callSeq: number;
130
131
  activity: ActivityEntry[];
131
132
  focus: { orderId?: string; productId?: string };
133
+ /**
134
+ * The change waiting on the caller's explicit yes, or null.
135
+ *
136
+ * Lives here and not in the flow because `callFlow` holds a POSITION —
137
+ * "something is waiting" — and this holds the thing itself, which is what
138
+ * `confirm_change` applies and what the sidebar renders. One tool call always
139
+ * moves both. See `pending.ts`.
140
+ */
141
+ pending: PendingAction | null;
132
142
  }
133
143
 
134
144
  /**
@@ -159,6 +169,7 @@ export function emptyRetailState(): RetailState {
159
169
  callSeq: 0,
160
170
  activity: [],
161
171
  focus: {},
172
+ pending: null,
162
173
  };
163
174
  }
164
175
 
@@ -204,6 +215,14 @@ export interface SwapOptionView {
204
215
  alternatives: { itemId: string; options: Record<string, string>; price: number }[];
205
216
  }
206
217
 
218
+ /** The staged change, as the browser sees it: what it is and what the agent is
219
+ * supposed to be reading back. The plan itself stays on the server — the panel
220
+ * only has to show the caller's own words back to them. */
221
+ export interface PendingView {
222
+ kind: PendingAction["kind"];
223
+ readBack: string;
224
+ }
225
+
207
226
  export interface StoreView {
208
227
  customer: CustomerView | null;
209
228
  orders: OrderView[];
@@ -214,6 +233,10 @@ export interface StoreView {
214
233
  activity: ActivityEntry[];
215
234
  scriptBullets: string[];
216
235
  productCount: number;
236
+ /** Gated on `user` like everything else here: a change staged before the
237
+ * caller was identified cannot exist, but the projection does not rely on
238
+ * that — it withholds it the same way it withholds their orders. */
239
+ pending: PendingView | null;
217
240
  }
218
241
 
219
242
  export const MAX_SWAP_ITEMS = 3;
@@ -397,6 +420,10 @@ export function storeView(state: FrozenRetailState): StoreView {
397
420
  ...(state.focus.productId ? { productId: state.focus.productId } : {}),
398
421
  },
399
422
  swapOptions: swapOptionsFor(state, focusedOrder),
423
+ pending:
424
+ user && state.pending
425
+ ? { kind: state.pending.kind, readBack: state.pending.plan.readBack }
426
+ : null,
400
427
  callSeq: state.callSeq,
401
428
  activity: state.activity.slice(-MAX_ACTIVITY),
402
429
  scriptBullets: buildScriptBullets(state),
@@ -417,6 +444,14 @@ export function buildScriptBullets(state: FrozenRetailState): string[] {
417
444
  const userId = state.authenticatedUserId;
418
445
  const user = userId ? state.store.users[userId] : undefined;
419
446
 
447
+ // A staged change is a QUESTION the agent has just asked, so while one waits
448
+ // the only two useful things a caller can say are the answer to it. Offering
449
+ // "cancel my pending order" here would be offering an action the gate is
450
+ // about to refuse.
451
+ if (user && state.pending) {
452
+ return ['"Yes, go ahead"', '"No, don\'t do that"'];
453
+ }
454
+
420
455
  // Pre-auth: only the two ways in. The personas panel (a constant, rendered
421
456
  // straight from DEMO_PERSONAS) covers WHICH customer to be.
422
457
  if (!user) {
@@ -225,9 +225,11 @@ describe("retailTool", () => {
225
225
  const ctx = makeCtx();
226
226
  await gated.execute({}, ctx);
227
227
  const state = retailSlot.get(ctx);
228
- // The gate short-circuits before the wrapper's body, so a blocked call no
229
- // longer records an activity entry the trade `retailTool`'s doc names.
230
- // What it buys is that a refusal cannot half-write the store.
228
+ // The gate short-circuits before the wrapper's body, so the EXECUTION path
229
+ // writes nothing at allwhich is what makes a refusal unable to half-write
230
+ // the store. The sidebar line for a blocked call comes from `agent.ts`'s
231
+ // `tool.called` hook instead, which is a different path and not under test
232
+ // here: see `registry.test.ts`.
231
233
  expect(state.callSeq).toBe(0);
232
234
  expect(state.activity).toEqual([]);
233
235
  });
@@ -245,7 +247,7 @@ describe("retailTool", () => {
245
247
  // the envelope this test is about, and a refusal fails here naming what the
246
248
  // flow said instead of reading `undefined` off a field nobody assigned.
247
249
  const answered = okPosition<{ ok: boolean }>(await gated.execute({}, ctx));
248
- expect(answered).toMatchObject({ state: "serving", done: false });
250
+ expect(answered).toMatchObject({ state: "serving.helping", done: false });
249
251
  expect(answered.instruction).toMatch(/one identified customer/);
250
252
  });
251
253