@alexkroman1/aai-cli 6.10.1 → 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 +93 -14
  26. package/dist/scaffold/package.json +5 -3
  27. package/dist/scaffold/server.mjs +13 -4
  28. package/dist/scaffold/vite.config.ts +1 -1
  29. package/dist/{secret-BuMuFR4B.mjs → secret-CVvSLIDV.mjs} +2 -2
  30. package/dist/{storage-DzRZ-eCw.mjs → storage-BvUrnvM3.mjs} +2 -2
  31. package/dist/{studio-BCNUpDgP.mjs → studio--MUV0cid.mjs} +4 -4
  32. package/dist/templates/call-audit/agent.test.ts +818 -0
  33. package/dist/templates/call-audit/agent.ts +171 -0
  34. package/dist/templates/call-audit/client.tsx +198 -0
  35. package/dist/templates/call-audit/workflows/audit.ts +298 -0
  36. package/dist/templates/call-audit/workflows/ingest.ts +251 -0
  37. package/dist/templates/call-audit/workflows/media.ts +637 -0
  38. package/dist/templates/call-audit/workflows/summarize.ts +201 -0
  39. package/dist/templates/call-audit/workflows/sync-api.ts +44 -0
  40. package/dist/templates/dispatch-center/agent.test.ts +149 -25
  41. package/dist/templates/dispatch-center/client.tsx +239 -129
  42. package/dist/templates/dispatch-center/shared.ts +99 -1
  43. package/dist/templates/dispatch-center/system-prompt.md +3 -1
  44. package/dist/templates/dispatch-center/tools/incident_add_note.ts +16 -13
  45. package/dist/templates/dispatch-center/tools/incident_create.ts +19 -1
  46. package/dist/templates/dispatch-center/tools/incident_escalate.ts +68 -59
  47. package/dist/templates/dispatch-center/tools/incident_triage.ts +57 -43
  48. package/dist/templates/dispatch-center/tools/incident_update_status.ts +49 -40
  49. package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +8 -2
  50. package/dist/templates/dispatch-center/tools/resources_dispatch.ts +69 -52
  51. package/dist/templates/dispatch-center/tools/resources_update_status.ts +45 -41
  52. package/dist/templates/embedded-assets/agent.test.ts +17 -14
  53. package/dist/templates/health-assistant/agent.test.ts +22 -4
  54. package/dist/templates/infocom-adventure/agent.test.ts +4 -6
  55. package/dist/templates/infocom-adventure/client.tsx +246 -164
  56. package/dist/templates/link-digest/agent.test.ts +24 -19
  57. package/dist/templates/link-digest/client.tsx +47 -61
  58. package/dist/templates/link-digest/workflows/digest.ts +19 -31
  59. package/dist/templates/night-owl/agent.test.ts +70 -19
  60. package/dist/templates/night-owl/agent.ts +5 -0
  61. package/dist/templates/night-owl/client.tsx +56 -56
  62. package/dist/templates/night-owl/shared.ts +24 -0
  63. package/dist/templates/night-owl/tools/recommend.ts +18 -6
  64. package/dist/templates/pizza-ordering/agent.test.ts +27 -27
  65. package/dist/templates/pizza-ordering/client.tsx +9 -26
  66. package/dist/templates/plan-and-execute/agent.test.ts +99 -58
  67. package/dist/templates/plan-and-execute/agent.ts +1 -1
  68. package/dist/templates/plan-and-execute/client.tsx +12 -15
  69. package/dist/templates/plan-and-execute/shared.ts +71 -2
  70. package/dist/templates/plan-and-execute/tools/plan_status.ts +18 -6
  71. package/dist/templates/plan-and-execute/tools/revise_plan.ts +18 -10
  72. package/dist/templates/plan-and-execute/tools/start_plan.ts +13 -2
  73. package/dist/templates/plan-and-execute/tools/work_next_step.ts +70 -22
  74. package/dist/templates/podcast-digest/agent.test.ts +746 -0
  75. package/dist/templates/podcast-digest/agent.ts +139 -0
  76. package/dist/templates/podcast-digest/client.tsx +154 -0
  77. package/dist/templates/podcast-digest/workflows/digest.ts +411 -0
  78. package/dist/templates/podcast-digest/workflows/feeds.ts +507 -0
  79. package/dist/templates/podcast-digest/workflows/slack.ts +209 -0
  80. package/dist/templates/recap-workflow/agent.test.ts +107 -96
  81. package/dist/templates/recap-workflow/tools/recap_progress.ts +9 -12
  82. package/dist/templates/recap-workflow/tools/recap_status.ts +8 -9
  83. package/dist/templates/recap-workflow/tools/request_recap.ts +2 -1
  84. package/dist/templates/recap-workflow/workflows/recap.ts +48 -41
  85. package/dist/templates/redline/agent.test.ts +17 -15
  86. package/dist/templates/redline/client.tsx +12 -12
  87. package/dist/templates/redline/workflows/redline.ts +19 -31
  88. package/dist/templates/research-workflow/agent.test.ts +60 -59
  89. package/dist/templates/research-workflow/tools/research_progress.ts +9 -12
  90. package/dist/templates/research-workflow/tools/research_status.ts +9 -11
  91. package/dist/templates/research-workflow/workflows/research.ts +44 -61
  92. package/dist/templates/retail/agent.test.ts +26 -23
  93. package/dist/templates/retail/client.tsx +226 -117
  94. package/dist/templates/retail/registry.test.ts +38 -6
  95. package/dist/templates/retail/store.test.ts +82 -15
  96. package/dist/templates/retail/store.ts +174 -47
  97. package/dist/templates/retail/system-prompt.md +11 -2
  98. package/dist/templates/retail/tools/cancel_pending_order.ts +2 -2
  99. package/dist/templates/retail/tools/exchange_delivered_order_items.ts +2 -2
  100. package/dist/templates/retail/tools/find_user_id_by_email.ts +8 -5
  101. package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +4 -5
  102. package/dist/templates/retail/tools/get_item_details.ts +3 -4
  103. package/dist/templates/retail/tools/get_order_details.ts +3 -4
  104. package/dist/templates/retail/tools/get_product_details.ts +3 -4
  105. package/dist/templates/retail/tools/get_user_details.ts +2 -2
  106. package/dist/templates/retail/tools/list_all_product_types.ts +5 -5
  107. package/dist/templates/retail/tools/modify_pending_order_address.ts +2 -2
  108. package/dist/templates/retail/tools/modify_pending_order_items.ts +2 -3
  109. package/dist/templates/retail/tools/modify_pending_order_payment.ts +2 -2
  110. package/dist/templates/retail/tools/modify_user_address.ts +2 -2
  111. package/dist/templates/retail/tools/return_delivered_order_items.ts +2 -2
  112. package/dist/templates/retail/tools/transfer_to_human_agents.ts +8 -4
  113. package/dist/templates/solo-rpg/agent.test.ts +227 -48
  114. package/dist/templates/solo-rpg/client.tsx +38 -37
  115. package/dist/templates/solo-rpg/shared.ts +145 -19
  116. package/dist/templates/solo-rpg/system-prompt.md +3 -2
  117. package/dist/templates/solo-rpg/tools/action_roll.ts +82 -63
  118. package/dist/templates/solo-rpg/tools/burn_momentum.ts +85 -50
  119. package/dist/templates/solo-rpg/tools/check_state.ts +24 -7
  120. package/dist/templates/solo-rpg/tools/load_game.ts +13 -1
  121. package/dist/templates/solo-rpg/tools/save_game.ts +16 -7
  122. package/dist/templates/solo-rpg/tools/setup_character.ts +22 -2
  123. package/dist/templates/solo-rpg/tools/update_state.ts +113 -100
  124. package/dist/templates/spoken-summary/agent.test.ts +314 -0
  125. package/dist/templates/spoken-summary/agent.ts +155 -0
  126. package/dist/templates/spoken-summary/client.tsx +185 -0
  127. package/dist/templates/spoken-summary/workflows/summarize.ts +237 -0
  128. package/dist/templates/spoken-summary/workflows/transcribe.ts +138 -0
  129. package/dist/templates/support-line/agent.test.ts +11 -16
  130. package/dist/templates/support-line/agent.ts +1 -1
  131. package/dist/templates/support-line/client.tsx +9 -9
  132. package/dist/templates/support-line/nodes.ts +100 -0
  133. package/dist/templates/support-line/procedure.ts +407 -0
  134. package/dist/templates/support-line/tools/answer_question.ts +17 -4
  135. package/dist/templates/transcription-workflow/agent.test.ts +246 -123
  136. package/dist/templates/transcription-workflow/agent.ts +21 -7
  137. package/dist/templates/transcription-workflow/client.tsx +17 -42
  138. package/dist/templates/transcription-workflow/workflows/batch.ts +79 -180
  139. package/dist/templates/transcription-workflow/workflows/normalize.ts +247 -0
  140. package/dist/templates/transcription-workflow/workflows/stitch.ts +0 -11
  141. package/dist/templates/transcription-workflow/workflows/stream.ts +10 -9
  142. package/dist/templates/transcription-workflow/workflows/sync-api.ts +26 -94
  143. package/dist/templates/transcription-workflow/workflows/transcribe.ts +55 -40
  144. package/dist/templates/transcription-workflow/workflows/wav.ts +31 -31
  145. package/dist/templates/travel-concierge/agent.test.ts +64 -33
  146. package/dist/templates/travel-concierge/client.tsx +11 -23
  147. package/dist/templates/travel-concierge/routing.ts +34 -15
  148. package/dist/templates/travel-concierge/shared.ts +70 -3
  149. package/dist/templates/travel-concierge/tools/book_car_rental.ts +2 -2
  150. package/dist/templates/travel-concierge/tools/book_excursion.ts +2 -2
  151. package/dist/templates/travel-concierge/tools/book_hotel.ts +2 -2
  152. package/dist/templates/travel-concierge/tools/cancel_ticket.ts +2 -2
  153. package/dist/templates/travel-concierge/tools/update_ticket.ts +2 -2
  154. package/dist/{worker-bundler-CGD4r8Kc.mjs → worker-bundler-COxnqstQ.mjs} +221 -3
  155. package/dist/worker-bundler.mjs +1 -1
  156. package/dist/{workflow-CFpxOFfQ.mjs → workflow-D2AQf2Pl.mjs} +28 -1
  157. package/dist/workflow-bundler.d.ts +6 -1
  158. package/dist/workflow.d.ts +1 -1
  159. package/package.json +5 -4
  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
@@ -1,11 +1,12 @@
1
1
  import "@alexkroman1/aai-ui/styles.css";
2
- import type { AgentState, ChatMessage } from "@alexkroman1/aai-ui";
2
+ import type { AgentState, ConversationItem } from "@alexkroman1/aai-ui";
3
3
  import {
4
4
  AutoScroll,
5
5
  client,
6
6
  useAgentState,
7
+ useConversation,
7
8
  useSession,
8
- useUserTranscript,
9
+ useSessionSelector,
9
10
  } from "@alexkroman1/aai-ui";
10
11
  import type { ReactNode } from "react";
11
12
  import type {
@@ -217,13 +218,226 @@ function SwapOptions({ option }: { option: SwapOptionView }) {
217
218
  );
218
219
  }
219
220
 
220
- function App() {
221
+ /** One line of the call: a bubble, or the tool the agent ran after it. */
222
+ function Row({ item }: { item: ConversationItem }) {
223
+ if (item.kind === "tool") {
224
+ const { name, status } = item.toolCall;
225
+ return (
226
+ <div
227
+ className="self-start rounded-md px-2.5 py-1 text-[11px] font-mono flex items-center gap-2"
228
+ style={{
229
+ background: "#ffffff",
230
+ border: "1px solid #e4e4e7",
231
+ color: status === "pending" ? "#b45309" : "#71717a",
232
+ }}
233
+ >
234
+ <span
235
+ className="w-1.5 h-1.5 rounded-full inline-block"
236
+ style={{
237
+ background: status === "pending" ? "#ca8a04" : "#16a34a",
238
+ animation: status === "pending" ? "rt-pulse 1s ease-in-out infinite" : "none",
239
+ }}
240
+ />
241
+ {name}
242
+ </div>
243
+ );
244
+ }
245
+ const { role, content } = item.message;
246
+ return (
247
+ <div
248
+ className="rounded-lg text-[13px] max-w-[80%] px-3.5 py-2.5"
249
+ style={{
250
+ lineHeight: 1.55,
251
+ alignSelf: role === "assistant" ? "flex-start" : "flex-end",
252
+ background: role === "assistant" ? "#ffffff" : "#2563eb",
253
+ color: role === "assistant" ? "#18181b" : "#ffffff",
254
+ border: role === "assistant" ? "1px solid #e4e4e7" : "none",
255
+ animation: "rt-slide-in 0.2s ease-out",
256
+ }}
257
+ >
258
+ {content}
259
+ </div>
260
+ );
261
+ }
262
+
263
+ /**
264
+ * The call transcript.
265
+ *
266
+ * `useConversation()` rather than `session.messages.map(...)`. The messages
267
+ * were only part of it: this agent runs FIFTEEN tools and the operator could
268
+ * see none of them, because tool calls live in a second array this page never
269
+ * read — and the streaming reply and the thinking indicator were dropped along
270
+ * with them. The hook owns the interleave (a tool row follows the message it
271
+ * was anchored to), the `null`-vs-`""` transcript distinction and the
272
+ * thinking-suppression rule; the markup below is what this template is for.
273
+ *
274
+ * It subscribes per FIELD, so the customer file in the sidebar no longer
275
+ * re-renders on every partial transcript the way the whole-page `useSession()`
276
+ * this replaced made it.
277
+ */
278
+ function Conversation() {
279
+ const { items, streaming, transcript, thinking } = useConversation();
280
+ return (
281
+ <>
282
+ <AutoScroll
283
+ scrollClassName="rt-scroll overflow-y-auto"
284
+ contentClassName="p-4 flex flex-col gap-2"
285
+ >
286
+ {items.length === 0 && streaming === null && (
287
+ <div className="text-center p-10 text-[13px]" style={{ color: "#a1a1aa" }}>
288
+ Press start, then read one of the emails from “Who to be” to the agent.
289
+ </div>
290
+ )}
291
+ {items.map((item) => (
292
+ <Row
293
+ key={item.kind === "message" ? `m${item.message.id}` : item.toolCall.callId}
294
+ item={item}
295
+ />
296
+ ))}
297
+ {streaming !== null && (
298
+ <div
299
+ className="rounded-lg text-[13px] max-w-[80%] px-3.5 py-2.5 self-start"
300
+ style={{
301
+ lineHeight: 1.55,
302
+ background: "#ffffff",
303
+ color: "#18181b",
304
+ border: "1px solid #e4e4e7",
305
+ }}
306
+ >
307
+ {streaming}
308
+ </div>
309
+ )}
310
+ {thinking && (
311
+ <div className="self-start text-[11px] px-3.5" style={{ color: "#a1a1aa" }}>
312
+ <span style={{ animation: "rt-pulse 1.2s ease-in-out infinite" }}>· · ·</span>
313
+ </div>
314
+ )}
315
+ </AutoScroll>
316
+
317
+ {transcript.speaking && (
318
+ <div
319
+ className="flex items-center px-4 py-2 text-xs italic min-h-8"
320
+ style={{ background: "#fafafa", borderTop: "1px solid #e4e4e7", color: "#71717a" }}
321
+ >
322
+ <span
323
+ className="w-2 h-2 rounded-full inline-block mr-2"
324
+ style={{ background: "#16a34a", animation: "rt-pulse 1.5s ease-in-out infinite" }}
325
+ />
326
+ {transcript.text}
327
+ </div>
328
+ )}
329
+ </>
330
+ );
331
+ }
332
+
333
+ /** The live status dot, on its own subscription rather than a field off a
334
+ * whole-page read — the header is the only thing here that wants it. */
335
+ function StatusReadout() {
336
+ const state = useSessionSelector((s) => s.state);
337
+ return (
338
+ <>
339
+ <span
340
+ className="w-2 h-2 rounded-full inline-block"
341
+ style={{
342
+ background: STATE_COLORS[state],
343
+ animation:
344
+ state === "listening"
345
+ ? "rt-pulse 1.5s ease-in-out infinite"
346
+ : state === "thinking"
347
+ ? "rt-pulse 0.8s ease-in-out infinite"
348
+ : "none",
349
+ }}
350
+ title={state}
351
+ />
352
+ <span className="text-[11px] font-normal" style={{ color: "#a1a1aa" }}>
353
+ {state}
354
+ </span>
355
+ </>
356
+ );
357
+ }
358
+
359
+ /**
360
+ * The fatal-error banner.
361
+ *
362
+ * `role="alert"` is the part a hand-rolled banner leaves out and the part that
363
+ * matters: per the `fatalError` latch in the session core this is the only
364
+ * remaining signal — the status readout beside it goes back to reading like a
365
+ * live call — and a screen reader is never told an unannounced one appeared.
366
+ * `ConsoleShell` carries it, and this template's two-pane page cannot use that
367
+ * shell as its frame; the attribute is what it is here for.
368
+ */
369
+ function ErrorBanner() {
370
+ const error = useSessionSelector((s) => s.error);
371
+ if (!error) return null;
372
+ return (
373
+ <div
374
+ role="alert"
375
+ className="px-4 py-2 text-xs"
376
+ style={{ background: "#fef2f2", color: "#b91c1c", borderTop: "1px solid #fecaca" }}
377
+ >
378
+ {error.message} ({error.code})
379
+ </div>
380
+ );
381
+ }
382
+
383
+ /** The call controls: the one place a whole-session read is what is wanted —
384
+ * `started`, `running` and three methods, in three buttons. */
385
+ function CallControls({ productCount }: { productCount: number }) {
221
386
  const session = useSession();
222
- // `speaking` and `text` in place of a `userTranscript !== null` check: `null`
223
- // is silence and `""` is speech detected with no words back yet, and reading
224
- // them as one falsy value hides the indicator at the start of every turn.
225
- const { speaking, text: userTranscript } = useUserTranscript();
226
- // The agent's own store, projected by `syncState` after every tool call.
387
+ return (
388
+ <div
389
+ className="flex items-center gap-2 px-4 py-3"
390
+ style={{ background: "#ffffff", borderTop: "1px solid #e4e4e7" }}
391
+ >
392
+ {!session.started ? (
393
+ <button
394
+ type="button"
395
+ className="px-4 py-2 border-none rounded-md text-xs font-semibold cursor-pointer text-white"
396
+ style={{ background: "#2563eb" }}
397
+ onClick={() => session.start()}
398
+ >
399
+ Start call
400
+ </button>
401
+ ) : (
402
+ <>
403
+ <button
404
+ type="button"
405
+ className="px-4 py-2 border-none rounded-md text-xs font-semibold cursor-pointer"
406
+ style={{
407
+ background: session.running ? "#e4e4e7" : "#2563eb",
408
+ color: session.running ? "#18181b" : "#ffffff",
409
+ }}
410
+ onClick={() => session.toggle()}
411
+ >
412
+ {session.running ? "Hold" : "Resume"}
413
+ </button>
414
+ {/* end() hangs up and flips `started` back, so the UI
415
+ returns to "Start call" and the next start is a brand-new
416
+ session (fresh store, greeting included). reset() would
417
+ keep the call live — the buttons never toggle back. */}
418
+ <button
419
+ type="button"
420
+ className="px-4 py-2 border-none rounded-md text-xs font-semibold cursor-pointer text-white"
421
+ style={{ background: "#dc2626" }}
422
+ onClick={() => session.end()}
423
+ >
424
+ End
425
+ </button>
426
+ </>
427
+ )}
428
+ <div className="flex-1" />
429
+ <span className="text-[10px] tabular-nums" style={{ color: "#a1a1aa" }}>
430
+ {productCount} products
431
+ </span>
432
+ </div>
433
+ );
434
+ }
435
+
436
+ function App() {
437
+ // The agent's own store, projected by `syncState` after every tool call —
438
+ // the only subscription at this level now. The session reads that used to sit
439
+ // beside it moved into the four components above, so a partial transcript no
440
+ // longer re-renders the customer file.
227
441
  const view = useAgentState<StoreView>(EMPTY_VIEW);
228
442
 
229
443
  const lastAction = view.activity.at(-1);
@@ -242,22 +456,7 @@ function App() {
242
456
  <div className="flex items-center gap-2.5 text-base font-semibold">
243
457
  <span style={{ color: "#2563eb" }}>◆</span>
244
458
  Retail Support
245
- <span
246
- className="w-2 h-2 rounded-full inline-block"
247
- style={{
248
- background: STATE_COLORS[session.state],
249
- animation:
250
- session.state === "listening"
251
- ? "rt-pulse 1.5s ease-in-out infinite"
252
- : session.state === "thinking"
253
- ? "rt-pulse 0.8s ease-in-out infinite"
254
- : "none",
255
- }}
256
- title={session.state}
257
- />
258
- <span className="text-[11px] font-normal" style={{ color: "#a1a1aa" }}>
259
- {session.state}
260
- </span>
459
+ <StatusReadout />
261
460
  </div>
262
461
  <span
263
462
  className="px-2.5 py-1 rounded text-[10px] font-semibold uppercase tracking-wider"
@@ -280,99 +479,9 @@ function App() {
280
479
  className="flex flex-col overflow-hidden"
281
480
  style={{ borderRight: "1px solid #e4e4e7" }}
282
481
  >
283
- <AutoScroll
284
- scrollClassName="rt-scroll overflow-y-auto"
285
- contentClassName="p-4 flex flex-col gap-2"
286
- >
287
- {session.messages.length === 0 && (
288
- <div className="text-center p-10 text-[13px]" style={{ color: "#a1a1aa" }}>
289
- Press start, then read one of the emails from “Who to be” to the agent.
290
- </div>
291
- )}
292
- {session.messages.map((message: ChatMessage, index: number) => (
293
- <div
294
- key={index}
295
- className="rounded-lg text-[13px] max-w-[80%] px-3.5 py-2.5"
296
- style={{
297
- lineHeight: 1.55,
298
- alignSelf: message.role === "assistant" ? "flex-start" : "flex-end",
299
- background: message.role === "assistant" ? "#ffffff" : "#2563eb",
300
- color: message.role === "assistant" ? "#18181b" : "#ffffff",
301
- border: message.role === "assistant" ? "1px solid #e4e4e7" : "none",
302
- animation: "rt-slide-in 0.2s ease-out",
303
- }}
304
- >
305
- {message.content}
306
- </div>
307
- ))}
308
- </AutoScroll>
309
-
310
- {speaking && (
311
- <div
312
- className="flex items-center px-4 py-2 text-xs italic min-h-8"
313
- style={{ background: "#fafafa", borderTop: "1px solid #e4e4e7", color: "#71717a" }}
314
- >
315
- <span
316
- className="w-2 h-2 rounded-full inline-block mr-2"
317
- style={{ background: "#16a34a", animation: "rt-pulse 1.5s ease-in-out infinite" }}
318
- />
319
- {userTranscript}
320
- </div>
321
- )}
322
- {session.error && (
323
- <div
324
- className="px-4 py-2 text-xs"
325
- style={{ background: "#fef2f2", color: "#b91c1c", borderTop: "1px solid #fecaca" }}
326
- >
327
- {session.error.message} ({session.error.code})
328
- </div>
329
- )}
330
-
331
- <div
332
- className="flex items-center gap-2 px-4 py-3"
333
- style={{ background: "#ffffff", borderTop: "1px solid #e4e4e7" }}
334
- >
335
- {!session.started ? (
336
- <button
337
- type="button"
338
- className="px-4 py-2 border-none rounded-md text-xs font-semibold cursor-pointer text-white"
339
- style={{ background: "#2563eb" }}
340
- onClick={() => session.start()}
341
- >
342
- Start call
343
- </button>
344
- ) : (
345
- <>
346
- <button
347
- type="button"
348
- className="px-4 py-2 border-none rounded-md text-xs font-semibold cursor-pointer"
349
- style={{
350
- background: session.running ? "#e4e4e7" : "#2563eb",
351
- color: session.running ? "#18181b" : "#ffffff",
352
- }}
353
- onClick={() => session.toggle()}
354
- >
355
- {session.running ? "Hold" : "Resume"}
356
- </button>
357
- {/* end() hangs up and flips `started` back, so the UI
358
- returns to "Start call" and the next start is a brand-new
359
- session (fresh store, greeting included). reset() would
360
- keep the call live — the buttons never toggle back. */}
361
- <button
362
- type="button"
363
- className="px-4 py-2 border-none rounded-md text-xs font-semibold cursor-pointer text-white"
364
- style={{ background: "#dc2626" }}
365
- onClick={() => session.end()}
366
- >
367
- End
368
- </button>
369
- </>
370
- )}
371
- <div className="flex-1" />
372
- <span className="text-[10px] tabular-nums" style={{ color: "#a1a1aa" }}>
373
- {view.productCount} products
374
- </span>
375
- </div>
482
+ <Conversation />
483
+ <ErrorBanner />
484
+ <CallControls productCount={view.productCount} />
376
485
  </div>
377
486
 
378
487
  {/* Sidebar: the customer file */}
@@ -4,11 +4,12 @@ import { isToolFailure, type ToolContext } from "@alexkroman1/aai";
4
4
  import { createToolContext, withDiscoveredTools } from "@alexkroman1/aai/testing";
5
5
  import { describe, expect, test } from "vitest";
6
6
  import authoredAgent from "./agent.ts";
7
- import { retailSlot } from "./store.ts";
7
+ import { callFlow, retailSlot } from "./store.ts";
8
8
 
9
- /** Tools that legitimately run before the caller is identified. Everything
10
- * else must refuse. Listed here so ADDING an unauthenticated tool is a
11
- * deliberate edit to this file, not a silent gap. */
9
+ /** Tools that legitimately run before the caller is identified — the six
10
+ * declaring `when: BEFORE_TRANSFER`. Everything else must refuse. Listed here
11
+ * so ADDING an unauthenticated tool is a deliberate edit to this file, not a
12
+ * silent gap. */
12
13
  const PUBLIC_TOOLS = new Set([
13
14
  "find_user_id_by_email",
14
15
  "find_user_id_by_name_zip",
@@ -37,6 +38,15 @@ const registry = Object.entries(retailAgent.tools);
37
38
  // distinct session, which is what these per-tool cases assume.
38
39
  const makeCtx = (): ToolContext => createToolContext();
39
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
43
+ * `authenticatedUserId`, because the gate reads the machine. */
44
+ function servingCtx(): ToolContext {
45
+ const ctx = makeCtx();
46
+ callFlow.send(ctx, { type: "IDENTIFIED" });
47
+ return ctx;
48
+ }
49
+
40
50
  /** Minimal args satisfying each tool's schema. Deliberately plausible-shaped
41
51
  * but wrong — these calls are expected to fail; what is asserted is that they
42
52
  * still moved the UI. */
@@ -134,7 +144,10 @@ describe("the UI-update invariant", () => {
134
144
  // This is the one that fails if a future tool is built with tool() instead of
135
145
  // retailTool(): it would work, and the sidebar would sit still through it.
136
146
  test.each(registry)("%s increments callSeq and logs activity", async (name, def) => {
137
- const ctx = makeCtx();
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 not — see `store.test.ts`.
150
+ const ctx = servingCtx();
138
151
  const before = retailSlot.get(ctx).callSeq;
139
152
  await def.execute(SAMPLE_ARGS[name] ?? {}, ctx);
140
153
  const state = retailSlot.get(ctx);
@@ -146,7 +159,7 @@ describe("the UI-update invariant", () => {
146
159
  test.each(registry)("%s logs its own registry key as its name", async (name, def) => {
147
160
  // Catches a copy-paste where the retailTool `name` and the registry key
148
161
  // disagree — the activity feed would then attribute calls to the wrong tool.
149
- const ctx = makeCtx();
162
+ const ctx = servingCtx();
150
163
  await def.execute(SAMPLE_ARGS[name] ?? {}, ctx);
151
164
  expect(retailSlot.get(ctx).activity.at(-1)?.tool).toBe(name);
152
165
  });
@@ -159,6 +172,11 @@ describe("the authentication gate", () => {
159
172
  const result = await def.execute(SAMPLE_ARGS[name] ?? {}, makeCtx());
160
173
  expect(isToolFailure(result), `${name} did not refuse`).toBe(true);
161
174
  if (!isToolFailure(result)) return;
175
+ // The refusal is `callFlow`'s: it names the position, and quotes the
176
+ // state's instruction — which names the two tools that get out of it.
177
+ expect(result.error, `${name} refused without naming the position`).toContain(
178
+ '"identifying"',
179
+ );
162
180
  expect(result.error, `${name} refused for the wrong reason`).toContain(
163
181
  "find_user_id_by_email",
164
182
  );
@@ -197,3 +215,17 @@ describe("agent config", () => {
197
215
  expect(a.store).toEqual(b.store);
198
216
  });
199
217
  });
218
+
219
+ describe("the transfer is terminal", () => {
220
+ test.each(registry)("%s refuses once the call is with a human", async (name, def) => {
221
+ const ctx = servingCtx();
222
+ callFlow.send(ctx, { type: "TRANSFERRED" });
223
+
224
+ const result = await def.execute(SAMPLE_ARGS[name] ?? {}, ctx);
225
+ // EVERY tool, the six public ones and `transfer_to_human_agents` itself
226
+ // included: nothing declares itself legal in the final state. Before the
227
+ // flow, the policy's "say nothing else after that" was enforced by nothing.
228
+ expect(isToolFailure(result), `${name} still ran after the handoff`).toBe(true);
229
+ expect(isToolFailure(result) && result.error, name).toContain('"transferred"');
230
+ });
231
+ });
@@ -1,9 +1,11 @@
1
1
  import { isToolFailure, type ToolContext } from "@alexkroman1/aai";
2
- import { createToolContext } from "@alexkroman1/aai/testing";
2
+ import { createToolContext, ok, okPosition } from "@alexkroman1/aai/testing";
3
3
  import { describe, expect, test } from "vitest";
4
4
  import { z } from "zod";
5
5
  import {
6
6
  authenticatedUser,
7
+ BEFORE_TRANSFER,
8
+ callFlow,
7
9
  createDefaultState,
8
10
  findItem,
9
11
  findOrder,
@@ -163,15 +165,16 @@ describe("retailTool", () => {
163
165
  name: "echo",
164
166
  description: "test tool",
165
167
  inputSchema: z.object({ value: z.string() }),
166
- requiresAuth: false,
168
+ when: BEFORE_TRANSFER,
167
169
  summary: (args) => `echoed ${args.value}`,
168
170
  execute: (args) => ({ echoed: args.value }),
169
171
  });
170
172
 
171
173
  const gated = retailTool({
172
174
  name: "gated",
173
- description: "test tool needing auth",
175
+ description: "test tool needing an identified customer",
174
176
  inputSchema: z.object({}),
177
+ when: "serving",
175
178
  summary: () => "ran",
176
179
  execute: () => ({ ok: true }),
177
180
  });
@@ -180,11 +183,15 @@ describe("retailTool", () => {
180
183
  name: "failing",
181
184
  description: "test tool that returns an error",
182
185
  inputSchema: z.object({}),
183
- requiresAuth: false,
186
+ when: BEFORE_TRANSFER,
184
187
  summary: () => "should not be used",
185
188
  execute: () => ({ error: "nope" }),
186
189
  });
187
190
 
191
+ /** Put the call where a `when: "serving"` tool can run, through the flow
192
+ * rather than by writing the store — the gate reads the machine. */
193
+ const serve = (ctx: ToolContext) => callFlow.send(ctx, { type: "IDENTIFIED" });
194
+
188
195
  test("increments callSeq and logs activity on every call", async () => {
189
196
  const ctx = makeCtx();
190
197
  await echo.execute({ value: "a" }, ctx);
@@ -203,27 +210,43 @@ describe("retailTool", () => {
203
210
  expect(retailSlot.get(ctx).callSeq).toBeGreaterThan(first);
204
211
  });
205
212
 
206
- test("defaults to requiring authentication and does not run execute when blocked", async () => {
213
+ test("a tool gated on serving refuses while the caller is unidentified", async () => {
207
214
  const ctx = makeCtx();
208
215
  const result = await gated.execute({}, ctx);
209
- expect(isToolFailure(result) && result.error).toContain("find_user_id_by_email");
216
+ expect(isToolFailure(result)).toBe(true);
217
+ // The refusal is the SDK's, so it names the position and quotes the
218
+ // state's own instruction — where `requiresAuth` answered one fixed
219
+ // sentence that could not say where the call was.
220
+ expect(isToolFailure(result) && result.error).toContain('"identifying"');
221
+ expect(isToolFailure(result) && result.error).toMatch(/user id/);
210
222
  });
211
223
 
212
- test("a blocked call is still logged and still bumps callSeq", async () => {
224
+ test("a refused call touches nothing, callSeq included", async () => {
213
225
  const ctx = makeCtx();
214
226
  await gated.execute({}, ctx);
215
227
  const state = retailSlot.get(ctx);
216
- expect(state.callSeq).toBe(1);
217
- expect(state.activity[0]?.summary).toContain("blocked");
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.
231
+ expect(state.callSeq).toBe(0);
232
+ expect(state.activity).toEqual([]);
218
233
  });
219
234
 
220
- test("runs once authenticated", async () => {
235
+ test("runs once the flow says serving", async () => {
221
236
  const ctx = makeCtx();
222
- retailSlot.update(ctx, (state) => {
223
- state.authenticatedUserId = "olivia_ito_3591";
224
- });
225
- const result = await gated.execute({}, ctx);
226
- expect(result).toEqual({ ok: true });
237
+ serve(ctx);
238
+ expect(ok(await gated.execute({}, ctx))).toEqual({ ok: true });
239
+ });
240
+
241
+ test("the result carries the position the call landed in", async () => {
242
+ const ctx = makeCtx();
243
+ serve(ctx);
244
+ // `okPosition` rather than a cast to `{ instruction?: string }`: it keeps
245
+ // the envelope this test is about, and a refusal fails here naming what the
246
+ // flow said instead of reading `undefined` off a field nobody assigned.
247
+ const answered = okPosition<{ ok: boolean }>(await gated.execute({}, ctx));
248
+ expect(answered).toMatchObject({ state: "serving", done: false });
249
+ expect(answered.instruction).toMatch(/one identified customer/);
227
250
  });
228
251
 
229
252
  test("an error result is logged as an error, not through summary()", async () => {
@@ -232,6 +255,21 @@ describe("retailTool", () => {
232
255
  expect(retailSlot.get(ctx).activity[0]?.summary).toBe("error: nope");
233
256
  });
234
257
 
258
+ test("a body that failed does not move the call", async () => {
259
+ const ctx = makeCtx();
260
+ const moves = retailTool({
261
+ name: "moves",
262
+ description: "test tool that would identify the caller and fails instead",
263
+ inputSchema: z.object({}),
264
+ when: BEFORE_TRANSFER,
265
+ send: { type: "IDENTIFIED" },
266
+ summary: () => "moved",
267
+ execute: () => ({ error: "no such customer" }),
268
+ });
269
+ expect(isToolFailure(await moves.execute({}, ctx))).toBe(true);
270
+ expect(callFlow.position(ctx).state).toBe("identifying");
271
+ });
272
+
235
273
  test("activity is capped so a long call cannot grow the payload", async () => {
236
274
  const ctx = makeCtx();
237
275
  for (let i = 0; i < 15; i++) await echo.execute({ value: String(i) }, ctx);
@@ -249,3 +287,32 @@ describe("retailTool", () => {
249
287
  expect(new Set(state.activity.map((a) => a.seq)).size).toBe(state.activity.length);
250
288
  });
251
289
  });
290
+
291
+ describe("the call flow", () => {
292
+ test("a fresh call is identifying, and nothing is latched", () => {
293
+ const ctx = makeCtx();
294
+ expect(callFlow.position(ctx).state).toBe("identifying");
295
+ expect(retailSlot.get(ctx).authenticatedUserId).toBeNull();
296
+ });
297
+
298
+ test("transferred is final, so every tool refuses after the handoff", async () => {
299
+ const ctx = makeCtx();
300
+ const anywhere = retailTool({
301
+ name: "anywhere",
302
+ description: "legal in every state but the terminal one",
303
+ inputSchema: z.object({}),
304
+ when: BEFORE_TRANSFER,
305
+ summary: () => "ran",
306
+ execute: () => ({ ok: true }),
307
+ });
308
+ expect(ok(await anywhere.execute({}, ctx))).toEqual({ ok: true });
309
+
310
+ const at = callFlow.send(ctx, { type: "TRANSFERRED" });
311
+ expect(at.state).toBe("transferred");
312
+ expect(at.done).toBe(true);
313
+
314
+ const refused = await anywhere.execute({}, ctx);
315
+ expect(isToolFailure(refused)).toBe(true);
316
+ expect(isToolFailure(refused) && refused.error).toContain('"transferred"');
317
+ });
318
+ });