@alexkroman1/aai-cli 6.11.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 (234) hide show
  1. package/README.md +1 -0
  2. package/dist/{_agent-DpH2pBJd.mjs → _agent-BTwZJu4P.mjs} +2 -2
  3. package/dist/{_bundler-C2BDwU7r.mjs → _bundler-CDuYl5Gb.mjs} +2 -2
  4. package/dist/_dev-env.d.ts +22 -2
  5. package/dist/{_dev-server-LLLWnFBs.mjs → _dev-server-Me06wgeF.mjs} +38 -47
  6. package/dist/_dev-server.d.ts +0 -17
  7. package/dist/{_init-C57bWAQ8.mjs → _init-BhEfQ6Yi.mjs} +3 -2
  8. package/dist/{_preflight-BtfaYtbE.mjs → _preflight-Co1G9Jww.mjs} +1 -1
  9. package/dist/_preflight.d.ts +1 -1
  10. package/dist/_resource-commands.d.ts +23 -0
  11. package/dist/{_slug-api-BxrsJXVI.mjs → _slug-api-z-USxPtL.mjs} +16 -12
  12. package/dist/_slug-api.d.ts +5 -2
  13. package/dist/{_studio-U3jhKlA3.mjs → _studio-DR5yzZ1T.mjs} +2 -1
  14. package/dist/{_templates-Dxb_P2Wz.mjs → _templates-4WcKOjS5.mjs} +5 -6
  15. package/dist/_templates.d.ts +3 -4
  16. package/dist/_workflow-scan.d.ts +108 -0
  17. package/dist/{build-Dfza2pRI.mjs → build-BBxf8vom.mjs} +6 -3
  18. package/dist/cli.mjs +346 -349
  19. package/dist/{client-bundler-BEIqgOtd.mjs → client-bundler-CWnG42cU.mjs} +2 -1
  20. package/dist/client-bundler.mjs +1 -1
  21. package/dist/{delete-vbpjGzqs.mjs → delete-Dl_LSfvI.mjs} +3 -3
  22. package/dist/{deploy-B8lubiRT.mjs → deploy-DRC8jWr8.mjs} +5 -5
  23. package/dist/{dev-Uq5ujP8a.mjs → dev-BvcctCn2.mjs} +1 -1
  24. package/dist/eval-DfQ8cm5p.mjs +56 -0
  25. package/dist/eval.d.ts +43 -0
  26. package/dist/{init-l6xfU4xX.mjs → init-CUo87gcI.mjs} +3 -3
  27. package/dist/{login-C71-qz8F.mjs → login-BBuM1sxH.mjs} +3 -4
  28. package/dist/{logs-BWVGSjLR.mjs → logs-CQ2T46dr.mjs} +2 -2
  29. package/dist/project-config.mjs +1 -1
  30. package/dist/scaffold/CLAUDE.md +442 -80
  31. package/dist/scaffold/package.json +6 -3
  32. package/dist/scaffold/server.mjs +11 -4
  33. package/dist/scaffold/tsconfig.json +0 -1
  34. package/dist/{secret-BuMuFR4B.mjs → secret-Ct34kdgU.mjs} +2 -2
  35. package/dist/{storage-DzRZ-eCw.mjs → storage-i_79ZwAR.mjs} +2 -2
  36. package/dist/{studio-BCNUpDgP.mjs → studio-CzTzPqe1.mjs} +4 -4
  37. package/dist/templates/briefing-desk/agent.test.ts +350 -0
  38. package/dist/templates/briefing-desk/agent.ts +30 -0
  39. package/dist/templates/briefing-desk/shared.ts +200 -0
  40. package/dist/templates/briefing-desk/system-prompt.md +39 -0
  41. package/dist/templates/briefing-desk/tools/briefing_so_far.ts +35 -0
  42. package/dist/templates/briefing-desk/tools/research_topic.ts +88 -0
  43. package/dist/templates/briefing-desk/tools/verify_claim.ts +77 -0
  44. package/dist/templates/call-audit/agent.eval.test.ts +474 -0
  45. package/dist/templates/call-audit/agent.test.ts +57 -204
  46. package/dist/templates/call-audit/agent.ts +32 -19
  47. package/dist/templates/call-audit/client.tsx +17 -54
  48. package/dist/templates/call-audit/workflows/audit.ts +11 -18
  49. package/dist/templates/call-audit/workflows/ingest.ts +106 -114
  50. package/dist/templates/call-audit/workflows/media.ts +2 -12
  51. package/dist/templates/call-audit/workflows/summarize.ts +47 -52
  52. package/dist/templates/call-audit/workflows/sync-api.ts +7 -7
  53. package/dist/templates/code-interpreter/agent.eval.test.ts +165 -0
  54. package/dist/templates/dispatch-center/agent.eval.test.ts +349 -0
  55. package/dist/templates/dispatch-center/agent.test.ts +149 -25
  56. package/dist/templates/dispatch-center/client.tsx +239 -129
  57. package/dist/templates/dispatch-center/shared.ts +99 -1
  58. package/dist/templates/dispatch-center/system-prompt.md +3 -1
  59. package/dist/templates/dispatch-center/tools/incident_add_note.ts +16 -13
  60. package/dist/templates/dispatch-center/tools/incident_create.ts +19 -1
  61. package/dist/templates/dispatch-center/tools/incident_escalate.ts +68 -59
  62. package/dist/templates/dispatch-center/tools/incident_triage.ts +57 -43
  63. package/dist/templates/dispatch-center/tools/incident_update_status.ts +49 -40
  64. package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +8 -2
  65. package/dist/templates/dispatch-center/tools/resources_dispatch.ts +69 -52
  66. package/dist/templates/dispatch-center/tools/resources_update_status.ts +45 -41
  67. package/dist/templates/embedded-assets/agent.eval.test.ts +138 -0
  68. package/dist/templates/embedded-assets/agent.test.ts +17 -14
  69. package/dist/templates/health-assistant/agent.eval.test.ts +167 -0
  70. package/dist/templates/health-assistant/agent.test.ts +22 -4
  71. package/dist/templates/infocom-adventure/agent.eval.test.ts +158 -0
  72. package/dist/templates/infocom-adventure/agent.test.ts +53 -24
  73. package/dist/templates/infocom-adventure/agent.ts +21 -0
  74. package/dist/templates/infocom-adventure/client.tsx +246 -164
  75. package/dist/templates/infocom-adventure/shared.ts +20 -9
  76. package/dist/templates/infocom-adventure/system-prompt.md +1 -1
  77. package/dist/templates/infocom-adventure/tools/game_state_move.ts +3 -2
  78. package/dist/templates/link-digest/agent.eval.test.ts +195 -0
  79. package/dist/templates/link-digest/agent.test.ts +24 -19
  80. package/dist/templates/link-digest/client.tsx +47 -61
  81. package/dist/templates/link-digest/workflows/digest.ts +19 -31
  82. package/dist/templates/math-buddy/agent.eval.test.ts +146 -0
  83. package/dist/templates/night-owl/agent.eval.test.ts +272 -0
  84. package/dist/templates/night-owl/agent.test.ts +70 -19
  85. package/dist/templates/night-owl/agent.ts +5 -0
  86. package/dist/templates/night-owl/client.tsx +56 -56
  87. package/dist/templates/night-owl/shared.ts +24 -0
  88. package/dist/templates/night-owl/tools/recommend.ts +18 -6
  89. package/dist/templates/personal-finance/agent.eval.test.ts +168 -0
  90. package/dist/templates/personal-finance/system-prompt.md +4 -0
  91. package/dist/templates/pipeline-simple/agent.eval.test.ts +78 -0
  92. package/dist/templates/pipeline-simple/agent.ts +2 -2
  93. package/dist/templates/pizza-ordering/agent.eval.test.ts +171 -0
  94. package/dist/templates/pizza-ordering/agent.test.ts +42 -27
  95. package/dist/templates/pizza-ordering/client.tsx +9 -26
  96. package/dist/templates/pizza-ordering/shared.ts +20 -1
  97. package/dist/templates/pizza-ordering/system-prompt.md +6 -0
  98. package/dist/templates/plan-and-execute/agent.eval.test.ts +209 -0
  99. package/dist/templates/plan-and-execute/agent.test.ts +99 -58
  100. package/dist/templates/plan-and-execute/agent.ts +1 -1
  101. package/dist/templates/plan-and-execute/client.tsx +12 -15
  102. package/dist/templates/plan-and-execute/shared.ts +71 -2
  103. package/dist/templates/plan-and-execute/tools/plan_status.ts +18 -6
  104. package/dist/templates/plan-and-execute/tools/revise_plan.ts +18 -10
  105. package/dist/templates/plan-and-execute/tools/start_plan.ts +13 -2
  106. package/dist/templates/plan-and-execute/tools/work_next_step.ts +70 -22
  107. package/dist/templates/podcast-digest/agent.eval.test.ts +411 -0
  108. package/dist/templates/podcast-digest/agent.test.ts +717 -0
  109. package/dist/templates/podcast-digest/agent.ts +139 -0
  110. package/dist/templates/podcast-digest/client.tsx +154 -0
  111. package/dist/templates/podcast-digest/workflows/digest.ts +444 -0
  112. package/dist/templates/podcast-digest/workflows/feeds.ts +507 -0
  113. package/dist/templates/podcast-digest/workflows/slack.ts +88 -0
  114. package/dist/templates/recap-workflow/agent.eval.test.ts +366 -0
  115. package/dist/templates/recap-workflow/agent.test.ts +97 -94
  116. package/dist/templates/recap-workflow/tools/recap_progress.ts +9 -12
  117. package/dist/templates/recap-workflow/tools/recap_status.ts +8 -9
  118. package/dist/templates/recap-workflow/tools/request_recap.ts +2 -1
  119. package/dist/templates/recap-workflow/workflows/recap.ts +36 -40
  120. package/dist/templates/redline/agent.eval.test.ts +209 -0
  121. package/dist/templates/redline/agent.test.ts +17 -15
  122. package/dist/templates/redline/client.tsx +12 -12
  123. package/dist/templates/redline/workflows/redline.ts +19 -31
  124. package/dist/templates/research-workflow/agent.eval.test.ts +329 -0
  125. package/dist/templates/research-workflow/agent.test.ts +60 -59
  126. package/dist/templates/research-workflow/tools/research_progress.ts +9 -12
  127. package/dist/templates/research-workflow/tools/research_status.ts +9 -11
  128. package/dist/templates/research-workflow/workflows/research.ts +44 -61
  129. package/dist/templates/retail/address.ts +87 -1
  130. package/dist/templates/retail/agent.eval.test.ts +314 -0
  131. package/dist/templates/retail/agent.test.ts +195 -112
  132. package/dist/templates/retail/agent.ts +35 -1
  133. package/dist/templates/retail/cancel.ts +116 -0
  134. package/dist/templates/retail/client.tsx +265 -117
  135. package/dist/templates/retail/payment.ts +107 -0
  136. package/dist/templates/retail/pending.test.ts +196 -0
  137. package/dist/templates/retail/pending.ts +137 -0
  138. package/dist/templates/retail/registry.test.ts +150 -7
  139. package/dist/templates/retail/returns.ts +102 -0
  140. package/dist/templates/retail/shared.test.ts +42 -0
  141. package/dist/templates/retail/shared.ts +35 -0
  142. package/dist/templates/retail/store.test.ts +84 -15
  143. package/dist/templates/retail/store.ts +231 -48
  144. package/dist/templates/retail/swap.test.ts +4 -4
  145. package/dist/templates/retail/swap.ts +248 -15
  146. package/dist/templates/retail/system-prompt.md +69 -28
  147. package/dist/templates/retail/tools/cancel_change.ts +33 -0
  148. package/dist/templates/retail/tools/cancel_pending_order.ts +16 -60
  149. package/dist/templates/retail/tools/confirm_change.ts +42 -0
  150. package/dist/templates/retail/tools/exchange_delivered_order_items.ts +22 -67
  151. package/dist/templates/retail/tools/find_user_id_by_email.ts +8 -5
  152. package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +4 -5
  153. package/dist/templates/retail/tools/get_item_details.ts +3 -4
  154. package/dist/templates/retail/tools/get_order_details.ts +3 -4
  155. package/dist/templates/retail/tools/get_product_details.ts +3 -4
  156. package/dist/templates/retail/tools/get_user_details.ts +2 -2
  157. package/dist/templates/retail/tools/list_all_product_types.ts +5 -5
  158. package/dist/templates/retail/tools/modify_pending_order_address.ts +13 -25
  159. package/dist/templates/retail/tools/modify_pending_order_items.ts +23 -62
  160. package/dist/templates/retail/tools/modify_pending_order_payment.ts +16 -78
  161. package/dist/templates/retail/tools/modify_user_address.ts +13 -16
  162. package/dist/templates/retail/tools/return_delivered_order_items.ts +17 -72
  163. package/dist/templates/retail/tools/transfer_to_human_agents.ts +12 -6
  164. package/dist/templates/simple/agent.eval.test.ts +54 -0
  165. package/dist/templates/solo-rpg/agent.eval.test.ts +320 -0
  166. package/dist/templates/solo-rpg/agent.test.ts +227 -48
  167. package/dist/templates/solo-rpg/client.tsx +38 -37
  168. package/dist/templates/solo-rpg/shared.ts +145 -19
  169. package/dist/templates/solo-rpg/system-prompt.md +3 -2
  170. package/dist/templates/solo-rpg/tools/action_roll.ts +82 -63
  171. package/dist/templates/solo-rpg/tools/burn_momentum.ts +85 -50
  172. package/dist/templates/solo-rpg/tools/check_state.ts +24 -7
  173. package/dist/templates/solo-rpg/tools/load_game.ts +13 -1
  174. package/dist/templates/solo-rpg/tools/save_game.ts +16 -7
  175. package/dist/templates/solo-rpg/tools/setup_character.ts +22 -2
  176. package/dist/templates/solo-rpg/tools/update_state.ts +113 -100
  177. package/dist/templates/spoken-summary/agent.eval.test.ts +342 -0
  178. package/dist/templates/spoken-summary/agent.test.ts +68 -97
  179. package/dist/templates/spoken-summary/agent.ts +30 -17
  180. package/dist/templates/spoken-summary/client.tsx +10 -50
  181. package/dist/templates/spoken-summary/workflows/summarize.ts +17 -22
  182. package/dist/templates/spoken-summary/workflows/transcribe.ts +19 -26
  183. package/dist/templates/support-line/agent.eval.test.ts +273 -0
  184. package/dist/templates/support-line/agent.test.ts +11 -16
  185. package/dist/templates/support-line/agent.ts +1 -1
  186. package/dist/templates/support-line/client.tsx +9 -9
  187. package/dist/templates/support-line/nodes.ts +100 -0
  188. package/dist/templates/support-line/procedure.ts +407 -0
  189. package/dist/templates/support-line/system-prompt.md +2 -1
  190. package/dist/templates/support-line/tools/answer_question.ts +66 -4
  191. package/dist/templates/transcription-workflow/agent.eval.test.ts +234 -0
  192. package/dist/templates/transcription-workflow/agent.test.ts +94 -194
  193. package/dist/templates/transcription-workflow/agent.ts +1 -1
  194. package/dist/templates/transcription-workflow/client.tsx +17 -42
  195. package/dist/templates/transcription-workflow/workflows/batch.ts +19 -22
  196. package/dist/templates/transcription-workflow/workflows/normalize.ts +90 -186
  197. package/dist/templates/transcription-workflow/workflows/stitch.ts +0 -11
  198. package/dist/templates/transcription-workflow/workflows/stream.ts +4 -5
  199. package/dist/templates/transcription-workflow/workflows/sync-api.ts +6 -6
  200. package/dist/templates/transcription-workflow/workflows/transcribe.ts +32 -26
  201. package/dist/templates/transcription-workflow/workflows/wav.ts +0 -31
  202. package/dist/templates/travel-concierge/agent.eval.test.ts +323 -0
  203. package/dist/templates/travel-concierge/agent.test.ts +174 -43
  204. package/dist/templates/travel-concierge/agent.ts +18 -9
  205. package/dist/templates/travel-concierge/client.tsx +11 -23
  206. package/dist/templates/travel-concierge/routing.ts +34 -15
  207. package/dist/templates/travel-concierge/shared.ts +180 -12
  208. package/dist/templates/travel-concierge/system-prompt.md +7 -1
  209. package/dist/templates/travel-concierge/tools/book_car_rental.ts +8 -5
  210. package/dist/templates/travel-concierge/tools/book_excursion.ts +8 -5
  211. package/dist/templates/travel-concierge/tools/book_hotel.ts +8 -5
  212. package/dist/templates/travel-concierge/tools/cancel_ticket.ts +8 -5
  213. package/dist/templates/travel-concierge/tools/search_car_rentals.ts +9 -5
  214. package/dist/templates/travel-concierge/tools/search_excursions.ts +9 -5
  215. package/dist/templates/travel-concierge/tools/search_flights.ts +9 -6
  216. package/dist/templates/travel-concierge/tools/search_hotels.ts +9 -5
  217. package/dist/templates/travel-concierge/tools/update_ticket.ts +8 -5
  218. package/dist/templates/web-researcher/agent.eval.test.ts +121 -0
  219. package/dist/templates/web-researcher/system-prompt.md +15 -4
  220. package/dist/{test-9kPKJI-w.mjs → test-BvyT6sAc.mjs} +24 -10
  221. package/dist/test.d.ts +29 -5
  222. package/dist/{worker-bundler-CGD4r8Kc.mjs → worker-bundler-CCVEDjm1.mjs} +241 -3
  223. package/dist/worker-bundler.mjs +1 -1
  224. package/dist/{workflow-CFpxOFfQ.mjs → workflow-BflATMmU.mjs} +47 -12
  225. package/dist/workflow-bundler.d.ts +6 -1
  226. package/dist/workflow.d.ts +1 -1
  227. package/package.json +5 -4
  228. package/dist/eject-31gjtaHF.mjs +0 -116
  229. package/dist/eject.d.ts +0 -60
  230. package/dist/templates/call-audit/workflows/temp-media.ts +0 -138
  231. package/dist/templates/infocom-adventure/tools/game_state_history.ts +0 -14
  232. package/dist/templates/support-line/graph.ts +0 -224
  233. package/dist/{_config-D_s09e7g.mjs → _config-CmJOFsAP.mjs} +1 -1
  234. /package/dist/templates/plan-and-execute/{graph.ts → procedure.ts} +0 -0
@@ -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,15 @@ 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
+ type SpecialistId,
32
+ tripProjection,
33
+ tripSlot,
34
+ tripView,
35
+ } from "./shared.ts";
22
36
 
23
37
  // ─── Harness ─────────────────────────────────────────────────────────────────
24
38
 
@@ -26,17 +40,31 @@ import { activeAssistant, FLIGHTS, tripProjection, tripSlot, tripView } from "./
26
40
  * below rests on. */
27
41
  const makeCtx = (): ToolContext => createToolContext();
28
42
 
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);
43
+ /** A tool by the name the model calls it by, bound to this agent. The lookup,
44
+ * its "no such tool" message and the args-or-context shape are all
45
+ * `toolRunner`'s (`@alexkroman1/aai/testing`); what is local is only which
46
+ * agent it runs against. */
47
+ const run = toolRunner(agentDef);
34
48
 
35
49
  /** The state a tool just mutated, read back the way `syncState` reads it. */
36
50
  function stateOf(ctx: ToolContext) {
37
51
  return tripSlot.get(ctx);
38
52
  }
39
53
 
54
+ /**
55
+ * Put the call at a desk the way the model has to: through that desk's own
56
+ * delegation tool.
57
+ *
58
+ * Every desk tool checks the stack first (`requireDesk`) and refuses from
59
+ * anywhere else, so a spec driving one has to stand where the model would. It is
60
+ * one line per test rather than a shared `beforeEach` deliberately — which desk
61
+ * a tool belongs to is what the block below is about, so it stays visible at the
62
+ * call site.
63
+ */
64
+ const atDesk = async (id: SpecialistId, ctx: ToolContext): Promise<void> => {
65
+ await run(`to_${id}_assistant`, { request: "what the caller asked for" }, ctx);
66
+ };
67
+
40
68
  // ─── 1. The dialog stack ─────────────────────────────────────────────────────
41
69
 
42
70
  describe("dialog stack (routing.ts)", () => {
@@ -85,6 +113,72 @@ describe("dialog stack (routing.ts)", () => {
85
113
  });
86
114
  });
87
115
 
116
+ // ─── 1b. The desk gate ───────────────────────────────────────────────────────
117
+ //
118
+ // What their graph gets from binding a tool set per node, and what this port
119
+ // used to only ask the prompt for. The eval (`agent.eval.test.ts`) is what
120
+ // measured the asking losing — 0 of 5 live runs, the model searching hotels
121
+ // while the stack said `primary` — so these are the tests that make the stack
122
+ // binding rather than descriptive.
123
+
124
+ describe("the desk gate (requireDesk)", () => {
125
+ const DESK_TOOLS = [
126
+ ["search_flights", { route: "Boston" }, "flight"],
127
+ ["update_ticket", { flightId: "LX52" }, "flight"],
128
+ ["cancel_ticket", {}, "flight"],
129
+ ["search_hotels", { city: "Boston" }, "hotel"],
130
+ ["book_hotel", { hotelId: "H1", nights: 2 }, "hotel"],
131
+ ["search_car_rentals", { city: "Boston" }, "car_rental"],
132
+ ["book_car_rental", { carId: "C2", days: 3 }, "car_rental"],
133
+ ["search_excursions", { city: "Boston" }, "excursion"],
134
+ ["book_excursion", { excursionId: "E2" }, "excursion"],
135
+ ] as const;
136
+
137
+ test.each(DESK_TOOLS)("%s refuses at the concierge desk", async (name, args, desk) => {
138
+ const ctx = makeCtx();
139
+ const refused = (await run(name, args, ctx)) as { error?: string };
140
+ // The refusal NAMES the way in, which is what the model recovers from
141
+ // inside the same turn — a bare "not allowed" would leave it guessing.
142
+ expect(refused.error).toContain(`to_${desk}_assistant`);
143
+ // And nothing happened: no search result, no staging, no stack movement.
144
+ expect(stateOf(ctx).pending).toBeNull();
145
+ expect(activeAssistant(stateOf(ctx))).toBe("primary");
146
+ expect(gateFlow.position(ctx).state).toBe("browsing");
147
+ });
148
+
149
+ test.each(DESK_TOOLS)("%s refuses from ANOTHER desk", async (name, args, desk) => {
150
+ const ctx = makeCtx();
151
+ // The excursions desk for anything that is not its own, and the flight desk
152
+ // for the excursion tools — so every case really is a wrong desk rather
153
+ // than a coincidence of ordering.
154
+ const wrong = desk === "excursion" ? "flight" : "excursion";
155
+ await atDesk(wrong, ctx);
156
+ const refused = (await run(name, args, ctx)) as { error?: string };
157
+ expect(refused.error).toContain(`to_${desk}_assistant`);
158
+ expect(refused.error).toContain(desk === "excursion" ? "flight desk" : "excursions desk");
159
+ expect(stateOf(ctx).pending).toBeNull();
160
+ });
161
+
162
+ test.each(DESK_TOOLS)("%s works once its own desk holds the call", async (name, args, desk) => {
163
+ // The other half, and the one that makes the block above non-vacuous: a
164
+ // gate that refused everywhere would pass every assertion up to here.
165
+ const ctx = makeCtx();
166
+ await atDesk(desk, ctx);
167
+ expect(await run(name, args, ctx)).not.toMatchObject({
168
+ error: expect.stringContaining("belongs to the"),
169
+ });
170
+ });
171
+
172
+ test("the concierge's own tools are NOT gated", async () => {
173
+ // `lookup_booking` is the concierge's, and the gate must not make the desk
174
+ // unable to answer "what am I holding?" from wherever the call is.
175
+ const ctx = makeCtx();
176
+ await atDesk("hotel", ctx);
177
+ const booking = (await run("lookup_booking", ctx)) as { passenger: string };
178
+ expect(booking.passenger).toBe("Nadia Rossi");
179
+ });
180
+ });
181
+
88
182
  // ─── 2. The confirmation gate ────────────────────────────────────────────────
89
183
  //
90
184
  // Their graph halts before a sensitive tool (`interrupt_before`) and resumes on
@@ -94,13 +188,14 @@ describe("dialog stack (routing.ts)", () => {
94
188
 
95
189
  describe("sensitive tools stage rather than act", () => {
96
190
  test.each([
97
- ["update_ticket", { flightId: "LX52" }],
98
- ["cancel_ticket", {}],
99
- ["book_hotel", { hotelId: "H1", nights: 2 }],
100
- ["book_car_rental", { carId: "C2", days: 3 }],
101
- ["book_excursion", { excursionId: "E2" }],
102
- ])("%s changes nothing on its own", async (name, args) => {
191
+ ["update_ticket", { flightId: "LX52" }, "flight"],
192
+ ["cancel_ticket", {}, "flight"],
193
+ ["book_hotel", { hotelId: "H1", nights: 2 }, "hotel"],
194
+ ["book_car_rental", { carId: "C2", days: 3 }, "car_rental"],
195
+ ["book_excursion", { excursionId: "E2" }, "excursion"],
196
+ ] as const)("%s changes nothing on its own", async (name, args, desk) => {
103
197
  const ctx = makeCtx();
198
+ await atDesk(desk, ctx);
104
199
  const before = structuredClone(stateOf(ctx));
105
200
 
106
201
  const staged = (await run(name, args, ctx)) as { awaitingConfirmation: boolean };
@@ -114,38 +209,51 @@ describe("sensitive tools stage rather than act", () => {
114
209
 
115
210
  test("confirm_action applies the staged change, and only then", async () => {
116
211
  const ctx = makeCtx();
212
+ await atDesk("hotel", ctx);
117
213
  await run("book_hotel", { hotelId: "H1", nights: 3 }, ctx);
118
214
 
119
- const applied = (await run("confirm_action", {}, ctx)) as {
120
- applied: string;
121
- reference: string;
122
- };
123
- expect(applied.applied).toContain("Harborview Suites");
215
+ // Staging moved the gate, in the same window it wrote `pending`.
216
+ expect(gateFlow.position(ctx).state).toBe("awaitingConfirmation");
217
+
218
+ // A gated tool answers the flow's POSITION wrapped around the body's own
219
+ // return value, so the applied sentence is under `result`. `okPosition`
220
+ // keeps the position and THROWS on a refusal, quoting it — where the cast
221
+ // it replaces read `undefined` off the failure and died three assertions
222
+ // later on a property of undefined.
223
+ const confirmed = okPosition<{ applied: string; reference: string }>(
224
+ await run("confirm_action", ctx),
225
+ );
226
+ expect(confirmed.result.applied).toContain("Harborview Suites");
124
227
  // 3 nights at $265.
125
- expect(applied.applied).toContain("$795");
228
+ expect(confirmed.result.applied).toContain("$795");
229
+ // SETTLED: the gate re-armed as part of the same call.
230
+ expect(confirmed.state).toBe("browsing");
126
231
 
127
232
  const state = stateOf(ctx);
128
233
  expect(state.pending).toBeNull();
129
234
  expect(state.bookings).toHaveLength(1);
130
- expect(state.bookings[0]?.reference).toBe(applied.reference);
235
+ expect(state.bookings[0]?.reference).toBe(confirmed.result.reference);
131
236
 
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.",
237
+ // The gate re-arms, and now REFUSES rather than reporting an empty apply
238
+ // `when: "awaitingConfirmation"` is what a second confirm meets.
239
+ expect(await run("confirm_action", ctx)).toMatchObject({
240
+ error: expect.stringContaining('this conversation is at "browsing"'),
135
241
  });
136
242
  });
137
243
 
138
244
  test("cancel_action drops the staged change and leaves the booking alone", async () => {
139
245
  const ctx = makeCtx();
246
+ await atDesk("flight", ctx);
140
247
  await run("update_ticket", { flightId: "LX54" }, ctx);
141
- const dropped = (await run("cancel_action", {}, ctx)) as { discarded: string };
142
- expect(dropped.discarded).toContain("LX54");
248
+ const dropped = okPosition<{ discarded: string }>(await run("cancel_action", ctx));
249
+ expect(dropped.result.discarded).toContain("LX54");
250
+ expect(dropped.state).toBe("browsing");
143
251
 
144
252
  const state = stateOf(ctx);
145
253
  expect(state.pending).toBeNull();
146
254
  expect(state.ticket?.flightId).toBe("LX40");
147
- expect(await run("cancel_action", {}, ctx)).toEqual({
148
- message: "Nothing was waiting for confirmation.",
255
+ expect(await run("cancel_action", ctx)).toMatchObject({
256
+ error: expect.stringContaining('this conversation is at "browsing"'),
149
257
  });
150
258
  });
151
259
 
@@ -153,6 +261,7 @@ describe("sensitive tools stage rather than act", () => {
153
261
  // Refused where the model can still recover — before the caller is asked to
154
262
  // confirm a flight the airline does not fly.
155
263
  const ctx = makeCtx();
264
+ await atDesk("flight", ctx);
156
265
  expect(await run("update_ticket", { flightId: "ZZ99" }, ctx)).toEqual({
157
266
  error: "No flight ZZ99 in the schedule.",
158
267
  });
@@ -161,29 +270,35 @@ describe("sensitive tools stage rather than act", () => {
161
270
 
162
271
  test("cancelling the ticket makes a later ticket change impossible", async () => {
163
272
  const ctx = makeCtx();
164
- await run("cancel_ticket", {}, ctx);
165
- await run("confirm_action", {}, ctx);
273
+ await atDesk("flight", ctx);
274
+ await run("cancel_ticket", ctx);
275
+ await run("confirm_action", ctx);
166
276
  expect(stateOf(ctx).ticket).toBeNull();
167
277
 
168
278
  expect(await run("update_ticket", { flightId: "LX52" }, ctx)).toEqual({
169
279
  error: "This caller has no ticket to move — it was cancelled on this call.",
170
280
  });
171
- expect(await run("cancel_ticket", {}, ctx)).toEqual({ error: "There is no ticket to cancel." });
281
+ expect(await run("cancel_ticket", ctx)).toEqual({ error: "There is no ticket to cancel." });
172
282
  });
173
283
 
174
284
  test("a second staging is REFUSED rather than overwriting the first", async () => {
175
285
  // The LLM loop runs a step's tool calls concurrently, so two sensitive
176
- // tools in one step is ordinary: the model hears "move my flight and book
177
- // the hotel" and emits both. Assigning `pending` unconditionally made the
286
+ // tools in one step is ordinary: the model hears "move my flight and cancel
287
+ // the old one" and emits both. Assigning `pending` unconditionally made the
178
288
  // second win — both answered `awaitingConfirmation`, the caller said yes
179
289
  // once, and one of the two changes was silently dropped forever.
180
290
  const ctx = makeCtx();
291
+ await atDesk("flight", ctx);
181
292
  const first = (await run("update_ticket", { flightId: "LX52" }, ctx)) as {
182
293
  awaitingConfirmation: boolean;
183
294
  };
184
295
  expect(first.awaitingConfirmation).toBe(true);
185
296
 
186
- const second = (await run("book_hotel", { hotelId: "H1", nights: 2 }, ctx)) as {
297
+ // Both from ONE desk, which is what a concurrent pair looks like now that
298
+ // `requireDesk` is enforced. Another desk's tool would be refused by the
299
+ // DESK gate first, which is a different refusal and would leave this one
300
+ // unexercised.
301
+ const second = (await run("cancel_ticket", ctx)) as {
187
302
  error: string;
188
303
  };
189
304
  // The refusal NAMES what is already waiting, which is what lets the model
@@ -193,11 +308,17 @@ describe("sensitive tools stage rather than act", () => {
193
308
 
194
309
  // The first staging is untouched, and it is what a yes applies.
195
310
  expect(stateOf(ctx).pending).toEqual({ kind: "update_ticket", flightId: "LX52" });
196
- const applied = (await run("confirm_action", {}, ctx)) as { applied: string };
311
+ // A refused SECOND staging must not have moved the gate either — it was
312
+ // already `awaitingConfirmation` and the refusal changed nothing.
313
+ expect(gateFlow.position(ctx).state).toBe("awaitingConfirmation");
314
+ // `ok` is `okPosition` with `.result` taken off: this assertion is about
315
+ // what the apply DID, not about where the gate landed.
316
+ const applied = ok<{ applied: string }>(await run("confirm_action", ctx));
197
317
  expect(applied.applied).toContain("LX52");
198
318
  expect(stateOf(ctx).bookings).toEqual([]);
199
319
 
200
- // Once the queue is clear, the hotel can be staged after all.
320
+ // Once the queue is clear, the next desk's booking can be staged after all.
321
+ await atDesk("hotel", ctx);
201
322
  const retried = (await run("book_hotel", { hotelId: "H1", nights: 2 }, ctx)) as {
202
323
  awaitingConfirmation: boolean;
203
324
  };
@@ -206,8 +327,10 @@ describe("sensitive tools stage rather than act", () => {
206
327
 
207
328
  test("cancel_action clears the block, so a declined change does not wedge the desk", async () => {
208
329
  const ctx = makeCtx();
330
+ await atDesk("car_rental", ctx);
209
331
  await run("book_car_rental", { carId: "C2", days: 3 }, ctx);
210
- await run("cancel_action", {}, ctx);
332
+ await run("cancel_action", ctx);
333
+ await atDesk("excursion", ctx);
211
334
  const staged = (await run("book_excursion", { excursionId: "E2" }, ctx)) as {
212
335
  awaitingConfirmation: boolean;
213
336
  };
@@ -225,12 +348,14 @@ describe("sensitive tools stage rather than act", () => {
225
348
 
226
349
  await run("to_hotel_assistant", { request: "a room" }, first);
227
350
  await run("book_hotel", { hotelId: "H3", nights: 1 }, first);
228
- await run("confirm_action", {}, first);
351
+ await run("confirm_action", first);
229
352
 
230
353
  expect(activeAssistant(stateOf(second))).toBe("primary");
231
354
  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.",
355
+ // The GATE is per-session too, not just the trip.
356
+ expect(gateFlow.position(second).state).toBe("browsing");
357
+ expect(await run("confirm_action", second)).toMatchObject({
358
+ error: expect.stringContaining('this conversation is at "browsing"'),
234
359
  });
235
360
  expect(stateOf(first).bookings).toHaveLength(1);
236
361
  });
@@ -241,6 +366,7 @@ describe("sensitive tools stage rather than act", () => {
241
366
  describe("search tools", () => {
242
367
  test("an unmatched route widens to the whole schedule rather than answering nothing", async () => {
243
368
  const ctx = makeCtx();
369
+ await atDesk("flight", ctx);
244
370
  const hit = (await run("search_flights", { route: "Zurich to Boston" }, ctx)) as {
245
371
  widened: boolean;
246
372
  flights: unknown[];
@@ -258,6 +384,7 @@ describe("search tools", () => {
258
384
 
259
385
  test("hotels come back cheapest first, and a ceiling filters them", async () => {
260
386
  const ctx = makeCtx();
387
+ await atDesk("hotel", ctx);
261
388
  const all = (await run("search_hotels", { city: "Boston" }, ctx)) as {
262
389
  hotels: { perNight: string }[];
263
390
  };
@@ -272,6 +399,7 @@ describe("search tools", () => {
272
399
 
273
400
  test("an excursion keyword that matches nothing falls back to the city", async () => {
274
401
  const ctx = makeCtx();
402
+ await atDesk("excursion", ctx);
275
403
  const result = (await run("search_excursions", { city: "Boston", keyword: "skiing" }, ctx)) as {
276
404
  widened: boolean;
277
405
  excursions: unknown[];
@@ -282,9 +410,10 @@ describe("search tools", () => {
282
410
 
283
411
  test("lookup_booking reports the ticket the caller is actually holding", async () => {
284
412
  const ctx = makeCtx();
413
+ await atDesk("flight", ctx);
285
414
  await run("update_ticket", { flightId: "LX52" }, ctx);
286
- await run("confirm_action", {}, ctx);
287
- const booking = (await run("lookup_booking", {}, ctx)) as {
415
+ await run("confirm_action", ctx);
416
+ const booking = (await run("lookup_booking", ctx)) as {
288
417
  passenger: string;
289
418
  ticket: { flight: string; departs: string } | null;
290
419
  };
@@ -326,10 +455,12 @@ describe("tripView projection", () => {
326
455
 
327
456
  test("totals every confirmed booking", async () => {
328
457
  const ctx = makeCtx();
458
+ await atDesk("hotel", ctx);
329
459
  await run("book_hotel", { hotelId: "H3", nights: 2 }, ctx); // 2 × 180
330
- await run("confirm_action", {}, ctx);
460
+ await run("confirm_action", ctx);
461
+ await atDesk("excursion", ctx);
331
462
  await run("book_excursion", { excursionId: "E1" }, ctx); // 35
332
- await run("confirm_action", {}, ctx);
463
+ await run("confirm_action", ctx);
333
464
 
334
465
  const view = tripView(stateOf(ctx));
335
466
  expect(view.total).toBe(395);
@@ -7,15 +7,24 @@ import { tripProjection } from "./shared.ts";
7
7
  * two mechanisms worth reading for are the dialog stack (`routing.ts`) and the
8
8
  * confirmation gate (`stageAction` / `confirm_action`).
9
9
  *
10
- * **Every tool in `tools/` belongs to this one agent the delegation is in the
11
- * PROMPT, not in the tool list.** Their graph gives each specialist node its
12
- * own bound tool set, so a specialist physically cannot call another desk's
13
- * tools. A voice session has one model with one tool list for its whole life,
14
- * so the specialist's brief arrives as a tool result and the narrowing is
15
- * something the model is asked to honour rather than something the runtime
16
- * enforces. That is the honest limit of the port, and it is why
17
- * `complete_or_escalate` is described the way it is: the stack is what the
18
- * caller and the sidebar can both see, even when the model reaches past it.
10
+ * **Every tool in `tools/` belongs to this one agent, and the delegation is
11
+ * ENFORCED anyway.** Their graph gives each specialist node its own bound tool
12
+ * set, so a specialist physically cannot call another desk's tools. A voice
13
+ * session has one model with one tool list for its whole life, so a list cannot
14
+ * be narrowed here but a TOOL can refuse, which is the same guarantee by a
15
+ * route that works mid-call: every desk tool checks the stack first
16
+ * (`requireDesk` in `shared.ts`) and answers a `ToolFailure` naming the
17
+ * `to_…_assistant` to call. So the position the sidebar renders is always the
18
+ * position the work is being done at, and the specialist's brief has always
19
+ * been read before that desk's first search.
20
+ *
21
+ * This used to be asked for in the prompt instead, described here as the honest
22
+ * limit of the port. It was measured and the prompt lost — 0 of 5 live runs, the
23
+ * model searching hotels from the concierge desk every time — so the eval case
24
+ * that predicted it (`agent.eval.test.ts`, "a hotel request goes to the hotel
25
+ * desk") is what turned the narrowing into a mechanism. `complete_or_escalate`
26
+ * is still described the way it is for the other half of the reason: the stack
27
+ * is what the caller and the sidebar can both see.
19
28
  */
20
29
  export default agent({
21
30
  name: "Swiss Air Concierge",
@@ -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
  });