@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
@@ -1,11 +1,20 @@
1
- import type { ToolContext } from "@alexkroman1/aai";
2
- import { createToolContext } from "@alexkroman1/aai/testing";
1
+ import type {
2
+ DialogPosition,
3
+ InferToolOutput,
4
+ ToolContext,
5
+ ToolDef,
6
+ ToolInputSchema,
7
+ } from "@alexkroman1/aai";
8
+ import { isToolFailure } from "@alexkroman1/aai";
9
+ import { createToolContext, ok } from "@alexkroman1/aai/testing";
3
10
  import { describe, expect, test } from "vitest";
4
- import { dispatchSlot } from "./shared.ts";
11
+ import { callFlow, dispatchSlot } from "./shared.ts";
12
+ import incidentAddNote from "./tools/incident_add_note.ts";
5
13
  import incidentCreate from "./tools/incident_create.ts";
6
14
  import incidentEscalate from "./tools/incident_escalate.ts";
7
15
  import incidentTriage from "./tools/incident_triage.ts";
8
16
  import incidentUpdateStatus from "./tools/incident_update_status.ts";
17
+ import opsRunScenario from "./tools/ops_run_scenario.ts";
9
18
  import resourcesDispatch from "./tools/resources_dispatch.ts";
10
19
  import resourcesUpdateStatus from "./tools/resources_update_status.ts";
11
20
 
@@ -14,14 +23,31 @@ import resourcesUpdateStatus from "./tools/resources_update_status.ts";
14
23
  * construction. */
15
24
  const makeCtx = (): ToolContext => createToolContext();
16
25
 
26
+ /**
27
+ * What a gated tool's own `execute` returned, read off the tool itself.
28
+ *
29
+ * `callFlow.tool` threads its result type out now, so `InferToolOutput` answers
30
+ * `DialogToolResult<R> | ToolFailure` — this is that minus the envelope and the
31
+ * refusal arm. It replaces the inline `{ dispatched: { callsign: string }[] }`
32
+ * shapes the assertions below used to restate, which were a second copy of each
33
+ * tool's return type that could not go stale loudly.
34
+ *
35
+ * The unwrap itself is `ok` from `@alexkroman1/aai/testing`; the hand-rolled
36
+ * copy that used to sit here was byte-identical to three other templates'.
37
+ */
38
+ type Result<T extends ToolDef<ToolInputSchema>> = Extract<
39
+ InferToolOutput<T>,
40
+ { result: unknown }
41
+ >["result"];
42
+
43
+ /** Where the call is, without going through a tool. */
44
+ const at = (ctx: ToolContext): DialogPosition => callFlow.position(ctx);
45
+
17
46
  async function createIncidentFor(
18
47
  ctx: ToolContext,
19
48
  description = "structure fire with heavy smoke",
20
49
  ): Promise<string> {
21
- const result = (await incidentCreate.execute(
22
- { location: "400 Oak Street", description },
23
- ctx,
24
- )) as { incidentId: string };
50
+ const result = await incidentCreate.execute({ location: "400 Oak Street", description }, ctx);
25
51
  return result.incidentId;
26
52
  }
27
53
 
@@ -52,10 +78,9 @@ describe("dispatch-center template", () => {
52
78
  const ctx = makeCtx();
53
79
  const incidentId = await createIncidentFor(ctx, "cardiac arrest, patient not breathing");
54
80
 
55
- const result = (await resourcesDispatch.execute({ incidentId, callsigns: ["auto"] }, ctx)) as {
56
- dispatched: { callsign: string }[];
57
- failed?: { callsign: string; reason: string }[];
58
- };
81
+ const result = ok<Result<typeof resourcesDispatch>>(
82
+ await resourcesDispatch.execute({ incidentId, callsigns: ["auto"] }, ctx),
83
+ );
59
84
 
60
85
  expect(result.failed).toBeUndefined();
61
86
  expect(result.dispatched.length).toBeGreaterThan(0);
@@ -68,10 +93,10 @@ describe("dispatch-center template", () => {
68
93
  // mutex in updateState makes each one run against the previous one's
69
94
  // finished state, so neither incident's changes are half-applied when
70
95
  // the other's mutator runs.
71
- const [a, b] = (await Promise.all([
96
+ const [a, b] = await Promise.all([
72
97
  incidentCreate.execute({ location: "1 First St", description: "gas leak" }, ctx),
73
98
  incidentCreate.execute({ location: "2 Second St", description: "vehicle crash" }, ctx),
74
- ])) as { incidentId: string }[];
99
+ ]);
75
100
 
76
101
  const state = dispatchSlot.get(ctx);
77
102
  expect(a?.incidentId).not.toBe(b?.incidentId);
@@ -124,21 +149,120 @@ describe("dispatch-center template", () => {
124
149
  const incidentId = await createIncidentFor(ctx);
125
150
  await incidentUpdateStatus.execute({ incidentId, status: "resolved" }, ctx);
126
151
 
127
- const escalated = (await incidentEscalate.execute({ incidentId, reason: "flare-up" }, ctx)) as {
128
- error?: string;
129
- };
130
- expect(escalated.error).toMatch(/resolved/);
152
+ // Each is a refusal the BODY answered, so it arrives unwrapped as a
153
+ // `ToolFailure` rather than under the position envelope — which is what the
154
+ // narrowing says, where the old cast to `{ error?: string }` said nothing
155
+ // and would have read `undefined` off a success just as quietly.
156
+ const escalated = await incidentEscalate.execute({ incidentId, reason: "flare-up" }, ctx);
157
+ expect(isToolFailure(escalated) && escalated.error).toMatch(/resolved/);
131
158
 
132
- const reResolved = (await incidentUpdateStatus.execute(
133
- { incidentId, status: "resolved" },
134
- ctx,
135
- )) as { error?: string };
136
- expect(reResolved.error).toMatch(/resolved/);
159
+ const reResolved = await incidentUpdateStatus.execute({ incidentId, status: "resolved" }, ctx);
160
+ expect(isToolFailure(reResolved) && reResolved.error).toMatch(/resolved/);
137
161
 
138
- const dispatchedTo = (await resourcesDispatch.execute(
162
+ const dispatchedTo = await resourcesDispatch.execute(
139
163
  { incidentId, callsigns: ["Medic-1"] },
140
164
  ctx,
141
- )) as { error?: string };
142
- expect(dispatchedTo.error).toMatch(/resolved/);
165
+ );
166
+ expect(isToolFailure(dispatchedTo) && dispatchedTo.error).toMatch(/resolved/);
167
+ });
168
+ });
169
+
170
+ describe("the call flow", () => {
171
+ test("a fresh shift is in standby, and every incident tool refuses there", async () => {
172
+ const ctx = makeCtx();
173
+ expect(at(ctx).state).toBe("standby");
174
+
175
+ // Each of these used to run and answer `Incident INC-0001 not found` — a
176
+ // data answer to a positional question. The refusal now names where the
177
+ // shift is and quotes what to do about it.
178
+ for (const call of [
179
+ incidentTriage.execute({ incidentId: "INC-0001" }, ctx),
180
+ resourcesDispatch.execute({ incidentId: "INC-0001", callsigns: ["Medic-1"] }, ctx),
181
+ incidentUpdateStatus.execute({ incidentId: "INC-0001", status: "on_scene" }, ctx),
182
+ incidentEscalate.execute({ incidentId: "INC-0001", reason: "spreading" }, ctx),
183
+ incidentAddNote.execute({ incidentId: "INC-0001", note: "caller hung up" }, ctx),
184
+ resourcesUpdateStatus.execute({ callsign: "Medic-1", status: "en_route" }, ctx),
185
+ ]) {
186
+ const refusal = await call;
187
+ expect(isToolFailure(refusal)).toBe(true);
188
+ expect(isToolFailure(refusal) && refusal.error).toMatch(/standby/);
189
+ }
190
+
191
+ // And nothing ran: a refusal must not have touched the board.
192
+ expect(dispatchSlot.get(ctx).incidentCounter).toBe(0);
193
+ });
194
+
195
+ test("logging, triaging and dispatching walk the call through its three steps", async () => {
196
+ const ctx = makeCtx();
197
+
198
+ const created = await incidentCreate.execute(
199
+ { location: "400 Oak Street", description: "structure fire with heavy smoke" },
200
+ ctx,
201
+ );
202
+ // `state`/`instruction`, not `at`/`next`: this ungated tool spreads the
203
+ // `DialogPosition` verbatim now, so it reports its position under the same
204
+ // keys every gated tool's result carries.
205
+ expect(created.state).toBe("working.triaging");
206
+ expect(created.instruction).toMatch(/incident_triage/);
207
+
208
+ const triaged = ok<Result<typeof incidentTriage>>(
209
+ await incidentTriage.execute({ incidentId: created.incidentId, severity: "critical" }, ctx),
210
+ );
211
+ expect(triaged.triageScore).toBeGreaterThan(0);
212
+ expect(at(ctx).state).toBe("working.dispatching");
213
+
214
+ ok(
215
+ await resourcesDispatch.execute({ incidentId: created.incidentId, autoDispatch: true }, ctx),
216
+ );
217
+ expect(at(ctx).state).toBe("working.monitoring");
218
+ expect(at(ctx).instruction).toMatch(/radio in/);
219
+ });
220
+
221
+ test("a dispatch that rolled nothing leaves the call where it was", async () => {
222
+ const ctx = makeCtx();
223
+ const incidentId = await createIncidentFor(ctx);
224
+ await incidentTriage.execute({ incidentId }, ctx);
225
+ expect(at(ctx).state).toBe("working.dispatching");
226
+
227
+ // Every requested callsign is unknown, so no unit moved — and the call has
228
+ // not moved on either.
229
+ const result = ok<Result<typeof resourcesDispatch>>(
230
+ await resourcesDispatch.execute({ incidentId, callsigns: ["Ghost-1"] }, ctx),
231
+ );
232
+ expect(result.dispatched).toHaveLength(0);
233
+ expect(at(ctx).state).toBe("working.dispatching");
234
+ });
235
+
236
+ test("a new call is legal mid-incident and puts the flow back on triage", async () => {
237
+ const ctx = makeCtx();
238
+ const first = await createIncidentFor(ctx);
239
+ await incidentTriage.execute({ incidentId: first }, ctx);
240
+ await resourcesDispatch.execute({ incidentId: first, autoDispatch: true }, ctx);
241
+ expect(at(ctx).state).toBe("working.monitoring");
242
+
243
+ await createIncidentFor(ctx, "chemical spill spreading toward a storm drain");
244
+ expect(at(ctx).state).toBe("working.triaging");
245
+
246
+ // The first incident is still workable — the position tracks the call in
247
+ // hand, and the tools are addressed by id.
248
+ ok(await incidentAddNote.execute({ incidentId: first, note: "crews on scene" }, ctx));
249
+ });
250
+
251
+ test("a failed tool does not advance the flow", async () => {
252
+ const ctx = makeCtx();
253
+ const incidentId = await createIncidentFor(ctx);
254
+ await incidentUpdateStatus.execute({ incidentId, status: "resolved" }, ctx);
255
+ const before = at(ctx).state;
256
+
257
+ const refused = await incidentTriage.execute({ incidentId, severity: "critical" }, ctx);
258
+ expect(isToolFailure(refused)).toBe(true);
259
+ expect(at(ctx).state).toBe(before);
260
+ });
261
+
262
+ test("a training scenario logs incidents like a real call does", async () => {
263
+ const ctx = makeCtx();
264
+ const result = await opsRunScenario.execute({ scenario: "mass_casualty" }, ctx);
265
+ expect(result.incidentsCreated.length).toBeGreaterThan(1);
266
+ expect(result.state).toBe("working.triaging");
143
267
  });
144
268
  });
@@ -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 { DispatchState, IncidentSummary, Severity, Status } from "./shared.ts";
11
12
  import { dashboardProjection } from "./shared.ts";
@@ -139,15 +140,242 @@ function IncidentCard({ inc }: { inc: IncidentSummary }) {
139
140
  );
140
141
  }
141
142
 
142
- function App() {
143
+ /** One radio line: a message bubble, or the tool call that came after it. */
144
+ function Row({ item }: { item: ConversationItem }) {
145
+ if (item.kind === "tool") {
146
+ const { name, status } = item.toolCall;
147
+ return (
148
+ <div
149
+ className="self-start rounded-md px-2.5 py-1.5 text-[11px] font-mono flex items-center gap-2"
150
+ style={{
151
+ background: "#111827",
152
+ border: "1px solid #1e293b",
153
+ color: status === "pending" ? "#eab308" : "#64748b",
154
+ }}
155
+ >
156
+ <span
157
+ className="w-1.5 h-1.5 rounded-full inline-block"
158
+ style={{
159
+ background: status === "pending" ? "#eab308" : "#22c55e",
160
+ animation: status === "pending" ? "dc-pulse 1s ease-in-out infinite" : "none",
161
+ }}
162
+ />
163
+ {name}
164
+ </div>
165
+ );
166
+ }
167
+ const { role, content } = item.message;
168
+ return (
169
+ <div
170
+ className="rounded-lg text-[13px] max-w-[85%] px-3.5 py-2.5"
171
+ style={{
172
+ lineHeight: 1.6,
173
+ alignSelf: role === "assistant" ? "flex-start" : "flex-end",
174
+ background: role === "assistant" ? "#1e293b" : "#172554",
175
+ animation: "dc-slide-in 0.2s ease-out",
176
+ borderLeft: role === "assistant" ? "3px solid #3b82f6" : "none",
177
+ borderRight: role !== "assistant" ? "3px solid #22d3ee" : "none",
178
+ }}
179
+ >
180
+ <div className="text-[10px] uppercase tracking-wider mb-1" style={{ color: "#64748b" }}>
181
+ {role === "assistant" ? "DISPATCH" : "OPERATOR"}
182
+ </div>
183
+ {content}
184
+ </div>
185
+ );
186
+ }
187
+
188
+ /**
189
+ * The radio log.
190
+ *
191
+ * `useConversation()` rather than a `session.messages.map(...)`: the messages
192
+ * were only half of it. This board runs eight tools and rendered NONE of them,
193
+ * because tool calls live in a second array this page never read — and it also
194
+ * dropped the streaming reply and the thinking indicator. The hook owns the
195
+ * interleave (a tool row follows its anchor message), the `null`-vs-`""`
196
+ * transcript distinction and the thinking-suppression rule; what stays here is
197
+ * the markup, which is the part this template exists to show.
198
+ *
199
+ * It also subscribes per FIELD, so the conversation re-renders at the
200
+ * conversation's rate. The whole-page `useSession()` this replaced re-rendered
201
+ * the incident board on every STT partial.
202
+ */
203
+ function Conversation() {
204
+ const { items, streaming, transcript, thinking } = useConversation();
205
+ return (
206
+ <>
207
+ <AutoScroll
208
+ scrollClassName="dc-messages overflow-y-auto"
209
+ contentClassName="p-4 flex flex-col gap-2"
210
+ >
211
+ {items.length === 0 && !streaming && (
212
+ <div className="text-center p-10 text-[13px]" style={{ color: "#475569" }}>
213
+ Dispatch Command Center standing by. Click START to begin operations.
214
+ </div>
215
+ )}
216
+ {items.map((item) => (
217
+ <Row
218
+ key={item.kind === "message" ? `m${item.message.id}` : item.toolCall.callId}
219
+ item={item}
220
+ />
221
+ ))}
222
+ {streaming !== null && (
223
+ <div
224
+ className="rounded-lg text-[13px] max-w-[85%] px-3.5 py-2.5 self-start"
225
+ style={{ lineHeight: 1.6, background: "#1e293b", borderLeft: "3px solid #3b82f6" }}
226
+ >
227
+ <div className="text-[10px] uppercase tracking-wider mb-1" style={{ color: "#64748b" }}>
228
+ DISPATCH
229
+ </div>
230
+ {streaming}
231
+ </div>
232
+ )}
233
+ {thinking && (
234
+ <div className="self-start text-[11px] px-3.5" style={{ color: "#64748b" }}>
235
+ <span style={{ animation: "dc-pulse 1.2s ease-in-out infinite" }}>· · ·</span>
236
+ </div>
237
+ )}
238
+ </AutoScroll>
239
+
240
+ {transcript.speaking && (
241
+ <div
242
+ className="flex items-center px-4 py-2 text-xs italic min-h-8"
243
+ style={{ background: "#111827", borderTop: "1px solid #1e293b", color: "#64748b" }}
244
+ >
245
+ <span
246
+ className="w-2.5 h-2.5 rounded-full inline-block mr-2"
247
+ style={{ background: "#22c55e", animation: "dc-pulse 1.5s ease-in-out infinite" }}
248
+ />
249
+ {transcript.text}
250
+ </div>
251
+ )}
252
+ </>
253
+ );
254
+ }
255
+
256
+ /**
257
+ * The live status readout, on its own subscription.
258
+ *
259
+ * `useSessionSelector` rather than a field off a whole-page `useSession()`: the
260
+ * header is the only thing here that cares about the session state, and reading
261
+ * it one level up dragged the incident board through every snapshot change.
262
+ */
263
+ function StatusReadout() {
264
+ const state = useSessionSelector((s) => s.state);
265
+ return (
266
+ <>
267
+ <span
268
+ className="w-2.5 h-2.5 rounded-full inline-block"
269
+ style={{
270
+ background: STATE_COLORS[state],
271
+ animation:
272
+ state === "listening"
273
+ ? "dc-pulse 1.5s ease-in-out infinite"
274
+ : state === "thinking"
275
+ ? "dc-pulse 0.8s ease-in-out infinite"
276
+ : "none",
277
+ }}
278
+ title={state}
279
+ />
280
+ <span className="text-[11px] font-normal normal-case" style={{ color: "#64748b" }}>
281
+ {state === "listening"
282
+ ? "LISTENING"
283
+ : state === "thinking"
284
+ ? "PROCESSING"
285
+ : state === "speaking"
286
+ ? "TRANSMITTING"
287
+ : state.toUpperCase()}
288
+ </span>
289
+ </>
290
+ );
291
+ }
292
+
293
+ /**
294
+ * The fatal-error banner.
295
+ *
296
+ * `role="alert"` is the part that is easy to leave out and is load-bearing: per
297
+ * the `fatalError` latch in the session core this banner is the only remaining
298
+ * signal — the status eyebrow beside it goes back to reading like a live
299
+ * session — and a screen reader is never told an unannounced one appeared. It
300
+ * is what `ConsoleShell` carries, which this template's full-bleed two-pane
301
+ * board cannot use as its frame.
302
+ */
303
+ function ErrorBanner() {
304
+ const error = useSessionSelector((s) => s.error);
305
+ if (!error) return null;
306
+ return (
307
+ <div
308
+ role="alert"
309
+ className="px-4 py-2 text-xs"
310
+ style={{ background: "#450a0a", color: "#fca5a5", borderTop: "1px solid #991b1b" }}
311
+ >
312
+ ERROR: {error.message} ({error.code})
313
+ </div>
314
+ );
315
+ }
316
+
317
+ /** The shift controls. The one place a whole-session read is what is wanted:
318
+ * it needs `started`, `running` and three methods, and it is three buttons. */
319
+ function ShiftControls({ logged }: { logged: number }) {
143
320
  const session = useSession();
144
- // `speaking` and `text` in place of a `userTranscript !== null` check: `null`
145
- // is silence and `""` is speech detected with no words back yet, and reading
146
- // them as one falsy value hides the indicator at the start of every turn.
147
- const { speaking, text: userTranscript } = useUserTranscript();
321
+ return (
322
+ <div
323
+ className="flex items-center gap-2.5 px-4 py-3"
324
+ style={{ background: "#111827", borderTop: "1px solid #1e293b" }}
325
+ >
326
+ {!session.started ? (
327
+ <button
328
+ type="button"
329
+ className="px-4 py-2 border-none rounded-md font-mono text-xs font-semibold uppercase tracking-wider cursor-pointer text-white"
330
+ style={{ background: "#2563eb" }}
331
+ onClick={() => session.start()}
332
+ >
333
+ Start Dispatch
334
+ </button>
335
+ ) : (
336
+ <>
337
+ <button
338
+ type="button"
339
+ className="px-4 py-2 border-none rounded-md font-mono text-xs font-semibold uppercase tracking-wider cursor-pointer"
340
+ style={{
341
+ background: session.running ? "#334155" : "#2563eb",
342
+ color: session.running ? "#e2e8f0" : "white",
343
+ }}
344
+ onClick={() => session.toggle()}
345
+ >
346
+ {session.running ? "Pause" : "Resume"}
347
+ </button>
348
+ {/* end() hangs up and flips `started` back, so the UI
349
+ returns to "Start Dispatch" and the next start is a
350
+ brand-new shift (fresh incident board, greeting
351
+ included). reset() would keep the call live — the
352
+ buttons never toggle back. */}
353
+ <button
354
+ type="button"
355
+ className="px-4 py-2 border-none rounded-md font-mono text-xs font-semibold uppercase tracking-wider cursor-pointer text-white"
356
+ style={{ background: "#dc2626" }}
357
+ onClick={() => session.end()}
358
+ >
359
+ End Shift
360
+ </button>
361
+ </>
362
+ )}
363
+ <div className="flex-1" />
364
+ <span className="text-[10px]" style={{ color: "#475569" }}>
365
+ {logged} incident{logged !== 1 ? "s" : ""} logged
366
+ </span>
367
+ </div>
368
+ );
369
+ }
370
+
371
+ function App() {
148
372
  // The agent's own board, projected by `syncState`. This replaced a
149
373
  // useState mirror that merged incident deltas out of tool events — the
150
374
  // projection is already the complete list, so there is nothing to merge.
375
+ //
376
+ // It is the ONLY subscription at this level: the session reads that used to
377
+ // sit beside it moved into the four components above, so a partial transcript
378
+ // no longer re-renders the incident cards.
151
379
  const dash = useAgentState(dashboardProjection);
152
380
 
153
381
  const incidentList = [...dash.incidents].reverse();
@@ -179,28 +407,7 @@ function App() {
179
407
  >
180
408
  <span style={{ color: "#3b82f6" }}>&#9670;</span>
181
409
  Dispatch Command Center
182
- <span
183
- className="w-2.5 h-2.5 rounded-full inline-block"
184
- style={{
185
- background: STATE_COLORS[session.state],
186
- animation:
187
- session.state === "listening"
188
- ? "dc-pulse 1.5s ease-in-out infinite"
189
- : session.state === "thinking"
190
- ? "dc-pulse 0.8s ease-in-out infinite"
191
- : "none",
192
- }}
193
- title={session.state}
194
- />
195
- <span className="text-[11px] font-normal normal-case" style={{ color: "#64748b" }}>
196
- {session.state === "listening"
197
- ? "LISTENING"
198
- : session.state === "thinking"
199
- ? "PROCESSING"
200
- : session.state === "speaking"
201
- ? "TRANSMITTING"
202
- : session.state.toUpperCase()}
203
- </span>
410
+ <StatusReadout />
204
411
  </div>
205
412
  <div className="flex gap-2 items-center">
206
413
  <span className="text-[10px] tracking-wider" style={{ color: "#64748b" }}>
@@ -229,106 +436,9 @@ function App() {
229
436
  className="flex flex-col overflow-hidden"
230
437
  style={{ borderRight: "1px solid #1e293b" }}
231
438
  >
232
- <AutoScroll
233
- scrollClassName="dc-messages overflow-y-auto"
234
- contentClassName="p-4 flex flex-col gap-2"
235
- >
236
- {session.messages.length === 0 && (
237
- <div className="text-center p-10 text-[13px]" style={{ color: "#475569" }}>
238
- Dispatch Command Center standing by. Click START to begin operations.
239
- </div>
240
- )}
241
- {session.messages.map((m: ChatMessage, i: number) => (
242
- <div
243
- key={i}
244
- className="rounded-lg text-[13px] max-w-[85%] px-3.5 py-2.5"
245
- style={{
246
- lineHeight: 1.6,
247
- alignSelf: m.role === "assistant" ? "flex-start" : "flex-end",
248
- background: m.role === "assistant" ? "#1e293b" : "#172554",
249
- animation: "dc-slide-in 0.2s ease-out",
250
- borderLeft: m.role === "assistant" ? "3px solid #3b82f6" : "none",
251
- borderRight: m.role !== "assistant" ? "3px solid #22d3ee" : "none",
252
- }}
253
- >
254
- <div
255
- className="text-[10px] uppercase tracking-wider mb-1"
256
- style={{ color: "#64748b" }}
257
- >
258
- {m.role === "assistant" ? "DISPATCH" : "OPERATOR"}
259
- </div>
260
- {m.content}
261
- </div>
262
- ))}
263
- </AutoScroll>
264
-
265
- {speaking && (
266
- <div
267
- className="flex items-center px-4 py-2 text-xs italic min-h-8"
268
- style={{ background: "#111827", borderTop: "1px solid #1e293b", color: "#64748b" }}
269
- >
270
- <span
271
- className="w-2.5 h-2.5 rounded-full inline-block mr-2"
272
- style={{ background: "#22c55e", animation: "dc-pulse 1.5s ease-in-out infinite" }}
273
- />
274
- {userTranscript}
275
- </div>
276
- )}
277
- {session.error && (
278
- <div
279
- className="px-4 py-2 text-xs"
280
- style={{ background: "#450a0a", color: "#fca5a5", borderTop: "1px solid #991b1b" }}
281
- >
282
- ERROR: {session.error.message} ({session.error.code})
283
- </div>
284
- )}
285
-
286
- <div
287
- className="flex items-center gap-2.5 px-4 py-3"
288
- style={{ background: "#111827", borderTop: "1px solid #1e293b" }}
289
- >
290
- {!session.started ? (
291
- <button
292
- type="button"
293
- className="px-4 py-2 border-none rounded-md font-mono text-xs font-semibold uppercase tracking-wider cursor-pointer text-white"
294
- style={{ background: "#2563eb" }}
295
- onClick={() => session.start()}
296
- >
297
- Start Dispatch
298
- </button>
299
- ) : (
300
- <>
301
- <button
302
- type="button"
303
- className="px-4 py-2 border-none rounded-md font-mono text-xs font-semibold uppercase tracking-wider cursor-pointer"
304
- style={{
305
- background: session.running ? "#334155" : "#2563eb",
306
- color: session.running ? "#e2e8f0" : "white",
307
- }}
308
- onClick={() => session.toggle()}
309
- >
310
- {session.running ? "Pause" : "Resume"}
311
- </button>
312
- {/* end() hangs up and flips `started` back, so the UI
313
- returns to "Start Dispatch" and the next start is a
314
- brand-new shift (fresh incident board, greeting
315
- included). reset() would keep the call live — the
316
- buttons never toggle back. */}
317
- <button
318
- type="button"
319
- className="px-4 py-2 border-none rounded-md font-mono text-xs font-semibold uppercase tracking-wider cursor-pointer text-white"
320
- style={{ background: "#dc2626" }}
321
- onClick={() => session.end()}
322
- >
323
- End Shift
324
- </button>
325
- </>
326
- )}
327
- <div className="flex-1" />
328
- <span className="text-[10px]" style={{ color: "#475569" }}>
329
- {incidentList.length} incident{incidentList.length !== 1 ? "s" : ""} logged
330
- </span>
331
- </div>
439
+ <Conversation />
440
+ <ErrorBanner />
441
+ <ShiftControls logged={incidentList.length} />
332
442
  </div>
333
443
 
334
444
  {/* Right: sidebar dashboard */}