@alexkroman1/aai-cli 13.2.0 → 14.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 (152) hide show
  1. package/bin.mjs +1 -1
  2. package/dist/{_agent-BTwZJu4P.mjs → _agent-BzUeqOdj.mjs} +33 -10
  3. package/dist/_agent.d.ts +23 -0
  4. package/dist/{_bundler-C31rlcYz.mjs → _bundler-DolUCMxu.mjs} +9 -8
  5. package/dist/_cli-common.d.ts +45 -6
  6. package/dist/{_config-CmJOFsAP.mjs → _config-DYzC6WMD.mjs} +7 -4
  7. package/dist/_config.d.ts +25 -1
  8. package/dist/_dev-env.d.ts +1 -1
  9. package/dist/{_dev-server-DWvkNoIS.mjs → _dev-server-CSMqF8PN.mjs} +28 -30
  10. package/dist/_dev-vite-config.d.ts +3 -3
  11. package/dist/_e2e-test-utils.d.ts +15 -7
  12. package/dist/{_init-BURbdHNM.mjs → _init-CQ8idAwo.mjs} +64 -30
  13. package/dist/{_output-CKkmWs7i.mjs → _output-DBP9Op_d.mjs} +43 -3
  14. package/dist/_output.d.ts +24 -7
  15. package/dist/{_preflight-Co1G9Jww.mjs → _preflight-CcAR577V.mjs} +3 -3
  16. package/dist/_resource-commands.d.ts +9 -9
  17. package/dist/{_server-common-6e0QI4mq.mjs → _server-common-vILJp3it.mjs} +3 -3
  18. package/dist/{_slug-api-CfGQq1Tt.mjs → _slug-api-CGvc2YSQ.mjs} +19 -10
  19. package/dist/_slug-api.d.ts +19 -0
  20. package/dist/{_studio-B1waXMuR.mjs → _studio-DnR_BqFp.mjs} +9 -5
  21. package/dist/_studio-commands.d.ts +13 -13
  22. package/dist/{_templates-0Zr2z6yA.mjs → _templates-CK4oKoeX.mjs} +3 -3
  23. package/dist/{_typecheck-gate-BXvUNnfs.mjs → _typecheck-gate-U3jcil8L.mjs} +5 -4
  24. package/dist/_typecheck-gate.d.ts +3 -1
  25. package/dist/{_ui-u7T4YooX.mjs → _ui-DCt4qZrk.mjs} +1 -1
  26. package/dist/{_utils-B8QmtFhK.mjs → _utils-D5JGcjiW.mjs} +44 -2
  27. package/dist/_utils.d.ts +35 -0
  28. package/dist/{_vite-env-D9Qj6KPX.mjs → _vite-env-kejF0p2L.mjs} +1 -1
  29. package/dist/{_workflow-determinism-f9J-5Zlj.mjs → _workflow-determinism-CvpqMpdh.mjs} +19 -27
  30. package/dist/_workflow-determinism.d.ts +3 -3
  31. package/dist/build-Mxk8gWvX.mjs +108 -0
  32. package/dist/build.d.ts +5 -0
  33. package/dist/cli.mjs +172 -116
  34. package/dist/{client-bundler-j5c46x0G.mjs → client-bundler-BJgREAh6.mjs} +4 -4
  35. package/dist/client-bundler.mjs +1 -1
  36. package/dist/{delete-X_CRgCf6.mjs → delete-BdFnbnVR.mjs} +6 -6
  37. package/dist/{deploy-BzZ-r4iM.mjs → deploy-uAJ4NukN.mjs} +14 -14
  38. package/dist/{dev-BDykFvcc.mjs → dev-DApPSaE_.mjs} +5 -5
  39. package/dist/{eval-0qEOdm0l.mjs → eval-BK47A_K5.mjs} +27 -11
  40. package/dist/eval.d.ts +2 -0
  41. package/dist/{init-BtJI306C.mjs → init-DukDxECd.mjs} +13 -21
  42. package/dist/{login-DeUETobb.mjs → login-DaLrPOFg.mjs} +6 -8
  43. package/dist/{logs-hWnJ66Bl.mjs → logs-7BRwq0cG.mjs} +22 -15
  44. package/dist/project-config.mjs +1 -1
  45. package/dist/scaffold/.env.example +17 -1
  46. package/dist/scaffold/CLAUDE.md +84 -56
  47. package/dist/scaffold/package.json +6 -5
  48. package/dist/scaffold/server.mjs +129 -25
  49. package/dist/secret-Cjp912yF.mjs +162 -0
  50. package/dist/secret.d.ts +48 -13
  51. package/dist/{studio-BxAS_FQQ.mjs → studio-CpHlNHUZ.mjs} +15 -17
  52. package/dist/templates/briefing-desk/agent.test.ts +7 -3
  53. package/dist/templates/call-audit/agent.test.ts +25 -9
  54. package/dist/templates/call-audit/agent.ts +2 -2
  55. package/dist/templates/call-audit/client.tsx +40 -58
  56. package/dist/templates/call-audit/workflows/audit.ts +7 -5
  57. package/dist/templates/call-audit/workflows/ingest.ts +7 -7
  58. package/dist/templates/call-audit/workflows/media.ts +12 -37
  59. package/dist/templates/call-audit/workflows/summarize.ts +8 -8
  60. package/dist/templates/call-audit/workflows/sync-api.ts +3 -3
  61. package/dist/templates/dispatch-center/agent.test.ts +7 -7
  62. package/dist/templates/dispatch-center/client.tsx +53 -61
  63. package/dist/templates/dispatch-center/shared.ts +0 -8
  64. package/dist/templates/dispatch-center/tools/ops_dashboard.ts +4 -1
  65. package/dist/templates/embedded-assets/agent.eval.test.ts +4 -2
  66. package/dist/templates/embedded-assets/agent.test.ts +7 -1
  67. package/dist/templates/health-assistant/agent.eval.test.ts +4 -2
  68. package/dist/templates/health-assistant/agent.test.ts +8 -4
  69. package/dist/templates/infocom-adventure/client.tsx +40 -36
  70. package/dist/templates/link-digest/agent.test.ts +10 -4
  71. package/dist/templates/link-digest/agent.ts +2 -2
  72. package/dist/templates/link-digest/client.tsx +42 -36
  73. package/dist/templates/link-digest/workflows/digest.ts +13 -16
  74. package/dist/templates/night-owl/agent.eval.test.ts +4 -2
  75. package/dist/templates/night-owl/agent.test.ts +6 -1
  76. package/dist/templates/night-owl/client.tsx +8 -2
  77. package/dist/templates/pipeline-simple/agent.test.ts +33 -16
  78. package/dist/templates/pizza-ordering/client.tsx +5 -4
  79. package/dist/templates/pizza-ordering/shared.ts +3 -8
  80. package/dist/templates/pizza-ordering/tools/add_pizza.ts +3 -2
  81. package/dist/templates/pizza-ordering/tools/place_order.ts +3 -2
  82. package/dist/templates/pizza-ordering/tools/remove_pizza.ts +3 -2
  83. package/dist/templates/pizza-ordering/tools/update_pizza.ts +3 -2
  84. package/dist/templates/pizza-ordering/tools/view_order.ts +3 -2
  85. package/dist/templates/plan-and-execute/agent.test.ts +10 -5
  86. package/dist/templates/plan-and-execute/client.tsx +2 -2
  87. package/dist/templates/podcast-digest/agent.test.ts +33 -14
  88. package/dist/templates/podcast-digest/client.tsx +27 -36
  89. package/dist/templates/podcast-digest/workflows/digest.ts +21 -19
  90. package/dist/templates/podcast-digest/workflows/feeds.ts +61 -33
  91. package/dist/templates/podcast-digest/workflows/slack.ts +7 -7
  92. package/dist/templates/recap-workflow/agent.eval.test.ts +21 -13
  93. package/dist/templates/recap-workflow/agent.test.ts +49 -32
  94. package/dist/templates/recap-workflow/workflows/recap.ts +24 -28
  95. package/dist/templates/redline/agent.eval.test.ts +5 -8
  96. package/dist/templates/redline/agent.test.ts +8 -2
  97. package/dist/templates/redline/client.tsx +47 -48
  98. package/dist/templates/redline/workflows/redline.ts +17 -17
  99. package/dist/templates/research-workflow/agent.eval.test.ts +5 -5
  100. package/dist/templates/research-workflow/agent.test.ts +17 -8
  101. package/dist/templates/research-workflow/workflows/research.ts +28 -30
  102. package/dist/templates/retail/agent.test.ts +17 -15
  103. package/dist/templates/retail/cancel.ts +7 -6
  104. package/dist/templates/retail/client.tsx +39 -58
  105. package/dist/templates/retail/payment.ts +4 -3
  106. package/dist/templates/retail/pending.test.ts +19 -19
  107. package/dist/templates/retail/registry.test.ts +99 -58
  108. package/dist/templates/retail/store.test.ts +5 -5
  109. package/dist/templates/retail/swap.ts +8 -11
  110. package/dist/templates/simple/agent.test.ts +39 -6
  111. package/dist/templates/solo-rpg/agent.eval.test.ts +2 -16
  112. package/dist/templates/solo-rpg/agent.test.ts +12 -12
  113. package/dist/templates/solo-rpg/client.tsx +3 -3
  114. package/dist/templates/spoken-summary/agent.test.ts +21 -12
  115. package/dist/templates/spoken-summary/agent.ts +3 -3
  116. package/dist/templates/spoken-summary/client.tsx +36 -47
  117. package/dist/templates/spoken-summary/workflows/summarize.ts +11 -11
  118. package/dist/templates/spoken-summary/workflows/transcribe.ts +14 -14
  119. package/dist/templates/support-line/client.tsx +4 -3
  120. package/dist/templates/support-line/prompts.ts +0 -2
  121. package/dist/templates/transcription-workflow/agent.test.ts +36 -20
  122. package/dist/templates/transcription-workflow/agent.ts +2 -2
  123. package/dist/templates/transcription-workflow/client.tsx +34 -40
  124. package/dist/templates/transcription-workflow/recover.ts +1 -1
  125. package/dist/templates/transcription-workflow/run-panel.tsx +34 -14
  126. package/dist/templates/transcription-workflow/total-latency.tsx +46 -20
  127. package/dist/templates/transcription-workflow/workflows/batch.ts +15 -15
  128. package/dist/templates/transcription-workflow/workflows/downsample.ts +1 -1
  129. package/dist/templates/transcription-workflow/workflows/normalize.ts +15 -19
  130. package/dist/templates/transcription-workflow/workflows/stream.ts +12 -12
  131. package/dist/templates/transcription-workflow/workflows/sync-api.ts +3 -3
  132. package/dist/templates/transcription-workflow/workflows/transcribe.ts +23 -34
  133. package/dist/templates/travel-concierge/agent.eval.test.ts +2 -2
  134. package/dist/templates/travel-concierge/agent.test.ts +15 -7
  135. package/dist/templates/travel-concierge/client.tsx +2 -2
  136. package/dist/templates/travel-concierge/shared.ts +10 -14
  137. package/dist/templates/travel-concierge/tools/lookup_booking.ts +4 -3
  138. package/dist/templates/travel-concierge/tools/search_car_rentals.ts +3 -2
  139. package/dist/templates/travel-concierge/tools/search_excursions.ts +3 -2
  140. package/dist/templates/travel-concierge/tools/search_flights.ts +3 -2
  141. package/dist/templates/travel-concierge/tools/search_hotels.ts +3 -2
  142. package/dist/test-D032V2cA.mjs +242 -0
  143. package/dist/test.d.ts +97 -31
  144. package/dist/typecheck.mjs +2 -2
  145. package/dist/worker-bundler.mjs +3 -3
  146. package/dist/{workflow-BBu5oEDw.mjs → workflow-CVioyO3V.mjs} +8 -7
  147. package/package.json +10 -10
  148. package/dist/bin.d.mts +0 -2
  149. package/dist/build-BLlLOrng.mjs +0 -66
  150. package/dist/secret-B-3T5tvr.mjs +0 -61
  151. package/dist/test-CiLab-AA.mjs +0 -182
  152. package/dist/tsdown.config.d.ts +0 -2
@@ -35,7 +35,7 @@
35
35
  *
36
36
  * ## Every stage REPORTS, and the report goes two places
37
37
  *
38
- * `report()` (`@alexkroman1/aai/step`) writes to the run's own stream — which
38
+ * `stepReport()` (`@alexkroman1/aai/step`) writes to the run's own stream — which
39
39
  * `research_progress` reads back down the phone and a page renders — and to the
40
40
  * server log, with the attempt number appended past the first. A pass that is
41
41
  * retrying and one that is working print the same sentences otherwise.
@@ -49,9 +49,9 @@
49
49
  * researcher CONCLUDED, which is exactly what the step returns.
50
50
  */
51
51
 
52
- import type { WorkflowCtx } from "@alexkroman1/aai";
53
- import { mapConcurrent, report } from "@alexkroman1/aai/step";
54
- import { stepGenerateClassified, stepGenerateJsonClassified } from "@alexkroman1/aai/step-errors";
52
+ import type { WorkflowContext } from "@alexkroman1/aai";
53
+ import { mapConcurrent, stepReport } from "@alexkroman1/aai/step";
54
+ import { stepGenerateJsonOrFail, stepGenerateOrFail } from "@alexkroman1/aai/step-errors";
55
55
  import { visitWebpage, webSearch } from "@alexkroman1/aai/tools";
56
56
  import { errorMessage, isToolFailure, plural } from "@alexkroman1/aai/utils";
57
57
  import { z } from "zod";
@@ -107,7 +107,7 @@ export type Source = { title: string; url: string };
107
107
 
108
108
  // ---- What each stage's model call has to come back as ------------------------
109
109
  //
110
- // `stepGenerateJsonClassified` validates against these, so a reply that missed
110
+ // `stepGenerateJsonOrFail` validates against these, so a reply that missed
111
111
  // is a plain throw and therefore a retry — where the hand-rolled `askJson<T>()`
112
112
  // this replaces returned a value the compiler believed and nothing checked. They are
113
113
  // deliberately LENIENT wherever the old hand-written coercion was: a model that
@@ -212,7 +212,7 @@ export type Findings = {
212
212
  */
213
213
  export async function researchFlow(
214
214
  input: { topic: string; requestedBy: string },
215
- ctx: WorkflowCtx,
215
+ ctx: WorkflowContext,
216
216
  ) {
217
217
  const brief = await ctx.step("writeBrief", () => writeBrief(input.topic));
218
218
  const angles = await ctx.step("planAngles", () => planAngles(brief));
@@ -270,11 +270,11 @@ export async function researchFlow(
270
270
  * later model call.
271
271
  */
272
272
  export async function writeBrief(topic: string): Promise<Brief> {
273
- await report(`Working out what "${topic}" is really asking.`);
274
- const parsed = await stepGenerateJsonClassified(
275
- `Research request, as the caller said it: ${topic}`,
276
- { system: BRIEF_SYSTEM, schema: BriefReply },
277
- );
273
+ await stepReport(`Working out what "${topic}" is really asking.`);
274
+ const parsed = await stepGenerateJsonOrFail(`Research request, as the caller said it: ${topic}`, {
275
+ system: BRIEF_SYSTEM,
276
+ schema: BriefReply,
277
+ });
278
278
  return { brief: parsed.brief || topic, criteria: parsed.criteria.slice(0, MAX_ANGLES) };
279
279
  }
280
280
 
@@ -286,7 +286,7 @@ export async function writeBrief(topic: string): Promise<Brief> {
286
286
  * asking the model again and getting a different one.
287
287
  */
288
288
  export async function planAngles(brief: Brief): Promise<string[]> {
289
- const parsed = await stepGenerateJsonClassified(briefText(brief), {
289
+ const parsed = await stepGenerateJsonOrFail(briefText(brief), {
290
290
  system: PLAN_SYSTEM,
291
291
  schema: AnglesReply,
292
292
  });
@@ -294,10 +294,10 @@ export async function planAngles(brief: Brief): Promise<string[]> {
294
294
  if (angles.length === 0) {
295
295
  // Nothing to fan out over is a plan failure, not an empty result: the brief
296
296
  // itself is the one angle that is always available.
297
- await report("No angles came back; researching the brief itself.");
297
+ await stepReport("No angles came back; researching the brief itself.");
298
298
  return [brief.brief];
299
299
  }
300
- await report(`Researching ${angles.length} ${plural(angles.length, "angle")}.`);
300
+ await stepReport(`Researching ${angles.length} ${plural(angles.length, "angle")}.`);
301
301
  return angles;
302
302
  }
303
303
 
@@ -309,7 +309,7 @@ export async function planAngles(brief: Brief): Promise<string[]> {
309
309
  * at the end, which is where it becomes small enough to journal.
310
310
  */
311
311
  export async function investigate(brief: Brief, angle: string): Promise<Note> {
312
- await report(`Looking into: ${angle}`);
312
+ await stepReport(`Looking into: ${angle}`);
313
313
  const seen: string[] = [];
314
314
  const sources: Source[] = [];
315
315
 
@@ -323,7 +323,7 @@ export async function investigate(brief: Brief, angle: string): Promise<Note> {
323
323
  continue;
324
324
  }
325
325
  if (action.action === "read" && action.url) {
326
- await report(`Reading ${hostname(action.url)}`);
326
+ await stepReport(`Reading ${hostname(action.url)}`);
327
327
  seen.push(`PAGE ${action.url}\n${await readPage(action.url)}`);
328
328
  continue;
329
329
  }
@@ -335,8 +335,6 @@ export async function investigate(brief: Brief, angle: string): Promise<Note> {
335
335
  return await compress(angle, seen, sources);
336
336
  }
337
337
 
338
- /** UNUSED-ORPHAN-MARKER Retries beyond the default: the far side is a search engine and a model. */
339
-
340
338
  /**
341
339
  * The supervisor's second look.
342
340
  *
@@ -346,12 +344,12 @@ export async function investigate(brief: Brief, angle: string): Promise<Note> {
346
344
  */
347
345
  export async function findGaps(brief: Brief, notes: readonly Note[]): Promise<string[]> {
348
346
  if (notes.length === 0) return [];
349
- const parsed = await stepGenerateJsonClassified(
347
+ const parsed = await stepGenerateJsonOrFail(
350
348
  `${briefText(brief)}\n\nWhat came back:\n${notes.map(noteText).join("\n\n")}`,
351
349
  { system: GAPS_SYSTEM, schema: AnglesReply },
352
350
  );
353
351
  const gaps = parsed.angles.slice(0, MAX_ANGLES - 1);
354
- await report(
352
+ await stepReport(
355
353
  gaps.length === 0
356
354
  ? "The brief is covered; writing it up."
357
355
  : `Following up ${gaps.length} ${plural(gaps.length, "gap")}.`,
@@ -371,12 +369,12 @@ export async function writeReport(
371
369
  brief: Brief,
372
370
  notes: readonly Note[],
373
371
  ): Promise<{ report: string; summary: string }> {
374
- await report(`Writing up ${notes.length} ${plural(notes.length, "angle")}.`);
375
- const written = await stepGenerateClassified(
372
+ await stepReport(`Writing up ${notes.length} ${plural(notes.length, "angle")}.`);
373
+ const written = await stepGenerateOrFail(
376
374
  `${briefText(brief)}\n\nFindings:\n${notes.map(noteText).join("\n\n")}`,
377
375
  { system: REPORT_SYSTEM },
378
376
  );
379
- const summary = await stepGenerateClassified(`Topic: ${topic}\n\nReport:\n${written}`, {
377
+ const summary = await stepGenerateOrFail(`Topic: ${topic}\n\nReport:\n${written}`, {
380
378
  system: BRIEF_SUMMARY_SYSTEM,
381
379
  });
382
380
  return { report: written, summary };
@@ -390,7 +388,7 @@ export async function writeReport(
390
388
  * parameters carry `_` for the same reason.
391
389
  */
392
390
  export async function file(_requestedBy: string, _topic: string): Promise<string> {
393
- await report("Filing the findings.");
391
+ await stepReport("Filing the findings.");
394
392
  return "filed";
395
393
  }
396
394
 
@@ -406,7 +404,7 @@ async function nextAction(
406
404
  seen: readonly string[],
407
405
  left: number,
408
406
  ): Promise<Action> {
409
- return await stepGenerateJsonClassified(
407
+ return await stepGenerateJsonOrFail(
410
408
  `${briefText(brief)}\n\nYour angle: ${angle}\n` +
411
409
  `Actions left: ${left}\n\n` +
412
410
  (seen.length === 0 ? "You have not looked at anything yet." : seen.join("\n\n")),
@@ -423,7 +421,7 @@ async function nextAction(
423
421
  * pages exist" and gets run again, differently worded, until the budget is gone.
424
422
  */
425
423
  async function search(query: string): Promise<{ summary: string; sources: Source[] }> {
426
- await report(`Searching: ${query}`);
424
+ await stepReport(`Searching: ${query}`);
427
425
  try {
428
426
  const results = await webSearch<{ results?: { title?: string; url?: string }[] }>({
429
427
  query,
@@ -445,7 +443,7 @@ async function search(query: string): Promise<{ summary: string; sources: Source
445
443
  };
446
444
  } catch (err: unknown) {
447
445
  const summary = `That search failed: ${errorMessage(err)}`;
448
- await report(summary);
446
+ await stepReport(summary);
449
447
  return { summary, sources: [] };
450
448
  }
451
449
  }
@@ -475,7 +473,7 @@ async function compress(angle: string, seen: readonly string[], sources: Source[
475
473
  if (seen.length === 0) {
476
474
  return { angle, findings: "Nothing was found on this angle.", sources: [] };
477
475
  }
478
- const parsed = await stepGenerateJsonClassified(`Angle: ${angle}\n\n${seen.join("\n\n")}`, {
476
+ const parsed = await stepGenerateJsonOrFail(`Angle: ${angle}\n\n${seen.join("\n\n")}`, {
479
477
  system: COMPRESS_SYSTEM,
480
478
  schema: CompressReply,
481
479
  });
@@ -492,11 +490,11 @@ async function compress(angle: string, seen: readonly string[], sources: Source[
492
490
  //
493
491
  // There is none left, and its absence is the point. This desk carried an `ask()`
494
492
  // and an `askJson()` whose whole body was `.catch(throwStepError)`; the SDK's
495
- // `stepGenerateClassified` and `stepGenerateJsonClassified`
493
+ // `stepGenerateOrFail` and `stepGenerateJsonOrFail`
496
494
  // (`@alexkroman1/aai/step-errors`) ARE that call — the `/step` one with the
497
495
  // gateway's verdict classified, so a terminal failure stays terminal and a rate
498
496
  // limit becomes a `RetryableError` carrying the delay the gateway itself named.
499
- // `stepGenerateJsonClassified` also owns the four things every JSON stage used
497
+ // `stepGenerateJsonOrFail` also owns the four things every JSON stage used
500
498
  // to re-derive — unwrap the fence, parse, reject a non-object, check the shape —
501
499
  // and throws PLAINLY when any of them misses, which is what makes a malformed
502
500
  // reply a retry rather than a failure.
@@ -1,6 +1,6 @@
1
1
  import type { ToolContext } from "@alexkroman1/aai";
2
2
  import { isToolFailure } from "@alexkroman1/aai";
3
- import { createToolContext, ok } from "@alexkroman1/aai/testing";
3
+ import { createToolContext, expectToolOk } from "@alexkroman1/aai/testing";
4
4
  import { describe, expect, test } from "vitest";
5
5
  import type { AuthResult } from "./authenticate.ts";
6
6
  import type { StagedResult } from "./pending.ts";
@@ -32,7 +32,7 @@ function makeCtx(): ToolContext {
32
32
  /** A context already authenticated as `userId`, via the real tool. */
33
33
  async function authedCtx(email: string): Promise<ToolContext> {
34
34
  const ctx = makeCtx();
35
- ok<AuthResult>(await findUserIdByEmail.execute({ email }, ctx));
35
+ expectToolOk<AuthResult>(await findUserIdByEmail.execute({ email }, ctx));
36
36
  return ctx;
37
37
  }
38
38
 
@@ -47,19 +47,19 @@ async function authedCtx(email: string): Promise<ToolContext> {
47
47
  * would let this gate pass while carrying nothing to confirm.
48
48
  */
49
49
  async function confirmed<R = unknown>(staged: unknown, ctx: ToolContext): Promise<R> {
50
- const stage = ok<StagedResult>(staged);
50
+ const stage = expectToolOk<StagedResult>(staged);
51
51
  // A throw rather than an `expect`, which biome's `noMisplacedAssertion`
52
52
  // rightly refuses outside a test body. A staged change with no sentence to
53
53
  // read back is the one shape that would let the gate pass while carrying
54
54
  // nothing to confirm, so it stops the spec here rather than downstream.
55
55
  if (stage.read_back.length === 0) throw new Error(`${stage.staged} staged with no readback`);
56
- return ok<R>(await confirmChange.execute({}, ctx));
56
+ return expectToolOk<R>(await confirmChange.execute({}, ctx));
57
57
  }
58
58
 
59
59
  describe("authentication", () => {
60
60
  test("find_user_id_by_email is case-insensitive and authenticates the session", async () => {
61
61
  const ctx = makeCtx();
62
- const result = ok<AuthResult>(
62
+ const result = expectToolOk<AuthResult>(
63
63
  await findUserIdByEmail.execute({ email: "OLIVIA.ITO5204@EXAMPLE.COM" }, ctx),
64
64
  );
65
65
  expect(result.user_id).toBe("olivia_ito_3591");
@@ -81,7 +81,7 @@ describe("authentication", () => {
81
81
 
82
82
  test("find_user_id_by_name_zip is case-insensitive on names and exact on zip", async () => {
83
83
  const ctx = makeCtx();
84
- const found = ok<AuthResult>(
84
+ const found = expectToolOk<AuthResult>(
85
85
  await findUserIdByNameZip.execute(
86
86
  { first_name: "aarav", last_name: "ANDERSON", zip: "19031" },
87
87
  ctx,
@@ -97,13 +97,13 @@ describe("authentication", () => {
97
97
  });
98
98
 
99
99
  test("two customers share a first name — the zip is what separates them", async () => {
100
- const a = ok<AuthResult>(
100
+ const a = expectToolOk<AuthResult>(
101
101
  await findUserIdByNameZip.execute(
102
102
  { first_name: "Aarav", last_name: "Anderson", zip: "19031" },
103
103
  makeCtx(),
104
104
  ),
105
105
  );
106
- const b = ok<AuthResult>(
106
+ const b = expectToolOk<AuthResult>(
107
107
  await findUserIdByNameZip.execute(
108
108
  { first_name: "Aarav", last_name: "Gonzalez", zip: "78268" },
109
109
  makeCtx(),
@@ -172,7 +172,7 @@ interface ProductTypesResult {
172
172
  describe("read tools", () => {
173
173
  test("get_user_details returns the caller's profile with gift-card balances", async () => {
174
174
  const ctx = await authedCtx("olivia.ito5204@example.com");
175
- const result = ok<UserDetailsResult>(
175
+ const result = expectToolOk<UserDetailsResult>(
176
176
  await getUserDetails.execute({ user_id: "olivia_ito_3591" }, ctx),
177
177
  );
178
178
  expect(result.email).toBe("olivia.ito5204@example.com");
@@ -189,7 +189,7 @@ describe("read tools", () => {
189
189
 
190
190
  test("get_order_details resolves shorthand and sets focus", async () => {
191
191
  const ctx = await authedCtx("olivia.ito5204@example.com");
192
- const result = ok<OrderDetailsResult>(
192
+ const result = expectToolOk<OrderDetailsResult>(
193
193
  await getOrderDetails.execute({ order_id: "the delivered one" }, ctx),
194
194
  );
195
195
  expect(result.order_id).toBe("#W5866402");
@@ -203,7 +203,7 @@ describe("read tools", () => {
203
203
  });
204
204
 
205
205
  test("get_product_details lists variants and needs no authentication", async () => {
206
- const result = ok<ProductDetailsResult>(
206
+ const result = expectToolOk<ProductDetailsResult>(
207
207
  await getProductDetails.execute({ product_id: "9832717871" }, makeCtx()),
208
208
  );
209
209
  expect(result.name).toBe("Tea Kettle");
@@ -217,7 +217,7 @@ describe("read tools", () => {
217
217
  });
218
218
 
219
219
  test("get_item_details resolves an item without knowing its product", async () => {
220
- const result = ok<ItemDetailsResult>(
220
+ const result = expectToolOk<ItemDetailsResult>(
221
221
  await getItemDetails.execute({ item_id: "3909406921" }, makeCtx()),
222
222
  );
223
223
  expect(result.price).toBe(98.25);
@@ -225,7 +225,9 @@ describe("read tools", () => {
225
225
  });
226
226
 
227
227
  test("list_all_product_types returns all 50, sorted by name", async () => {
228
- const result = ok<ProductTypesResult>(await listAllProductTypes.execute({}, makeCtx()));
228
+ const result = expectToolOk<ProductTypesResult>(
229
+ await listAllProductTypes.execute({}, makeCtx()),
230
+ );
229
231
  expect(Object.keys(result.products)).toHaveLength(50);
230
232
  const names = Object.keys(result.products);
231
233
  // localeCompare, not the default lexicographic sort — the tool sorts for a
@@ -1148,7 +1150,7 @@ interface TransferResult {
1148
1150
 
1149
1151
  describe("transfer_to_human_agents", () => {
1150
1152
  test("works without authentication — the escape hatch cannot be gated", async () => {
1151
- const result = ok<TransferResult>(
1153
+ const result = expectToolOk<TransferResult>(
1152
1154
  await transferToHumanAgents.execute(
1153
1155
  { summary: "Caller wants to dispute a charge from 2019." },
1154
1156
  makeCtx(),
@@ -1160,7 +1162,7 @@ describe("transfer_to_human_agents", () => {
1160
1162
 
1161
1163
  test("the handoff is terminal, so the call cannot be worked afterwards", async () => {
1162
1164
  const ctx = await authedCtx("olivia.ito5204@example.com");
1163
- ok(await transferToHumanAgents.execute({ summary: "wants a human" }, ctx));
1165
+ expectToolOk(await transferToHumanAgents.execute({ summary: "wants a human" }, ctx));
1164
1166
 
1165
1167
  const at = callFlow.position(ctx);
1166
1168
  expect(at.state).toBe("transferred");
@@ -10,10 +10,11 @@
10
10
  */
11
11
 
12
12
  import { isToolFailure, type ToolFailure } from "@alexkroman1/aai";
13
+ import { formatMoney } from "@alexkroman1/aai/utils";
13
14
  import { creditRefund, REFUND_DELAY_NOTE, REFUND_IMMEDIATE_NOTE } from "./refund.ts";
14
15
  import { resolveOrder } from "./resolve.ts";
15
16
  import type { RetailState } from "./shared.ts";
16
- import { authenticatedUser, money } from "./store.ts";
17
+ import { money } from "./store.ts";
17
18
 
18
19
  /** The only two reasons the store records. */
19
20
  export const CANCEL_REASONS = ["no longer needed", "ordered by mistake"] as const;
@@ -42,9 +43,9 @@ export function planCancel(
42
43
  spokenOrderId: string,
43
44
  reason: CancelReason,
44
45
  ): CancelPlan | ToolFailure {
45
- const user = authenticatedUser(state);
46
- if (isToolFailure(user)) return user;
47
-
46
+ // No `authenticatedUser` gate here: `resolveOrder` opens with the identical
47
+ // call and returns the identical failure, so a second one is a second store
48
+ // lookup for a value this planner never reads.
48
49
  const order = resolveOrder(state, spokenOrderId);
49
50
  if (isToolFailure(order)) return order;
50
51
 
@@ -75,7 +76,7 @@ export function planCancel(
75
76
  return {
76
77
  readBack:
77
78
  `cancel order ${order.order_id} (${items}) as '${reason}' and refund ` +
78
- `$${total.toFixed(2)} to ${destinations}`,
79
+ `${formatMoney(total)} to ${destinations}`,
79
80
  orderId: order.order_id,
80
81
  reason,
81
82
  refunds,
@@ -109,7 +110,7 @@ export function applyCancel(state: RetailState, plan: CancelPlan) {
109
110
  cancel_reason: plan.reason,
110
111
  refunded: plan.total,
111
112
  refund_immediate: immediate,
112
- message: `Order ${plan.orderId} is cancelled and $${plan.total.toFixed(2)} is being refunded. ${
113
+ message: `Order ${plan.orderId} is cancelled and ${formatMoney(plan.total)} is being refunded. ${
113
114
  immediate ? REFUND_IMMEDIATE_NOTE : REFUND_DELAY_NOTE
114
115
  }`,
115
116
  };
@@ -1,12 +1,15 @@
1
+ import { formatMoney, plural } from "@alexkroman1/aai/utils";
1
2
  import "@alexkroman1/aai-ui/styles.css";
2
- import type { AgentState, ConversationItem, Session } from "@alexkroman1/aai-ui";
3
+ import type { AgentState, ConversationItem } from "@alexkroman1/aai-ui";
3
4
  import {
4
5
  AutoScroll,
5
- client,
6
+ mountClient,
7
+ SessionErrorBanner,
6
8
  useAgentState,
7
9
  useConversation,
8
- useSession,
10
+ useSessionActions,
9
11
  useSessionSelector,
12
+ useSessionStatus,
10
13
  } from "@alexkroman1/aai-ui";
11
14
  import type { ReactNode } from "react";
12
15
  import type {
@@ -100,7 +103,7 @@ function MethodRow({ method }: { method: PaymentMethodView }) {
100
103
  </span>
101
104
  ) : (
102
105
  <span className="font-semibold tabular-nums" style={{ color: "#15803d" }}>
103
- ${method.balance.toFixed(2)}
106
+ {formatMoney(method.balance)}
104
107
  </span>
105
108
  )}
106
109
  </div>
@@ -132,8 +135,7 @@ function OrderCard({ order, focused }: { order: OrderView; focused: boolean }) {
132
135
  </span>
133
136
  </div>
134
137
  <div className="text-[11px] mb-1 tabular-nums" style={{ color: "#71717a" }}>
135
- ${order.total.toFixed(2)} · {order.items.length} item
136
- {order.items.length === 1 ? "" : "s"}
138
+ {formatMoney(order.total)} · {order.items.length} {plural(order.items.length, "item")}
137
139
  </div>
138
140
  {order.items.slice(0, 3).map((item) => (
139
141
  <div
@@ -246,7 +248,7 @@ function SwapOptions({ option }: { option: SwapOptionView }) {
246
248
  >
247
249
  <span>{Object.values(alternative.options).join(", ")}</span>
248
250
  <span className="tabular-nums whitespace-nowrap" style={{ color: "#71717a" }}>
249
- ${alternative.price.toFixed(2)}
251
+ {formatMoney(alternative.price)}
250
252
  </span>
251
253
  </div>
252
254
  ))
@@ -344,8 +346,16 @@ function Conversation() {
344
346
  {streaming}
345
347
  </div>
346
348
  )}
349
+ {/* Same contract as the shipped `MessageList`'s indicator: three pulsing
350
+ dots are the only sign the agent is working, and to a screen reader
351
+ they are punctuation. */}
347
352
  {thinking && (
348
- <div className="self-start text-[11px] px-3.5" style={{ color: "#a1a1aa" }}>
353
+ <div
354
+ role="status"
355
+ aria-label="Agent is thinking"
356
+ className="self-start text-[11px] px-3.5"
357
+ style={{ color: "#a1a1aa" }}
358
+ >
349
359
  <span style={{ animation: "rt-pulse 1.2s ease-in-out infinite" }}>· · ·</span>
350
360
  </div>
351
361
  )}
@@ -370,7 +380,7 @@ function Conversation() {
370
380
  /** The live status dot, on its own subscription rather than a field off a
371
381
  * whole-page read — the header is the only thing here that wants it. */
372
382
  function StatusReadout() {
373
- const state = useSessionSelector((s) => s.state);
383
+ const state = useSessionStatus();
374
384
  return (
375
385
  <>
376
386
  <span
@@ -393,58 +403,29 @@ function StatusReadout() {
393
403
  );
394
404
  }
395
405
 
396
- /**
397
- * The fatal-error banner.
406
+ /*
407
+ * The call controls.
398
408
  *
399
- * `role="alert"` is the part a hand-rolled banner leaves out and the part that
400
- * matters: per the `fatalError` latch in the session core this is the only
401
- * remaining signal the status readout beside it goes back to reading like a
402
- * live call and a screen reader is never told an unannounced one appeared.
403
- * `ConsoleShell` carries it, and this template's two-pane page cannot use that
404
- * shell as its frame; the attribute is what it is here for.
409
+ * The methods come off `useSessionActions()`, which does not subscribe, and the
410
+ * two flags off one-field selectors. The whole-snapshot `useSession()` this
411
+ * replaced re-rendered these four buttons on every STT partial to read two
412
+ * booleans that change once a call.
405
413
  */
406
- function ErrorBanner() {
407
- const error = useSessionSelector((s) => s.error);
408
- if (!error) return null;
409
- return (
410
- <div
411
- role="alert"
412
- className="px-4 py-2 text-xs"
413
- style={{ background: "#fef2f2", color: "#b91c1c", borderTop: "1px solid #fecaca" }}
414
- >
415
- {error.message} ({error.code})
416
- </div>
417
- );
418
- }
419
-
420
- /**
421
- * Start a fresh conversation without leaving the console.
422
- *
423
- * Written out here rather than reached for on the session, because there is no
424
- * one method that does it: `reset()` clears the CONVERSATION and keeps the
425
- * session, which is wrong for any agent that also keeps session-scoped state —
426
- * this one's store would come back with the next tool call.
427
- */
428
- function newConversation(session: Session): void {
429
- session.end();
430
- session.start();
431
- }
432
-
433
- /** The call controls: the one place a whole-session read is what is wanted —
434
- * `started`, `running` and four methods, in four buttons. */
435
414
  function CallControls({ productCount }: { productCount: number }) {
436
- const session = useSession();
415
+ const { start, toggle, restart, end } = useSessionActions();
416
+ const started = useSessionSelector((s) => s.started);
417
+ const running = useSessionSelector((s) => s.running);
437
418
  return (
438
419
  <div
439
420
  className="flex items-center gap-2 px-4 py-3"
440
421
  style={{ background: "#ffffff", borderTop: "1px solid #e4e4e7" }}
441
422
  >
442
- {!session.started ? (
423
+ {!started ? (
443
424
  <button
444
425
  type="button"
445
426
  className="px-4 py-2 border-none rounded-md text-xs font-semibold cursor-pointer text-white"
446
427
  style={{ background: "#2563eb" }}
447
- onClick={() => session.start()}
428
+ onClick={() => start()}
448
429
  >
449
430
  Start call
450
431
  </button>
@@ -454,12 +435,12 @@ function CallControls({ productCount }: { productCount: number }) {
454
435
  type="button"
455
436
  className="px-4 py-2 border-none rounded-md text-xs font-semibold cursor-pointer"
456
437
  style={{
457
- background: session.running ? "#e4e4e7" : "#2563eb",
458
- color: session.running ? "#18181b" : "#ffffff",
438
+ background: running ? "#e4e4e7" : "#2563eb",
439
+ color: running ? "#18181b" : "#ffffff",
459
440
  }}
460
- onClick={() => session.toggle()}
441
+ onClick={() => toggle()}
461
442
  >
462
- {session.running ? "Hold" : "Resume"}
443
+ {running ? "Hold" : "Resume"}
463
444
  </button>
464
445
  {/* The one-click new conversation the default shell's
465
446
  `<Controls>` gives every other template — a custom
@@ -472,7 +453,7 @@ function CallControls({ productCount }: { productCount: number }) {
472
453
  type="button"
473
454
  className="px-4 py-2 rounded-md text-xs font-semibold cursor-pointer"
474
455
  style={{ background: "#ffffff", color: "#18181b", border: "1px solid #e4e4e7" }}
475
- onClick={() => newConversation(session)}
456
+ onClick={restart}
476
457
  >
477
458
  New Conversation
478
459
  </button>
@@ -484,7 +465,7 @@ function CallControls({ productCount }: { productCount: number }) {
484
465
  type="button"
485
466
  className="px-4 py-2 border-none rounded-md text-xs font-semibold cursor-pointer text-white"
486
467
  style={{ background: "#dc2626" }}
487
- onClick={() => session.end()}
468
+ onClick={() => end()}
488
469
  >
489
470
  End
490
471
  </button>
@@ -545,7 +526,7 @@ function App() {
545
526
  style={{ borderRight: "1px solid #e4e4e7" }}
546
527
  >
547
528
  <Conversation />
548
- <ErrorBanner />
529
+ <SessionErrorBanner className="rounded-none border-x-0 border-b-0" />
549
530
  <CallControls productCount={view.productCount} />
550
531
  </div>
551
532
 
@@ -642,7 +623,7 @@ function App() {
642
623
  )}
643
624
  </span>
644
625
  <span className="tabular-nums whitespace-nowrap ml-3">
645
- {view.callSeq} call{view.callSeq === 1 ? "" : "s"}
626
+ {view.callSeq} {plural(view.callSeq, "call")}
646
627
  </span>
647
628
  </div>
648
629
  </div>
@@ -650,7 +631,7 @@ function App() {
650
631
  );
651
632
  }
652
633
 
653
- client({
634
+ mountClient({
654
635
  component: App,
655
636
  theme: {
656
637
  bg: "#f4f4f5",
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { isToolFailure, type ToolFailure } from "@alexkroman1/aai";
7
+ import { formatMoney } from "@alexkroman1/aai/utils";
7
8
  import { resolveOrder } from "./resolve.ts";
8
9
  import type { RetailState } from "./shared.ts";
9
10
  import { authenticatedUser, findPaymentMethod, isGiftCard, money } from "./store.ts";
@@ -53,13 +54,13 @@ export function planPayment(
53
54
  const amount = original.amount;
54
55
  if (isGiftCard(newMethod) && newMethod.balance < amount) {
55
56
  return {
56
- error: `Gift card ${newMethodId}'s balance ($${newMethod.balance.toFixed(2)}) does not cover the $${amount.toFixed(2)} order total.`,
57
+ error: `Gift card ${newMethodId}'s balance (${formatMoney(newMethod.balance)}) does not cover the ${formatMoney(amount)} order total.`,
57
58
  };
58
59
  }
59
60
 
60
61
  return {
61
62
  readBack:
62
- `charge order ${order.order_id} — $${amount.toFixed(2)} — to ${newMethodId} instead, ` +
63
+ `charge order ${order.order_id} — ${formatMoney(amount)} — to ${newMethodId} instead, ` +
63
64
  `refunding ${original.payment_method_id}`,
64
65
  orderId: order.order_id,
65
66
  newMethodId,
@@ -102,6 +103,6 @@ export function applyPayment(state: RetailState, plan: PaymentPlan) {
102
103
  amount: plan.amount,
103
104
  paid_with: plan.newMethodId,
104
105
  refunded_to: plan.oldMethodId,
105
- message: `Order ${plan.orderId} is now charged to ${plan.newMethodId}, and $${plan.amount.toFixed(2)} is going back to ${plan.oldMethodId}.`,
106
+ message: `Order ${plan.orderId} is now charged to ${plan.newMethodId}, and ${formatMoney(plan.amount)} is going back to ${plan.oldMethodId}.`,
106
107
  };
107
108
  }