@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.
- package/dist/{_agent-DpH2pBJd.mjs → _agent-BTwZJu4P.mjs} +2 -2
- package/dist/{_bundler-C2BDwU7r.mjs → _bundler-B4RqNF5Z.mjs} +2 -2
- package/dist/_dev-env.d.ts +22 -2
- package/dist/{_dev-server-LLLWnFBs.mjs → _dev-server-CiKFH9qw.mjs} +38 -47
- package/dist/_dev-server.d.ts +0 -17
- package/dist/{_init-C57bWAQ8.mjs → _init-CE4HKdgV.mjs} +3 -2
- package/dist/{_preflight-BtfaYtbE.mjs → _preflight-Co1G9Jww.mjs} +1 -1
- package/dist/_preflight.d.ts +1 -1
- package/dist/{_slug-api-BxrsJXVI.mjs → _slug-api-nRFaBEPJ.mjs} +2 -2
- package/dist/{_studio-U3jhKlA3.mjs → _studio-DR5yzZ1T.mjs} +2 -1
- package/dist/{_templates-Dxb_P2Wz.mjs → _templates-FI3xPEMj.mjs} +1 -1
- package/dist/_workflow-scan.d.ts +108 -0
- package/dist/{build-Dfza2pRI.mjs → build-6jfaakgc.mjs} +5 -2
- package/dist/cli.mjs +30 -25
- package/dist/{client-bundler-BEIqgOtd.mjs → client-bundler-CWnG42cU.mjs} +2 -1
- package/dist/client-bundler.mjs +1 -1
- package/dist/{delete-vbpjGzqs.mjs → delete-Dl_LSfvI.mjs} +3 -3
- package/dist/{deploy-B8lubiRT.mjs → deploy-DvYeD8ia.mjs} +5 -5
- package/dist/{dev-Uq5ujP8a.mjs → dev-B59JIfWO.mjs} +1 -1
- package/dist/{eject-31gjtaHF.mjs → eject-C9WJyyr2.mjs} +1 -1
- package/dist/{init-l6xfU4xX.mjs → init-CpJVjEg0.mjs} +3 -3
- package/dist/{login-C71-qz8F.mjs → login-BBuM1sxH.mjs} +3 -4
- package/dist/{logs-BWVGSjLR.mjs → logs-9q-psa4q.mjs} +2 -2
- package/dist/project-config.mjs +1 -1
- package/dist/scaffold/CLAUDE.md +40 -19
- package/dist/scaffold/package.json +5 -3
- package/dist/scaffold/server.mjs +1 -1
- package/dist/{secret-BuMuFR4B.mjs → secret-CVvSLIDV.mjs} +2 -2
- package/dist/{storage-DzRZ-eCw.mjs → storage-BvUrnvM3.mjs} +2 -2
- package/dist/{studio-BCNUpDgP.mjs → studio--MUV0cid.mjs} +4 -4
- package/dist/templates/call-audit/agent.test.ts +57 -204
- package/dist/templates/call-audit/agent.ts +32 -19
- package/dist/templates/call-audit/client.tsx +17 -54
- package/dist/templates/call-audit/workflows/audit.ts +11 -18
- package/dist/templates/call-audit/workflows/ingest.ts +106 -114
- package/dist/templates/call-audit/workflows/media.ts +2 -12
- package/dist/templates/call-audit/workflows/summarize.ts +47 -52
- package/dist/templates/call-audit/workflows/sync-api.ts +7 -7
- package/dist/templates/dispatch-center/agent.test.ts +149 -25
- package/dist/templates/dispatch-center/client.tsx +239 -129
- package/dist/templates/dispatch-center/shared.ts +99 -1
- package/dist/templates/dispatch-center/system-prompt.md +3 -1
- package/dist/templates/dispatch-center/tools/incident_add_note.ts +16 -13
- package/dist/templates/dispatch-center/tools/incident_create.ts +19 -1
- package/dist/templates/dispatch-center/tools/incident_escalate.ts +68 -59
- package/dist/templates/dispatch-center/tools/incident_triage.ts +57 -43
- package/dist/templates/dispatch-center/tools/incident_update_status.ts +49 -40
- package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +8 -2
- package/dist/templates/dispatch-center/tools/resources_dispatch.ts +69 -52
- package/dist/templates/dispatch-center/tools/resources_update_status.ts +45 -41
- package/dist/templates/embedded-assets/agent.test.ts +17 -14
- package/dist/templates/health-assistant/agent.test.ts +22 -4
- package/dist/templates/infocom-adventure/agent.test.ts +4 -6
- package/dist/templates/infocom-adventure/client.tsx +246 -164
- package/dist/templates/link-digest/agent.test.ts +24 -19
- package/dist/templates/link-digest/client.tsx +47 -61
- package/dist/templates/link-digest/workflows/digest.ts +19 -31
- package/dist/templates/night-owl/agent.test.ts +70 -19
- package/dist/templates/night-owl/agent.ts +5 -0
- package/dist/templates/night-owl/client.tsx +56 -56
- package/dist/templates/night-owl/shared.ts +24 -0
- package/dist/templates/night-owl/tools/recommend.ts +18 -6
- package/dist/templates/pizza-ordering/agent.test.ts +27 -27
- package/dist/templates/pizza-ordering/client.tsx +9 -26
- package/dist/templates/plan-and-execute/agent.test.ts +99 -58
- package/dist/templates/plan-and-execute/agent.ts +1 -1
- package/dist/templates/plan-and-execute/client.tsx +12 -15
- package/dist/templates/plan-and-execute/shared.ts +71 -2
- package/dist/templates/plan-and-execute/tools/plan_status.ts +18 -6
- package/dist/templates/plan-and-execute/tools/revise_plan.ts +18 -10
- package/dist/templates/plan-and-execute/tools/start_plan.ts +13 -2
- package/dist/templates/plan-and-execute/tools/work_next_step.ts +70 -22
- package/dist/templates/podcast-digest/agent.test.ts +746 -0
- package/dist/templates/podcast-digest/agent.ts +139 -0
- package/dist/templates/podcast-digest/client.tsx +154 -0
- package/dist/templates/podcast-digest/workflows/digest.ts +411 -0
- package/dist/templates/podcast-digest/workflows/feeds.ts +507 -0
- package/dist/templates/podcast-digest/workflows/slack.ts +209 -0
- package/dist/templates/recap-workflow/agent.test.ts +97 -94
- package/dist/templates/recap-workflow/tools/recap_progress.ts +9 -12
- package/dist/templates/recap-workflow/tools/recap_status.ts +8 -9
- package/dist/templates/recap-workflow/tools/request_recap.ts +2 -1
- package/dist/templates/recap-workflow/workflows/recap.ts +36 -40
- package/dist/templates/redline/agent.test.ts +17 -15
- package/dist/templates/redline/client.tsx +12 -12
- package/dist/templates/redline/workflows/redline.ts +19 -31
- package/dist/templates/research-workflow/agent.test.ts +60 -59
- package/dist/templates/research-workflow/tools/research_progress.ts +9 -12
- package/dist/templates/research-workflow/tools/research_status.ts +9 -11
- package/dist/templates/research-workflow/workflows/research.ts +44 -61
- package/dist/templates/retail/agent.test.ts +26 -23
- package/dist/templates/retail/client.tsx +226 -117
- package/dist/templates/retail/registry.test.ts +38 -6
- package/dist/templates/retail/store.test.ts +82 -15
- package/dist/templates/retail/store.ts +174 -47
- package/dist/templates/retail/system-prompt.md +11 -2
- package/dist/templates/retail/tools/cancel_pending_order.ts +2 -2
- package/dist/templates/retail/tools/exchange_delivered_order_items.ts +2 -2
- package/dist/templates/retail/tools/find_user_id_by_email.ts +8 -5
- package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +4 -5
- package/dist/templates/retail/tools/get_item_details.ts +3 -4
- package/dist/templates/retail/tools/get_order_details.ts +3 -4
- package/dist/templates/retail/tools/get_product_details.ts +3 -4
- package/dist/templates/retail/tools/get_user_details.ts +2 -2
- package/dist/templates/retail/tools/list_all_product_types.ts +5 -5
- package/dist/templates/retail/tools/modify_pending_order_address.ts +2 -2
- package/dist/templates/retail/tools/modify_pending_order_items.ts +2 -3
- package/dist/templates/retail/tools/modify_pending_order_payment.ts +2 -2
- package/dist/templates/retail/tools/modify_user_address.ts +2 -2
- package/dist/templates/retail/tools/return_delivered_order_items.ts +2 -2
- package/dist/templates/retail/tools/transfer_to_human_agents.ts +8 -4
- package/dist/templates/solo-rpg/agent.test.ts +227 -48
- package/dist/templates/solo-rpg/client.tsx +38 -37
- package/dist/templates/solo-rpg/shared.ts +145 -19
- package/dist/templates/solo-rpg/system-prompt.md +3 -2
- package/dist/templates/solo-rpg/tools/action_roll.ts +82 -63
- package/dist/templates/solo-rpg/tools/burn_momentum.ts +85 -50
- package/dist/templates/solo-rpg/tools/check_state.ts +24 -7
- package/dist/templates/solo-rpg/tools/load_game.ts +13 -1
- package/dist/templates/solo-rpg/tools/save_game.ts +16 -7
- package/dist/templates/solo-rpg/tools/setup_character.ts +22 -2
- package/dist/templates/solo-rpg/tools/update_state.ts +113 -100
- package/dist/templates/spoken-summary/agent.test.ts +68 -97
- package/dist/templates/spoken-summary/agent.ts +30 -17
- package/dist/templates/spoken-summary/client.tsx +10 -50
- package/dist/templates/spoken-summary/workflows/summarize.ts +17 -22
- package/dist/templates/spoken-summary/workflows/transcribe.ts +19 -26
- package/dist/templates/support-line/agent.test.ts +11 -16
- package/dist/templates/support-line/agent.ts +1 -1
- package/dist/templates/support-line/client.tsx +9 -9
- package/dist/templates/support-line/nodes.ts +100 -0
- package/dist/templates/support-line/procedure.ts +407 -0
- package/dist/templates/support-line/tools/answer_question.ts +17 -4
- package/dist/templates/transcription-workflow/agent.test.ts +94 -194
- package/dist/templates/transcription-workflow/agent.ts +1 -1
- package/dist/templates/transcription-workflow/client.tsx +17 -42
- package/dist/templates/transcription-workflow/workflows/batch.ts +19 -22
- package/dist/templates/transcription-workflow/workflows/normalize.ts +90 -186
- package/dist/templates/transcription-workflow/workflows/stitch.ts +0 -11
- package/dist/templates/transcription-workflow/workflows/stream.ts +4 -5
- package/dist/templates/transcription-workflow/workflows/sync-api.ts +6 -6
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +32 -26
- package/dist/templates/transcription-workflow/workflows/wav.ts +0 -31
- package/dist/templates/travel-concierge/agent.test.ts +64 -33
- package/dist/templates/travel-concierge/client.tsx +11 -23
- package/dist/templates/travel-concierge/routing.ts +34 -15
- package/dist/templates/travel-concierge/shared.ts +70 -3
- package/dist/templates/travel-concierge/tools/book_car_rental.ts +2 -2
- package/dist/templates/travel-concierge/tools/book_excursion.ts +2 -2
- package/dist/templates/travel-concierge/tools/book_hotel.ts +2 -2
- package/dist/templates/travel-concierge/tools/cancel_ticket.ts +2 -2
- package/dist/templates/travel-concierge/tools/update_ticket.ts +2 -2
- package/dist/{worker-bundler-CGD4r8Kc.mjs → worker-bundler-COxnqstQ.mjs} +221 -3
- package/dist/worker-bundler.mjs +1 -1
- package/dist/{workflow-CFpxOFfQ.mjs → workflow-D2AQf2Pl.mjs} +28 -1
- package/dist/workflow-bundler.d.ts +6 -1
- package/dist/workflow.d.ts +1 -1
- package/package.json +5 -4
- package/dist/templates/call-audit/workflows/temp-media.ts +0 -138
- package/dist/templates/support-line/graph.ts +0 -224
- package/dist/{_config-D_s09e7g.mjs → _config-CmJOFsAP.mjs} +1 -1
- /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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
284
|
-
|
|
285
|
-
|
|
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
|
|
10
|
-
* else must refuse. Listed here
|
|
11
|
-
* deliberate edit to this file, not a
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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("
|
|
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)
|
|
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
|
|
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
|
-
|
|
217
|
-
|
|
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
|
|
235
|
+
test("runs once the flow says serving", async () => {
|
|
221
236
|
const ctx = makeCtx();
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
+
});
|