@alexkroman1/aai-cli 6.11.0 → 7.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 (162) hide show
  1. package/dist/{_agent-DpH2pBJd.mjs → _agent-BTwZJu4P.mjs} +2 -2
  2. package/dist/{_bundler-C2BDwU7r.mjs → _bundler-B4RqNF5Z.mjs} +2 -2
  3. package/dist/_dev-env.d.ts +22 -2
  4. package/dist/{_dev-server-LLLWnFBs.mjs → _dev-server-CiKFH9qw.mjs} +38 -47
  5. package/dist/_dev-server.d.ts +0 -17
  6. package/dist/{_init-C57bWAQ8.mjs → _init-CE4HKdgV.mjs} +3 -2
  7. package/dist/{_preflight-BtfaYtbE.mjs → _preflight-Co1G9Jww.mjs} +1 -1
  8. package/dist/_preflight.d.ts +1 -1
  9. package/dist/{_slug-api-BxrsJXVI.mjs → _slug-api-nRFaBEPJ.mjs} +2 -2
  10. package/dist/{_studio-U3jhKlA3.mjs → _studio-DR5yzZ1T.mjs} +2 -1
  11. package/dist/{_templates-Dxb_P2Wz.mjs → _templates-FI3xPEMj.mjs} +1 -1
  12. package/dist/_workflow-scan.d.ts +108 -0
  13. package/dist/{build-Dfza2pRI.mjs → build-6jfaakgc.mjs} +5 -2
  14. package/dist/cli.mjs +30 -25
  15. package/dist/{client-bundler-BEIqgOtd.mjs → client-bundler-CWnG42cU.mjs} +2 -1
  16. package/dist/client-bundler.mjs +1 -1
  17. package/dist/{delete-vbpjGzqs.mjs → delete-Dl_LSfvI.mjs} +3 -3
  18. package/dist/{deploy-B8lubiRT.mjs → deploy-DvYeD8ia.mjs} +5 -5
  19. package/dist/{dev-Uq5ujP8a.mjs → dev-B59JIfWO.mjs} +1 -1
  20. package/dist/{eject-31gjtaHF.mjs → eject-C9WJyyr2.mjs} +1 -1
  21. package/dist/{init-l6xfU4xX.mjs → init-CpJVjEg0.mjs} +3 -3
  22. package/dist/{login-C71-qz8F.mjs → login-BBuM1sxH.mjs} +3 -4
  23. package/dist/{logs-BWVGSjLR.mjs → logs-9q-psa4q.mjs} +2 -2
  24. package/dist/project-config.mjs +1 -1
  25. package/dist/scaffold/CLAUDE.md +40 -19
  26. package/dist/scaffold/package.json +5 -3
  27. package/dist/scaffold/server.mjs +1 -1
  28. package/dist/{secret-BuMuFR4B.mjs → secret-CVvSLIDV.mjs} +2 -2
  29. package/dist/{storage-DzRZ-eCw.mjs → storage-BvUrnvM3.mjs} +2 -2
  30. package/dist/{studio-BCNUpDgP.mjs → studio--MUV0cid.mjs} +4 -4
  31. package/dist/templates/call-audit/agent.test.ts +57 -204
  32. package/dist/templates/call-audit/agent.ts +32 -19
  33. package/dist/templates/call-audit/client.tsx +17 -54
  34. package/dist/templates/call-audit/workflows/audit.ts +11 -18
  35. package/dist/templates/call-audit/workflows/ingest.ts +106 -114
  36. package/dist/templates/call-audit/workflows/media.ts +2 -12
  37. package/dist/templates/call-audit/workflows/summarize.ts +47 -52
  38. package/dist/templates/call-audit/workflows/sync-api.ts +7 -7
  39. package/dist/templates/dispatch-center/agent.test.ts +149 -25
  40. package/dist/templates/dispatch-center/client.tsx +239 -129
  41. package/dist/templates/dispatch-center/shared.ts +99 -1
  42. package/dist/templates/dispatch-center/system-prompt.md +3 -1
  43. package/dist/templates/dispatch-center/tools/incident_add_note.ts +16 -13
  44. package/dist/templates/dispatch-center/tools/incident_create.ts +19 -1
  45. package/dist/templates/dispatch-center/tools/incident_escalate.ts +68 -59
  46. package/dist/templates/dispatch-center/tools/incident_triage.ts +57 -43
  47. package/dist/templates/dispatch-center/tools/incident_update_status.ts +49 -40
  48. package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +8 -2
  49. package/dist/templates/dispatch-center/tools/resources_dispatch.ts +69 -52
  50. package/dist/templates/dispatch-center/tools/resources_update_status.ts +45 -41
  51. package/dist/templates/embedded-assets/agent.test.ts +17 -14
  52. package/dist/templates/health-assistant/agent.test.ts +22 -4
  53. package/dist/templates/infocom-adventure/agent.test.ts +4 -6
  54. package/dist/templates/infocom-adventure/client.tsx +246 -164
  55. package/dist/templates/link-digest/agent.test.ts +24 -19
  56. package/dist/templates/link-digest/client.tsx +47 -61
  57. package/dist/templates/link-digest/workflows/digest.ts +19 -31
  58. package/dist/templates/night-owl/agent.test.ts +70 -19
  59. package/dist/templates/night-owl/agent.ts +5 -0
  60. package/dist/templates/night-owl/client.tsx +56 -56
  61. package/dist/templates/night-owl/shared.ts +24 -0
  62. package/dist/templates/night-owl/tools/recommend.ts +18 -6
  63. package/dist/templates/pizza-ordering/agent.test.ts +27 -27
  64. package/dist/templates/pizza-ordering/client.tsx +9 -26
  65. package/dist/templates/plan-and-execute/agent.test.ts +99 -58
  66. package/dist/templates/plan-and-execute/agent.ts +1 -1
  67. package/dist/templates/plan-and-execute/client.tsx +12 -15
  68. package/dist/templates/plan-and-execute/shared.ts +71 -2
  69. package/dist/templates/plan-and-execute/tools/plan_status.ts +18 -6
  70. package/dist/templates/plan-and-execute/tools/revise_plan.ts +18 -10
  71. package/dist/templates/plan-and-execute/tools/start_plan.ts +13 -2
  72. package/dist/templates/plan-and-execute/tools/work_next_step.ts +70 -22
  73. package/dist/templates/podcast-digest/agent.test.ts +746 -0
  74. package/dist/templates/podcast-digest/agent.ts +139 -0
  75. package/dist/templates/podcast-digest/client.tsx +154 -0
  76. package/dist/templates/podcast-digest/workflows/digest.ts +411 -0
  77. package/dist/templates/podcast-digest/workflows/feeds.ts +507 -0
  78. package/dist/templates/podcast-digest/workflows/slack.ts +209 -0
  79. package/dist/templates/recap-workflow/agent.test.ts +97 -94
  80. package/dist/templates/recap-workflow/tools/recap_progress.ts +9 -12
  81. package/dist/templates/recap-workflow/tools/recap_status.ts +8 -9
  82. package/dist/templates/recap-workflow/tools/request_recap.ts +2 -1
  83. package/dist/templates/recap-workflow/workflows/recap.ts +36 -40
  84. package/dist/templates/redline/agent.test.ts +17 -15
  85. package/dist/templates/redline/client.tsx +12 -12
  86. package/dist/templates/redline/workflows/redline.ts +19 -31
  87. package/dist/templates/research-workflow/agent.test.ts +60 -59
  88. package/dist/templates/research-workflow/tools/research_progress.ts +9 -12
  89. package/dist/templates/research-workflow/tools/research_status.ts +9 -11
  90. package/dist/templates/research-workflow/workflows/research.ts +44 -61
  91. package/dist/templates/retail/agent.test.ts +26 -23
  92. package/dist/templates/retail/client.tsx +226 -117
  93. package/dist/templates/retail/registry.test.ts +38 -6
  94. package/dist/templates/retail/store.test.ts +82 -15
  95. package/dist/templates/retail/store.ts +174 -47
  96. package/dist/templates/retail/system-prompt.md +11 -2
  97. package/dist/templates/retail/tools/cancel_pending_order.ts +2 -2
  98. package/dist/templates/retail/tools/exchange_delivered_order_items.ts +2 -2
  99. package/dist/templates/retail/tools/find_user_id_by_email.ts +8 -5
  100. package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +4 -5
  101. package/dist/templates/retail/tools/get_item_details.ts +3 -4
  102. package/dist/templates/retail/tools/get_order_details.ts +3 -4
  103. package/dist/templates/retail/tools/get_product_details.ts +3 -4
  104. package/dist/templates/retail/tools/get_user_details.ts +2 -2
  105. package/dist/templates/retail/tools/list_all_product_types.ts +5 -5
  106. package/dist/templates/retail/tools/modify_pending_order_address.ts +2 -2
  107. package/dist/templates/retail/tools/modify_pending_order_items.ts +2 -3
  108. package/dist/templates/retail/tools/modify_pending_order_payment.ts +2 -2
  109. package/dist/templates/retail/tools/modify_user_address.ts +2 -2
  110. package/dist/templates/retail/tools/return_delivered_order_items.ts +2 -2
  111. package/dist/templates/retail/tools/transfer_to_human_agents.ts +8 -4
  112. package/dist/templates/solo-rpg/agent.test.ts +227 -48
  113. package/dist/templates/solo-rpg/client.tsx +38 -37
  114. package/dist/templates/solo-rpg/shared.ts +145 -19
  115. package/dist/templates/solo-rpg/system-prompt.md +3 -2
  116. package/dist/templates/solo-rpg/tools/action_roll.ts +82 -63
  117. package/dist/templates/solo-rpg/tools/burn_momentum.ts +85 -50
  118. package/dist/templates/solo-rpg/tools/check_state.ts +24 -7
  119. package/dist/templates/solo-rpg/tools/load_game.ts +13 -1
  120. package/dist/templates/solo-rpg/tools/save_game.ts +16 -7
  121. package/dist/templates/solo-rpg/tools/setup_character.ts +22 -2
  122. package/dist/templates/solo-rpg/tools/update_state.ts +113 -100
  123. package/dist/templates/spoken-summary/agent.test.ts +68 -97
  124. package/dist/templates/spoken-summary/agent.ts +30 -17
  125. package/dist/templates/spoken-summary/client.tsx +10 -50
  126. package/dist/templates/spoken-summary/workflows/summarize.ts +17 -22
  127. package/dist/templates/spoken-summary/workflows/transcribe.ts +19 -26
  128. package/dist/templates/support-line/agent.test.ts +11 -16
  129. package/dist/templates/support-line/agent.ts +1 -1
  130. package/dist/templates/support-line/client.tsx +9 -9
  131. package/dist/templates/support-line/nodes.ts +100 -0
  132. package/dist/templates/support-line/procedure.ts +407 -0
  133. package/dist/templates/support-line/tools/answer_question.ts +17 -4
  134. package/dist/templates/transcription-workflow/agent.test.ts +94 -194
  135. package/dist/templates/transcription-workflow/agent.ts +1 -1
  136. package/dist/templates/transcription-workflow/client.tsx +17 -42
  137. package/dist/templates/transcription-workflow/workflows/batch.ts +19 -22
  138. package/dist/templates/transcription-workflow/workflows/normalize.ts +90 -186
  139. package/dist/templates/transcription-workflow/workflows/stitch.ts +0 -11
  140. package/dist/templates/transcription-workflow/workflows/stream.ts +4 -5
  141. package/dist/templates/transcription-workflow/workflows/sync-api.ts +6 -6
  142. package/dist/templates/transcription-workflow/workflows/transcribe.ts +32 -26
  143. package/dist/templates/transcription-workflow/workflows/wav.ts +0 -31
  144. package/dist/templates/travel-concierge/agent.test.ts +64 -33
  145. package/dist/templates/travel-concierge/client.tsx +11 -23
  146. package/dist/templates/travel-concierge/routing.ts +34 -15
  147. package/dist/templates/travel-concierge/shared.ts +70 -3
  148. package/dist/templates/travel-concierge/tools/book_car_rental.ts +2 -2
  149. package/dist/templates/travel-concierge/tools/book_excursion.ts +2 -2
  150. package/dist/templates/travel-concierge/tools/book_hotel.ts +2 -2
  151. package/dist/templates/travel-concierge/tools/cancel_ticket.ts +2 -2
  152. package/dist/templates/travel-concierge/tools/update_ticket.ts +2 -2
  153. package/dist/{worker-bundler-CGD4r8Kc.mjs → worker-bundler-COxnqstQ.mjs} +221 -3
  154. package/dist/worker-bundler.mjs +1 -1
  155. package/dist/{workflow-CFpxOFfQ.mjs → workflow-D2AQf2Pl.mjs} +28 -1
  156. package/dist/workflow-bundler.d.ts +6 -1
  157. package/dist/workflow.d.ts +1 -1
  158. package/package.json +5 -4
  159. package/dist/templates/call-audit/workflows/temp-media.ts +0 -138
  160. package/dist/templates/support-line/graph.ts +0 -224
  161. package/dist/{_config-D_s09e7g.mjs → _config-CmJOFsAP.mjs} +1 -1
  162. /package/dist/templates/plan-and-execute/{graph.ts → procedure.ts} +0 -0
@@ -312,34 +312,3 @@ export function planSegments(format: WavFormat): Segment[] {
312
312
  }
313
313
  return segments;
314
314
  }
315
-
316
- /**
317
- * Put a canonical 44-byte WAV header in front of a slice of samples.
318
- *
319
- * A byte range of a WAV is raw PCM with no header, and the sync endpoint reads
320
- * the rate and channel count out of one. Sending `audio/pcm` with a `config`
321
- * part instead would work equally well; a header keeps the two parts of the
322
- * request from being able to disagree.
323
- */
324
- export function wavWithHeader(format: WavFormat, samples: Uint8Array): Uint8Array<ArrayBuffer> {
325
- const out = new Uint8Array(44 + samples.length);
326
- const view = new DataView(out.buffer);
327
- const write = (at: number, text: string) => {
328
- for (let i = 0; i < text.length; i++) view.setUint8(at + i, text.charCodeAt(i));
329
- };
330
-
331
- write(0, "RIFF");
332
- view.setUint32(4, 36 + samples.length, true);
333
- write(8, "WAVEfmt ");
334
- view.setUint32(16, 16, true); // `fmt ` payload length
335
- view.setUint16(20, 1, true); // linear PCM
336
- view.setUint16(22, format.channels, true);
337
- view.setUint32(24, format.sampleRate, true);
338
- view.setUint32(28, bytesPerSecond(format), true);
339
- view.setUint16(32, blockAlign(format), true);
340
- view.setUint16(34, format.bitsPerSample, true);
341
- write(36, "data");
342
- view.setUint32(40, samples.length, true);
343
- out.set(samples, 44);
344
- return out;
345
- }
@@ -1,7 +1,13 @@
1
1
  /// <reference types="vite/client" />
2
2
 
3
3
  import type { ToolContext } from "@alexkroman1/aai";
4
- import { createToolContext, runTool, withDiscoveredTools } from "@alexkroman1/aai/testing";
4
+ import {
5
+ createToolContext,
6
+ ok,
7
+ okPosition,
8
+ toolRunner,
9
+ withDiscoveredTools,
10
+ } from "@alexkroman1/aai/testing";
5
11
  import { describe, expect, test } from "vitest";
6
12
  import authoredAgent from "./agent.ts";
7
13
 
@@ -18,7 +24,14 @@ const agentDef = withDiscoveredTools(
18
24
  import.meta.glob("./tools/*.ts", { eager: true }),
19
25
  );
20
26
 
21
- import { activeAssistant, FLIGHTS, tripProjection, tripSlot, tripView } from "./shared.ts";
27
+ import {
28
+ activeAssistant,
29
+ FLIGHTS,
30
+ gateFlow,
31
+ tripProjection,
32
+ tripSlot,
33
+ tripView,
34
+ } from "./shared.ts";
22
35
 
23
36
  // ─── Harness ─────────────────────────────────────────────────────────────────
24
37
 
@@ -26,11 +39,11 @@ import { activeAssistant, FLIGHTS, tripProjection, tripSlot, tripView } from "./
26
39
  * below rests on. */
27
40
  const makeCtx = (): ToolContext => createToolContext();
28
41
 
29
- /** A tool by the name the model calls it by, bound to this agent. The lookup
30
- * and its "no such tool" message are `runTool`'s (`@alexkroman1/aai/testing`);
31
- * what is local is only which agent they run against. */
32
- const run = (name: string, args: Record<string, unknown>, ctx: ToolContext) =>
33
- runTool(agentDef, name, args, ctx);
42
+ /** A tool by the name the model calls it by, bound to this agent. The lookup,
43
+ * its "no such tool" message and the args-or-context shape are all
44
+ * `toolRunner`'s (`@alexkroman1/aai/testing`); what is local is only which
45
+ * agent it runs against. */
46
+ const run = toolRunner(agentDef);
34
47
 
35
48
  /** The state a tool just mutated, read back the way `syncState` reads it. */
36
49
  function stateOf(ctx: ToolContext) {
@@ -116,36 +129,47 @@ describe("sensitive tools stage rather than act", () => {
116
129
  const ctx = makeCtx();
117
130
  await run("book_hotel", { hotelId: "H1", nights: 3 }, ctx);
118
131
 
119
- const applied = (await run("confirm_action", {}, ctx)) as {
120
- applied: string;
121
- reference: string;
122
- };
123
- expect(applied.applied).toContain("Harborview Suites");
132
+ // Staging moved the gate, in the same window it wrote `pending`.
133
+ expect(gateFlow.position(ctx).state).toBe("awaitingConfirmation");
134
+
135
+ // A gated tool answers the flow's POSITION wrapped around the body's own
136
+ // return value, so the applied sentence is under `result`. `okPosition`
137
+ // keeps the position and THROWS on a refusal, quoting it — where the cast
138
+ // it replaces read `undefined` off the failure and died three assertions
139
+ // later on a property of undefined.
140
+ const confirmed = okPosition<{ applied: string; reference: string }>(
141
+ await run("confirm_action", ctx),
142
+ );
143
+ expect(confirmed.result.applied).toContain("Harborview Suites");
124
144
  // 3 nights at $265.
125
- expect(applied.applied).toContain("$795");
145
+ expect(confirmed.result.applied).toContain("$795");
146
+ // SETTLED: the gate re-armed as part of the same call.
147
+ expect(confirmed.state).toBe("browsing");
126
148
 
127
149
  const state = stateOf(ctx);
128
150
  expect(state.pending).toBeNull();
129
151
  expect(state.bookings).toHaveLength(1);
130
- expect(state.bookings[0]?.reference).toBe(applied.reference);
152
+ expect(state.bookings[0]?.reference).toBe(confirmed.result.reference);
131
153
 
132
- // The gate re-arms: a second confirm has nothing to apply.
133
- expect(await run("confirm_action", {}, ctx)).toEqual({
134
- error: "Nothing is waiting for confirmation. Use the booking tool first.",
154
+ // The gate re-arms, and now REFUSES rather than reporting an empty apply
155
+ // `when: "awaitingConfirmation"` is what a second confirm meets.
156
+ expect(await run("confirm_action", ctx)).toMatchObject({
157
+ error: expect.stringContaining('this conversation is at "browsing"'),
135
158
  });
136
159
  });
137
160
 
138
161
  test("cancel_action drops the staged change and leaves the booking alone", async () => {
139
162
  const ctx = makeCtx();
140
163
  await run("update_ticket", { flightId: "LX54" }, ctx);
141
- const dropped = (await run("cancel_action", {}, ctx)) as { discarded: string };
142
- expect(dropped.discarded).toContain("LX54");
164
+ const dropped = okPosition<{ discarded: string }>(await run("cancel_action", ctx));
165
+ expect(dropped.result.discarded).toContain("LX54");
166
+ expect(dropped.state).toBe("browsing");
143
167
 
144
168
  const state = stateOf(ctx);
145
169
  expect(state.pending).toBeNull();
146
170
  expect(state.ticket?.flightId).toBe("LX40");
147
- expect(await run("cancel_action", {}, ctx)).toEqual({
148
- message: "Nothing was waiting for confirmation.",
171
+ expect(await run("cancel_action", ctx)).toMatchObject({
172
+ error: expect.stringContaining('this conversation is at "browsing"'),
149
173
  });
150
174
  });
151
175
 
@@ -161,14 +185,14 @@ describe("sensitive tools stage rather than act", () => {
161
185
 
162
186
  test("cancelling the ticket makes a later ticket change impossible", async () => {
163
187
  const ctx = makeCtx();
164
- await run("cancel_ticket", {}, ctx);
165
- await run("confirm_action", {}, ctx);
188
+ await run("cancel_ticket", ctx);
189
+ await run("confirm_action", ctx);
166
190
  expect(stateOf(ctx).ticket).toBeNull();
167
191
 
168
192
  expect(await run("update_ticket", { flightId: "LX52" }, ctx)).toEqual({
169
193
  error: "This caller has no ticket to move — it was cancelled on this call.",
170
194
  });
171
- expect(await run("cancel_ticket", {}, ctx)).toEqual({ error: "There is no ticket to cancel." });
195
+ expect(await run("cancel_ticket", ctx)).toEqual({ error: "There is no ticket to cancel." });
172
196
  });
173
197
 
174
198
  test("a second staging is REFUSED rather than overwriting the first", async () => {
@@ -193,7 +217,12 @@ describe("sensitive tools stage rather than act", () => {
193
217
 
194
218
  // The first staging is untouched, and it is what a yes applies.
195
219
  expect(stateOf(ctx).pending).toEqual({ kind: "update_ticket", flightId: "LX52" });
196
- const applied = (await run("confirm_action", {}, ctx)) as { applied: string };
220
+ // A refused SECOND staging must not have moved the gate either — it was
221
+ // already `awaitingConfirmation` and the refusal changed nothing.
222
+ expect(gateFlow.position(ctx).state).toBe("awaitingConfirmation");
223
+ // `ok` is `okPosition` with `.result` taken off: this assertion is about
224
+ // what the apply DID, not about where the gate landed.
225
+ const applied = ok<{ applied: string }>(await run("confirm_action", ctx));
197
226
  expect(applied.applied).toContain("LX52");
198
227
  expect(stateOf(ctx).bookings).toEqual([]);
199
228
 
@@ -207,7 +236,7 @@ describe("sensitive tools stage rather than act", () => {
207
236
  test("cancel_action clears the block, so a declined change does not wedge the desk", async () => {
208
237
  const ctx = makeCtx();
209
238
  await run("book_car_rental", { carId: "C2", days: 3 }, ctx);
210
- await run("cancel_action", {}, ctx);
239
+ await run("cancel_action", ctx);
211
240
  const staged = (await run("book_excursion", { excursionId: "E2" }, ctx)) as {
212
241
  awaitingConfirmation: boolean;
213
242
  };
@@ -225,12 +254,14 @@ describe("sensitive tools stage rather than act", () => {
225
254
 
226
255
  await run("to_hotel_assistant", { request: "a room" }, first);
227
256
  await run("book_hotel", { hotelId: "H3", nights: 1 }, first);
228
- await run("confirm_action", {}, first);
257
+ await run("confirm_action", first);
229
258
 
230
259
  expect(activeAssistant(stateOf(second))).toBe("primary");
231
260
  expect(stateOf(second).bookings).toEqual([]);
232
- expect(await run("confirm_action", {}, second)).toEqual({
233
- error: "Nothing is waiting for confirmation. Use the booking tool first.",
261
+ // The GATE is per-session too, not just the trip.
262
+ expect(gateFlow.position(second).state).toBe("browsing");
263
+ expect(await run("confirm_action", second)).toMatchObject({
264
+ error: expect.stringContaining('this conversation is at "browsing"'),
234
265
  });
235
266
  expect(stateOf(first).bookings).toHaveLength(1);
236
267
  });
@@ -283,8 +314,8 @@ describe("search tools", () => {
283
314
  test("lookup_booking reports the ticket the caller is actually holding", async () => {
284
315
  const ctx = makeCtx();
285
316
  await run("update_ticket", { flightId: "LX52" }, ctx);
286
- await run("confirm_action", {}, ctx);
287
- const booking = (await run("lookup_booking", {}, ctx)) as {
317
+ await run("confirm_action", ctx);
318
+ const booking = (await run("lookup_booking", ctx)) as {
288
319
  passenger: string;
289
320
  ticket: { flight: string; departs: string } | null;
290
321
  };
@@ -327,9 +358,9 @@ describe("tripView projection", () => {
327
358
  test("totals every confirmed booking", async () => {
328
359
  const ctx = makeCtx();
329
360
  await run("book_hotel", { hotelId: "H3", nights: 2 }, ctx); // 2 × 180
330
- await run("confirm_action", {}, ctx);
361
+ await run("confirm_action", ctx);
331
362
  await run("book_excursion", { excursionId: "E1" }, ctx); // 35
332
- await run("confirm_action", {}, ctx);
363
+ await run("confirm_action", ctx);
333
364
 
334
365
  const view = tripView(stateOf(ctx));
335
366
  expect(view.total).toBe(395);
@@ -1,5 +1,5 @@
1
1
  import "@alexkroman1/aai-ui/styles.css";
2
- import { AutoScroll, client, useAgentState, useTheme } from "@alexkroman1/aai-ui";
2
+ import { AutoScroll, client, useAgentState } from "@alexkroman1/aai-ui";
3
3
  import type { TripView } from "./shared.ts";
4
4
  import { SPECIALIST_IDS, SPECIALISTS, tripProjection } from "./shared.ts";
5
5
 
@@ -15,7 +15,6 @@ function deskLabel(id: (typeof DESKS)[number]): string {
15
15
  * the transcript — the whole delegation mechanism is otherwise invisible.
16
16
  */
17
17
  function DeskStrip({ active }: { active: TripView["assistant"] }) {
18
- const theme = useTheme();
19
18
  return (
20
19
  <div className="flex flex-wrap gap-1.5">
21
20
  {DESKS.map((id) => {
@@ -23,12 +22,9 @@ function DeskStrip({ active }: { active: TripView["assistant"] }) {
23
22
  return (
24
23
  <span
25
24
  key={id}
26
- className="rounded-full px-2.5 py-1 text-[11px] font-medium capitalize"
27
- style={{
28
- background: on ? theme.primary : theme.surface,
29
- color: on ? theme.bg : theme.text,
30
- opacity: on ? 1 : 0.55,
31
- }}
25
+ className={`rounded-full px-2.5 py-1 text-[11px] font-medium capitalize ${
26
+ on ? "bg-aai-primary text-aai-bg" : "bg-aai-surface text-aai-text opacity-55"
27
+ }`}
32
28
  >
33
29
  {deskLabel(id)}
34
30
  </span>
@@ -39,11 +35,10 @@ function DeskStrip({ active }: { active: TripView["assistant"] }) {
39
35
  }
40
36
 
41
37
  function ItinerarySidebar() {
42
- const theme = useTheme();
43
38
  const trip = useAgentState(tripProjection);
44
39
 
45
40
  return (
46
- <div className="flex flex-col gap-4 p-4 h-full min-h-0" style={{ color: theme.text }}>
41
+ <div className="flex flex-col gap-4 p-4 h-full min-h-0 text-aai-text">
47
42
  <div className="flex flex-col gap-2">
48
43
  <h3 className="text-sm font-bold opacity-60 uppercase tracking-wide">{trip.passenger}</h3>
49
44
  <DeskStrip active={trip.assistant} />
@@ -52,10 +47,7 @@ function ItinerarySidebar() {
52
47
  {/* The staged action — the browser half of the confirmation gate. What is
53
48
  on screen is exactly what the concierge just asked out loud. */}
54
49
  {trip.pending && (
55
- <div
56
- className="rounded-lg p-3 text-sm"
57
- style={{ background: theme.surface, border: `1px solid ${theme.primary}` }}
58
- >
50
+ <div className="rounded-lg p-3 text-sm bg-aai-surface border border-aai-primary">
59
51
  <p className="text-[11px] font-bold uppercase tracking-wide opacity-60">
60
52
  Waiting on your yes
61
53
  </p>
@@ -66,7 +58,7 @@ function ItinerarySidebar() {
66
58
  <div className="flex flex-col gap-2">
67
59
  <p className="text-[11px] font-bold uppercase tracking-wide opacity-60">Ticket</p>
68
60
  {trip.ticket ? (
69
- <div className="rounded-lg p-3" style={{ background: theme.surface }}>
61
+ <div className="rounded-lg p-3 bg-aai-surface">
70
62
  <p className="text-sm font-medium">
71
63
  {trip.ticket.flightId} · {trip.ticket.route}
72
64
  </p>
@@ -85,24 +77,20 @@ function ItinerarySidebar() {
85
77
  {trip.bookings.map((booking) => (
86
78
  <div
87
79
  key={booking.reference}
88
- className="flex items-center justify-between gap-3 rounded-lg p-3"
89
- style={{ background: theme.surface }}
80
+ className="flex items-center justify-between gap-3 rounded-lg p-3 bg-aai-surface"
90
81
  >
91
82
  <div className="min-w-0">
92
83
  <p className="truncate text-sm">{booking.summary}</p>
93
84
  <p className="text-xs opacity-60">{booking.reference}</p>
94
85
  </div>
95
- <span className="text-sm font-bold" style={{ color: theme.primary }}>
86
+ <span className="text-sm font-bold text-aai-primary">
96
87
  ${booking.price.toLocaleString("en-US")}
97
88
  </span>
98
89
  </div>
99
90
  ))}
100
- <div
101
- className="flex justify-between border-t pt-2 text-sm font-bold"
102
- style={{ borderColor: theme.border }}
103
- >
91
+ <div className="flex justify-between border-t border-aai-border pt-2 text-sm font-bold">
104
92
  <span>Total</span>
105
- <span style={{ color: theme.primary }}>${trip.total.toLocaleString("en-US")}</span>
93
+ <span className="text-aai-primary">${trip.total.toLocaleString("en-US")}</span>
106
94
  </div>
107
95
  </div>
108
96
  )}
@@ -24,6 +24,7 @@ import {
24
24
  activeAssistant,
25
25
  applyPending,
26
26
  describeAction,
27
+ gateFlow,
27
28
  note,
28
29
  SPECIALISTS,
29
30
  type SpecialistId,
@@ -111,28 +112,46 @@ export const completeOrEscalate = tripSlot.updateTool({
111
112
  * halts before a sensitive tool and resumes on approval; the halt here is that
112
113
  * every sensitive tool stages instead of acting, so the approval has somewhere
113
114
  * to arrive.
115
+ *
116
+ * **`when: "awaitingConfirmation"` is the other side of that halt.** It used to
117
+ * be a null check inside {@link applyPending} — "Nothing is waiting for
118
+ * confirmation. Use the booking tool first." — which is the question "where is
119
+ * this conversation", asked of the payload. The gate asks the machine instead,
120
+ * and its refusal carries the state's own instruction. `SETTLED` fires only when
121
+ * the body did NOT answer with a {@link ToolFailure}, so an application that
122
+ * failed leaves the change staged and the caller still being asked.
114
123
  */
115
- export const confirmAction = tripSlot.updateTool({
124
+ export const confirmAction = gateFlow.tool({
116
125
  description:
117
126
  "Apply the change the caller has just confirmed out loud. Only call this " +
118
127
  "after you have read the change back and heard a clear yes.",
119
- execute(_args, trip) {
120
- return applyPending(trip);
121
- },
128
+ when: "awaitingConfirmation",
129
+ send: { type: "SETTLED" },
130
+ execute: (_args, ctx) => tripSlot.update(ctx, (trip) => applyPending(trip)),
122
131
  });
123
132
 
124
- /** `cancel_action` — the caller said no. Drops the staged action, changes nothing. */
125
- export const cancelAction = tripSlot.updateTool({
133
+ /**
134
+ * `cancel_action` the caller said no. Drops the staged action, changes nothing.
135
+ *
136
+ * Gated for the same reason as `confirm_action`, and its own "nothing was
137
+ * waiting" arm is gone with the same argument.
138
+ */
139
+ export const cancelAction = gateFlow.tool({
126
140
  description:
127
141
  "Discard the change the caller just declined. Call this when they say no, " +
128
142
  "or when they want to change the details before confirming.",
129
- execute(_args, trip) {
130
- const action = trip.pending;
131
- if (!action) return { message: "Nothing was waiting for confirmation." };
132
- trip.pending = null;
133
- const described = describeAction(action);
134
- const summary = typeof described === "string" ? described : action.kind;
135
- note(trip, `Declined: ${summary}`);
136
- return { discarded: summary, message: "Nothing was changed." };
137
- },
143
+ when: "awaitingConfirmation",
144
+ send: { type: "SETTLED" },
145
+ execute: (_args, ctx) =>
146
+ tripSlot.update(ctx, (trip) => {
147
+ const action = trip.pending;
148
+ // Reachable only if the position and the payload disagree; see
149
+ // `applyPending`. Reported rather than thrown, mid-call.
150
+ if (!action) return { discarded: null, message: "Nothing was staged after all." };
151
+ trip.pending = null;
152
+ const described = describeAction(action);
153
+ const summary = typeof described === "string" ? described : action.kind;
154
+ note(trip, `Declined: ${summary}`);
155
+ return { discarded: summary, message: "Nothing was changed." };
156
+ }),
138
157
  });
@@ -35,7 +35,15 @@
35
35
  * agent that can rebook a flight and one that can rebook a flight *by mistake*.
36
36
  */
37
37
 
38
- import { type DeepReadonly, pushCapped, sessionSlot, type ToolFailure } from "@alexkroman1/aai";
38
+ import {
39
+ type DeepReadonly,
40
+ type DialogSpec,
41
+ dialog,
42
+ pushCapped,
43
+ sessionSlot,
44
+ type ToolContext,
45
+ type ToolFailure,
46
+ } from "@alexkroman1/aai";
39
47
 
40
48
  // ─── The booking world ───────────────────────────────────────────────────────
41
49
  // Their notebook downloads a sqlite database of a real airline's schedule and
@@ -341,6 +349,51 @@ export function note(state: TripState, entry: string): void {
341
349
 
342
350
  // ─── The confirmation gate ───────────────────────────────────────────────────
343
351
 
352
+ /**
353
+ * The gate as a STATE MAP — their `interrupt_before`, which is a graph construct.
354
+ *
355
+ * This port had to hand-roll it: a voice session had no graph, so "halt before a
356
+ * sensitive tool and resume on approval" became "every sensitive tool STAGES and
357
+ * `confirm_action` is the only thing that applies one". That half is unchanged
358
+ * and is still the mechanism — see {@link stageAction}. What the machine adds is
359
+ * the PRECONDITION on the other side of the halt: `confirm_action` and
360
+ * `cancel_action` are legal only while something is actually waiting, which was
361
+ * a null check inside each of them and is now `when`.
362
+ *
363
+ * **The staging tools are deliberately NOT gated on `browsing`.**
364
+ * {@link stageAction}'s refusal NAMES the sentence already waiting, which is what
365
+ * lets the model settle that one first and restage the rest; a state gate can
366
+ * only say "you are in awaitingConfirmation", since a state's instruction is
367
+ * static. So the specific refusal stays where it is and the flow follows it.
368
+ */
369
+ const gateSpec = {
370
+ initial: "browsing",
371
+ states: {
372
+ browsing: {
373
+ instruction:
374
+ "Nothing is waiting for the caller's yes. Stage a change with a booking tool first.",
375
+ on: { STAGED: "awaitingConfirmation" },
376
+ },
377
+ awaitingConfirmation: {
378
+ instruction:
379
+ "Read the staged change back and hear a clear yes or no, then use confirm_action or cancel_action.",
380
+ on: { SETTLED: "browsing" },
381
+ },
382
+ },
383
+ } as const satisfies DialogSpec;
384
+
385
+ /**
386
+ * Whether a change is waiting on the caller's word.
387
+ *
388
+ * Its own slot, beside {@link tripSlot}: the flow holds the POSITION and the
389
+ * trip holds the staged action itself, because an inspectable
390
+ * {@link PendingAction} is what `confirm_action` re-derives the effect from. One
391
+ * tool call always moves both — {@link stageAction} sends `STAGED` in the same
392
+ * synchronous window it writes `pending` in, and the two settling tools send
393
+ * `SETTLED` only on success.
394
+ */
395
+ export const gateFlow = dialog("gate", gateSpec);
396
+
344
397
  /**
345
398
  * Describe a staged action in one sentence, in the second person — this is
346
399
  * read aloud, so it is a question's worth of text and not a receipt.
@@ -396,6 +449,7 @@ export function describeAction(action: DeepReadonly<PendingAction>): string | To
396
449
  * model ask about that one first and restage the rest afterwards.
397
450
  */
398
451
  export function stageAction(
452
+ ctx: ToolContext,
399
453
  state: TripState,
400
454
  action: PendingAction,
401
455
  ):
@@ -413,6 +467,11 @@ export function stageAction(
413
467
  };
414
468
  }
415
469
  state.pending = action;
470
+ // The flow moves in the SAME synchronous window `pending` is written in, so
471
+ // the position and the payload cannot be observed disagreeing. `gateFlow` is
472
+ // a different slot from `tripSlot`, so this is not a nested write to the draft
473
+ // being held — the open-draft guard is per slot.
474
+ gateFlow.send(ctx, { type: "STAGED" });
416
475
  note(state, `Awaiting confirmation: ${described}`);
417
476
  return {
418
477
  awaitingConfirmation: true,
@@ -421,13 +480,21 @@ export function stageAction(
421
480
  };
422
481
  }
423
482
 
424
- /** Apply the staged action. The ONE place any of them takes effect. */
483
+ /**
484
+ * Apply the staged action. The ONE place any of them takes effect.
485
+ *
486
+ * The "nothing is waiting" guard is gone: `confirm_action` is gated on
487
+ * `awaitingConfirmation`, so reaching here means something IS staged. The
488
+ * remaining `!action` arm is the flow and the payload disagreeing, which one
489
+ * synchronous window per transition is what rules out — it reports rather than
490
+ * throwing, because a live call is the wrong place to find out.
491
+ */
425
492
  export function applyPending(
426
493
  state: TripState,
427
494
  ): { applied: string; reference?: string } | ToolFailure {
428
495
  const action = state.pending;
429
496
  if (!action) {
430
- return { error: "Nothing is waiting for confirmation. Use the booking tool first." };
497
+ return { error: "Nothing is staged after all ask the caller again what they want." };
431
498
  }
432
499
  const described = describeAction(action);
433
500
  if (typeof described !== "string") return described;
@@ -10,8 +10,8 @@ export default tripSlot.updateTool({
10
10
  carId: z.string().max(20).describe("The car id from search_car_rentals, e.g. 'C2'"),
11
11
  days: z.number().int().min(1).max(60).describe("How many days"),
12
12
  }),
13
- execute(args, trip) {
14
- return stageAction(trip, {
13
+ execute(args, trip, ctx) {
14
+ return stageAction(ctx, trip, {
15
15
  kind: "book_car",
16
16
  carId: args.carId.toUpperCase(),
17
17
  days: args.days,
@@ -9,8 +9,8 @@ export default tripSlot.updateTool({
9
9
  inputSchema: z.object({
10
10
  excursionId: z.string().max(20).describe("The excursion id from search_excursions, e.g. 'E2'"),
11
11
  }),
12
- execute(args, trip) {
13
- return stageAction(trip, {
12
+ execute(args, trip, ctx) {
13
+ return stageAction(ctx, trip, {
14
14
  kind: "book_excursion",
15
15
  excursionId: args.excursionId.toUpperCase(),
16
16
  });
@@ -10,8 +10,8 @@ export default tripSlot.updateTool({
10
10
  hotelId: z.string().max(20).describe("The hotel id from search_hotels, e.g. 'H1'"),
11
11
  nights: z.number().int().min(1).max(30).describe("How many nights"),
12
12
  }),
13
- execute(args, trip) {
14
- return stageAction(trip, {
13
+ execute(args, trip, ctx) {
14
+ return stageAction(ctx, trip, {
15
15
  kind: "book_hotel",
16
16
  hotelId: args.hotelId.toUpperCase(),
17
17
  nights: args.nights,
@@ -9,8 +9,8 @@ export default tripSlot.updateTool({
9
9
  description:
10
10
  "Cancel the caller's ticket outright. This does NOT cancel anything yet — " +
11
11
  "it stages the cancellation so you can read it back and hear a yes.",
12
- execute(_args, trip) {
12
+ execute(_args, trip, ctx) {
13
13
  if (!trip.ticket) return { error: "There is no ticket to cancel." };
14
- return stageAction(trip, { kind: "cancel_ticket" });
14
+ return stageAction(ctx, trip, { kind: "cancel_ticket" });
15
15
  },
16
16
  });
@@ -13,10 +13,10 @@ export default tripSlot.updateTool({
13
13
  inputSchema: z.object({
14
14
  flightId: z.string().max(20).describe("The flight to move to, e.g. 'LX52'"),
15
15
  }),
16
- execute(args, trip) {
16
+ execute(args, trip, ctx) {
17
17
  if (!trip.ticket) {
18
18
  return { error: "This caller has no ticket to move — it was cancelled on this call." };
19
19
  }
20
- return stageAction(trip, { kind: "update_ticket", flightId: args.flightId.toUpperCase() });
20
+ return stageAction(ctx, trip, { kind: "update_ticket", flightId: args.flightId.toUpperCase() });
21
21
  },
22
22
  });