@alexkroman1/aai-cli 13.3.0 → 15.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 (158) hide show
  1. package/dist/{_agent-Clbq3a4h.mjs → _agent-BzUeqOdj.mjs} +32 -9
  2. package/dist/_agent.d.ts +23 -0
  3. package/dist/_artifacts-BJOYGQPp.mjs +21 -0
  4. package/dist/_artifacts.d.ts +16 -0
  5. package/dist/_build-target.d.ts +172 -0
  6. package/dist/{_bundler-DEkjO1d8.mjs → _bundler-DM0d0M7m.mjs} +8 -7
  7. package/dist/_cli-common.d.ts +45 -6
  8. package/dist/{_config-ZY2euhdh.mjs → _config-DYzC6WMD.mjs} +2 -2
  9. package/dist/_dev-env.d.ts +1 -1
  10. package/dist/{_dev-server-C13xuGB1.mjs → _dev-server-BzWB6-4y.mjs} +25 -24
  11. package/dist/_dev-vite-config.d.ts +3 -3
  12. package/dist/_e2e-test-utils.d.ts +1 -1
  13. package/dist/{_init-Ci4QGjp8.mjs → _init-Bsi3DZNJ.mjs} +9 -23
  14. package/dist/{_output-DbJt9gsl.mjs → _output-DBP9Op_d.mjs} +22 -13
  15. package/dist/_output.d.ts +10 -9
  16. package/dist/{_preflight-B2laWPdU.mjs → _preflight-CcAR577V.mjs} +2 -2
  17. package/dist/_resource-commands.d.ts +9 -9
  18. package/dist/_server-common-De0haHr9.mjs +70 -0
  19. package/dist/_server-common.d.ts +20 -1
  20. package/dist/{_slug-api-DlKpj91X.mjs → _slug-api-CGvc2YSQ.mjs} +18 -9
  21. package/dist/_slug-api.d.ts +19 -0
  22. package/dist/{_studio-DTi3EdXQ.mjs → _studio-DnR_BqFp.mjs} +7 -3
  23. package/dist/_studio-commands.d.ts +13 -13
  24. package/dist/{_templates-V4l5sZJy.mjs → _templates-CIlJ3Vay.mjs} +4 -4
  25. package/dist/_templates.d.ts +1 -1
  26. package/dist/{_typecheck-gate-sV4N5F93.mjs → _typecheck-gate-U3jcil8L.mjs} +3 -2
  27. package/dist/_typecheck-gate.d.ts +3 -1
  28. package/dist/{_utils-Dhd058D9.mjs → _utils-D5JGcjiW.mjs} +43 -1
  29. package/dist/_utils.d.ts +35 -0
  30. package/dist/_vercel-output.d.ts +63 -0
  31. package/dist/{_workflow-determinism-C9l8uC2R.mjs → _workflow-determinism-CvpqMpdh.mjs} +18 -26
  32. package/dist/_workflow-determinism.d.ts +3 -3
  33. package/dist/build-BhEaxBPu.mjs +481 -0
  34. package/dist/build.d.ts +23 -10
  35. package/dist/cli.d.ts +1 -2
  36. package/dist/cli.mjs +155 -138
  37. package/dist/{client-bundler-CT-_26c5.mjs → client-bundler-6mTLs6ny.mjs} +5 -5
  38. package/dist/client-bundler.d.ts +1 -1
  39. package/dist/client-bundler.mjs +1 -1
  40. package/dist/{delete-RqlQZ6Y_.mjs → delete-BdFnbnVR.mjs} +4 -4
  41. package/dist/{deploy-B7f3CK7v.mjs → deploy-CGqPU5U-.mjs} +11 -11
  42. package/dist/{dev-DamXieW_.mjs → dev-Bx9gYBHM.mjs} +3 -3
  43. package/dist/{eval-h3DTVfRw.mjs → eval-B3I7FqN9.mjs} +3 -3
  44. package/dist/{init-D-vFrV8h.mjs → init-CFyusRbq.mjs} +48 -20
  45. package/dist/init.d.ts +14 -0
  46. package/dist/{login-CDiemNmC.mjs → login-DaLrPOFg.mjs} +4 -6
  47. package/dist/{logs-DGwPNCzB.mjs → logs-7BRwq0cG.mjs} +21 -14
  48. package/dist/project-config.mjs +1 -1
  49. package/dist/scaffold/CLAUDE.md +155 -76
  50. package/dist/scaffold/package.json +6 -6
  51. package/dist/{secret-V1w7y9-j.mjs → secret-Cjp912yF.mjs} +5 -5
  52. package/dist/secret.d.ts +8 -6
  53. package/dist/start.d.ts +112 -0
  54. package/dist/start.mjs +156 -0
  55. package/dist/{studio-BpMCfUNx.mjs → studio-C_zuRC_z.mjs} +13 -15
  56. package/dist/templates/briefing-desk/agent.eval.test.ts +156 -0
  57. package/dist/templates/call-audit/agent.test.ts +11 -6
  58. package/dist/templates/call-audit/agent.ts +2 -2
  59. package/dist/templates/call-audit/client.tsx +27 -42
  60. package/dist/templates/call-audit/workflows/audit.ts +7 -5
  61. package/dist/templates/call-audit/workflows/ingest.ts +7 -7
  62. package/dist/templates/call-audit/workflows/media.ts +12 -37
  63. package/dist/templates/call-audit/workflows/summarize.ts +8 -8
  64. package/dist/templates/call-audit/workflows/sync-api.ts +3 -3
  65. package/dist/templates/code-interpreter/agent.test.ts +103 -0
  66. package/dist/templates/dispatch-center/agent.test.ts +7 -7
  67. package/dist/templates/dispatch-center/client.tsx +53 -61
  68. package/dist/templates/dispatch-center/shared.ts +0 -8
  69. package/dist/templates/dispatch-center/tools/ops_dashboard.ts +4 -1
  70. package/dist/templates/embedded-assets/agent.eval.test.ts +4 -2
  71. package/dist/templates/health-assistant/agent.eval.test.ts +4 -2
  72. package/dist/templates/infocom-adventure/client.tsx +40 -36
  73. package/dist/templates/link-digest/agent.test.ts +3 -3
  74. package/dist/templates/link-digest/agent.ts +2 -2
  75. package/dist/templates/link-digest/client.tsx +81 -19
  76. package/dist/templates/link-digest/workflows/digest.ts +13 -16
  77. package/dist/templates/math-buddy/agent.test.ts +126 -0
  78. package/dist/templates/night-owl/agent.eval.test.ts +4 -2
  79. package/dist/templates/night-owl/client.tsx +8 -2
  80. package/dist/templates/personal-finance/agent.test.ts +127 -0
  81. package/dist/templates/pipeline-simple/agent.test.ts +2 -2
  82. package/dist/templates/pizza-ordering/client.tsx +5 -4
  83. package/dist/templates/pizza-ordering/shared.ts +3 -8
  84. package/dist/templates/pizza-ordering/tools/add_pizza.ts +3 -2
  85. package/dist/templates/pizza-ordering/tools/place_order.ts +3 -2
  86. package/dist/templates/pizza-ordering/tools/remove_pizza.ts +3 -2
  87. package/dist/templates/pizza-ordering/tools/update_pizza.ts +3 -2
  88. package/dist/templates/pizza-ordering/tools/view_order.ts +3 -2
  89. package/dist/templates/plan-and-execute/agent.test.ts +10 -5
  90. package/dist/templates/plan-and-execute/client.tsx +2 -2
  91. package/dist/templates/podcast-digest/agent.test.ts +20 -11
  92. package/dist/templates/podcast-digest/client.tsx +16 -22
  93. package/dist/templates/podcast-digest/workflows/digest.ts +21 -19
  94. package/dist/templates/podcast-digest/workflows/feeds.ts +61 -33
  95. package/dist/templates/podcast-digest/workflows/slack.ts +7 -7
  96. package/dist/templates/recap-workflow/agent.eval.test.ts +21 -13
  97. package/dist/templates/recap-workflow/agent.test.ts +27 -23
  98. package/dist/templates/recap-workflow/workflows/recap.ts +24 -28
  99. package/dist/templates/redline/agent.eval.test.ts +5 -8
  100. package/dist/templates/redline/agent.test.ts +1 -1
  101. package/dist/templates/redline/client.tsx +36 -34
  102. package/dist/templates/redline/workflows/redline.ts +17 -17
  103. package/dist/templates/research-workflow/agent.eval.test.ts +5 -5
  104. package/dist/templates/research-workflow/agent.test.ts +10 -7
  105. package/dist/templates/research-workflow/workflows/research.ts +28 -30
  106. package/dist/templates/retail/agent.test.ts +17 -15
  107. package/dist/templates/retail/cancel.ts +7 -6
  108. package/dist/templates/retail/client.tsx +39 -58
  109. package/dist/templates/retail/payment.ts +4 -3
  110. package/dist/templates/retail/pending.test.ts +19 -19
  111. package/dist/templates/retail/store.test.ts +5 -5
  112. package/dist/templates/retail/swap.ts +8 -11
  113. package/dist/templates/solo-rpg/agent.eval.test.ts +2 -16
  114. package/dist/templates/solo-rpg/agent.test.ts +12 -12
  115. package/dist/templates/solo-rpg/client.tsx +3 -3
  116. package/dist/templates/spoken-summary/agent.test.ts +9 -9
  117. package/dist/templates/spoken-summary/agent.ts +3 -3
  118. package/dist/templates/spoken-summary/client.tsx +23 -30
  119. package/dist/templates/spoken-summary/workflows/summarize.ts +11 -11
  120. package/dist/templates/spoken-summary/workflows/transcribe.ts +14 -14
  121. package/dist/templates/support-line/agent.ts +8 -0
  122. package/dist/templates/support-line/client.tsx +4 -3
  123. package/dist/templates/support-line/prompts.ts +0 -2
  124. package/dist/templates/transcription-workflow/agent.test.ts +20 -12
  125. package/dist/templates/transcription-workflow/agent.ts +2 -2
  126. package/dist/templates/transcription-workflow/client.tsx +15 -14
  127. package/dist/templates/transcription-workflow/recover.ts +1 -1
  128. package/dist/templates/transcription-workflow/run-panel.tsx +34 -14
  129. package/dist/templates/transcription-workflow/total-latency.tsx +46 -20
  130. package/dist/templates/transcription-workflow/workflows/batch.ts +15 -15
  131. package/dist/templates/transcription-workflow/workflows/downsample.ts +1 -1
  132. package/dist/templates/transcription-workflow/workflows/normalize.ts +15 -19
  133. package/dist/templates/transcription-workflow/workflows/stream.ts +12 -12
  134. package/dist/templates/transcription-workflow/workflows/sync-api.ts +3 -3
  135. package/dist/templates/transcription-workflow/workflows/transcribe.ts +23 -34
  136. package/dist/templates/travel-concierge/agent.eval.test.ts +2 -2
  137. package/dist/templates/travel-concierge/agent.test.ts +15 -7
  138. package/dist/templates/travel-concierge/client.tsx +2 -2
  139. package/dist/templates/travel-concierge/routing.ts +64 -55
  140. package/dist/templates/travel-concierge/shared.ts +10 -14
  141. package/dist/templates/travel-concierge/tools/cancel_action.ts +3 -1
  142. package/dist/templates/travel-concierge/tools/complete_or_escalate.ts +3 -1
  143. package/dist/templates/travel-concierge/tools/confirm_action.ts +3 -1
  144. package/dist/templates/travel-concierge/tools/lookup_booking.ts +4 -3
  145. package/dist/templates/travel-concierge/tools/search_car_rentals.ts +3 -2
  146. package/dist/templates/travel-concierge/tools/search_excursions.ts +3 -2
  147. package/dist/templates/travel-concierge/tools/search_flights.ts +3 -2
  148. package/dist/templates/travel-concierge/tools/search_hotels.ts +3 -2
  149. package/dist/templates/web-researcher/agent.test.ts +130 -0
  150. package/dist/{test-guenJUxi.mjs → test-D032V2cA.mjs} +4 -17
  151. package/dist/typecheck.mjs +1 -1
  152. package/dist/worker-bundler.d.ts +1 -1
  153. package/dist/worker-bundler.mjs +8 -8
  154. package/dist/{workflow-BZyKLkhV.mjs → workflow-CVioyO3V.mjs} +6 -5
  155. package/package.json +9 -4
  156. package/dist/_server-common-BV4_CrP3.mjs +0 -43
  157. package/dist/build-CWyFGe-W.mjs +0 -69
  158. package/dist/scaffold/server.mjs +0 -204
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * The browser half of a workflow app.
4
4
  *
5
- * Mounted with `page()` rather than `client()`: there is no session to build, so
5
+ * Mounted with `mountPage()` rather than `mountClient()`: there is no session to build, so
6
6
  * there is no socket, no audio graph, and no microphone request. Everything else
7
7
  * is the same — the same `client.tsx` filename, React, Tailwind, and the same
8
8
  * theme tokens the voice components read.
@@ -49,7 +49,7 @@
49
49
  *
50
50
  * `useWorkflowSubmit` answers "where has this got to" from the world's own
51
51
  * record — pending, running, completed. `<WorkflowProgress>` answers "what is it
52
- * doing" from what the run wrote itself (`report()` in `workflows/digest.ts`). A
52
+ * doing" from what the run wrote itself (`stepReport()` in `workflows/digest.ts`). A
53
53
  * page with only the first shows "Working…" for the length of the run; a page
54
54
  * with only the second cannot tell a finished run from a quiet one. Both are
55
55
  * cheap: one stream each, ended by the agent when there is nothing left to say.
@@ -62,9 +62,32 @@
62
62
  * what narrows it to the newest line, because on a page this small that is the
63
63
  * whole of what a status wants; `transcription-workflow` renders the full log,
64
64
  * where a fan-out makes the history worth seeing.
65
+ *
66
+ * ## Two things that are only ever true for a MOMENT
67
+ *
68
+ * The Copy button and the reply to "File it now" are both a word that appears
69
+ * and then goes away, and both used to be the sort of thing a page writes with
70
+ * a `useState` and a bare `setTimeout` — which gets two things wrong that only
71
+ * show up on the second click (a second flash has its window cut short by the
72
+ * first one's timer) and on unmount (a `setState` into a torn-down tree). They
73
+ * are `useCopy` and `useFlash` from `@alexkroman1/aai-ui`.
74
+ *
75
+ * Reach for `useCopy` when the moment is a clipboard write — it keys the flash
76
+ * by the copied TEXT, so on a page with several copy buttons only the one
77
+ * clicked lights up, and it reports a REFUSED write as `"Failed"` rather than
78
+ * doing nothing visible (there is no clipboard at all on an insecure origin).
79
+ * Reach for `useFlash` for any other transient word; here it carries what
80
+ * `wake()` answered, which is a number and not a failure at 0.
65
81
  */
66
82
 
67
- import { page, useWorkflowSubmit, WorkflowProgress } from "@alexkroman1/aai-ui";
83
+ import {
84
+ BulletList,
85
+ mountPage,
86
+ useCopy,
87
+ useFlash,
88
+ useWorkflowSubmit,
89
+ WorkflowProgress,
90
+ } from "@alexkroman1/aai-ui";
68
91
  import "@alexkroman1/aai-ui/styles.css";
69
92
  // ERASED at build time, so naming the agent's own type costs the browser bundle
70
93
  // nothing — and it is what stops this file restating a shape `workflows/
@@ -90,12 +113,22 @@ function pendingNote(startedHere: boolean, found: boolean): string {
90
113
  return "Still working on the digest this tab started earlier. Reloading is safe.";
91
114
  }
92
115
 
116
+ /** The digest as one pasteable block — a headline and its bullets. */
117
+ function asText(headline: string, points: readonly string[]): string {
118
+ return [headline, ...points.map((point) => `- ${point}`)].join("\n");
119
+ }
120
+
93
121
  export function App() {
94
122
  const [url, setUrl] = useState("");
95
- // Did THIS load start the run? A reload cannot have, and that is the only way
96
- // the page can tell "working on what you just submitted" from "picking up
97
- // where you left off" the hook reports the run, not who asked for it.
98
- const [startedHere, setStartedHere] = useState(false);
123
+ // One copier for the page. It would be one per GROUP of copy buttons on a
124
+ // bigger page the flash is shared, so clicking a second row clears the
125
+ // first row's "Copied", which is what stops two rows both claiming to be on
126
+ // the clipboard.
127
+ const copier = useCopy();
128
+ // `wake()` resolves with how many sleeps it ended, and 0 is an ANSWER (the
129
+ // run had already moved past its wait) rather than a failure — so the button
130
+ // says which happened, for a moment, and then goes back to being a button.
131
+ const woken = useFlash<string>();
99
132
  // The generic is what makes `run.status === "completed"` narrow to a TYPED
100
133
  // `run.output` instead of `unknown`. `error` is the agent's own sentence for a
101
134
  // rejected input, which is better copy than anything this page could write, and
@@ -104,14 +137,14 @@ export function App() {
104
137
  // No `key` and no `recover`: this tab's handle on its own runs is minted and
105
138
  // remembered by the hook, and read back as it mounts. See the module doc for
106
139
  // what a page says when it wants a different one.
107
- const { submit, run, pending, error, wake } = useWorkflowSubmit<typeof digest>("digest");
140
+ const { submit, run, pending, error, wake, startedHere } =
141
+ useWorkflowSubmit<typeof digest>("digest");
108
142
 
109
143
  // `submit()` resolves as soon as the run exists — deliberately not when it
110
144
  // finishes. That is the whole mechanism: the digest sleeps for a while, and
111
145
  // this page is free to be closed in the meantime.
112
146
  const onSubmit = (event: React.FormEvent) => {
113
147
  event.preventDefault();
114
- setStartedHere(true);
115
148
  void submit({ url });
116
149
  };
117
150
 
@@ -125,6 +158,11 @@ export function App() {
125
158
  required
126
159
  value={url}
127
160
  onChange={(e) => setUrl(e.target.value)}
161
+ // A placeholder is not a name: it disappears the moment anything is
162
+ // typed, and a screen reader reaches an unlabelled box. The declared
163
+ // fields in `@alexkroman1/aai-ui` say the same thing — a `label`, or
164
+ // an `aria-label` where the row has no room for one.
165
+ aria-label="Article URL"
128
166
  // A REAL article, because a placeholder is a suggestion and this one
129
167
  // gets typed. `https://example.com/article` 404s, and the bare
130
168
  // `example.com` a reader trims it to has no prose on it — so the first
@@ -139,7 +177,13 @@ export function App() {
139
177
  </button>
140
178
  </form>
141
179
 
142
- {error !== undefined && <p className="text-red-600">{error}</p>}
180
+ {/* `<Form>` carries `role="alert"` for the templates that declare their
181
+ fields; a hand-written form has to say it itself. */}
182
+ {error !== undefined && (
183
+ <p role="alert" className="text-red-600">
184
+ {error}
185
+ </p>
186
+ )}
143
187
 
144
188
  {/* A run that has not settled says so. `pending` is not derivable from the
145
189
  snapshot alone — an id the agent never knew leaves `run` undefined,
@@ -161,28 +205,46 @@ export function App() {
161
205
  {pending && (
162
206
  <button
163
207
  type="button"
164
- onClick={() => void wake()}
208
+ onClick={() => {
209
+ void wake().then((count) =>
210
+ woken.flash(count > 0 ? "Filing it now" : "Already past its wait"),
211
+ );
212
+ }}
165
213
  className="self-start rounded-md border px-3 py-1 text-sm"
166
214
  >
167
- File it now
215
+ {woken.value ?? "File it now"}
168
216
  </button>
169
217
  )}
170
218
 
171
- {run?.status === "failed" && <p className="text-red-600">That one failed: {run.error}</p>}
219
+ {/* `role="alert"`, like the submit error above: this is the outcome the
220
+ reader has been waiting for, and it can arrive long after they looked
221
+ away. */}
222
+ {run?.status === "failed" && (
223
+ <p role="alert" className="text-red-600">
224
+ That one failed: {run.error}
225
+ </p>
226
+ )}
172
227
 
173
228
  {run?.status === "completed" && (
174
229
  <article className="flex flex-col gap-3">
175
230
  <h2 className="text-xl">{run.output.headline}</h2>
176
- <ul className="flex list-disc flex-col gap-1 pl-5">
177
- {run.output.points.map((point) => (
178
- <li key={point}>{point}</li>
179
- ))}
180
- </ul>
231
+ <BulletList items={run.output.points} />
181
232
  <p className="text-sm opacity-70">Filed {run.output.filedAt}</p>
233
+ {/* The whole digest as plain text, which is what somebody pasting it
234
+ into a note wants. `copier.label` is the button's own text: it
235
+ reads "Copy" until it is clicked, then "Copied" — or "Failed",
236
+ which is the case a hand-rolled version silently drops. */}
237
+ <button
238
+ type="button"
239
+ onClick={() => copier.copy(asText(run.output.headline, run.output.points))}
240
+ className="self-start rounded-md border px-3 py-1 text-sm"
241
+ >
242
+ {copier.label(asText(run.output.headline, run.output.points))}
243
+ </button>
182
244
  </article>
183
245
  )}
184
246
  </main>
185
247
  );
186
248
  }
187
249
 
188
- page({ name: "Link Digest", component: App });
250
+ mountPage({ name: "Link Digest", component: App });
@@ -24,10 +24,10 @@
24
24
  * fetched text crosses a queue between them, which is what the cap on it is for.
25
25
  */
26
26
 
27
- import type { WorkflowCtx } from "@alexkroman1/aai";
27
+ import type { WorkflowContext } from "@alexkroman1/aai";
28
28
  import { htmlToText, pageMetadata } from "@alexkroman1/aai/html";
29
- import { report, stepInfo } from "@alexkroman1/aai/step";
30
- import { FatalError, stepFetchOk, stepGenerateJsonClassified } from "@alexkroman1/aai/step-errors";
29
+ import { stepInfo, stepReport } from "@alexkroman1/aai/step";
30
+ import { FatalError, stepFetchOrFail, stepGenerateJsonOrFail } from "@alexkroman1/aai/step-errors";
31
31
  import { omitUndefined } from "@alexkroman1/aai/utils";
32
32
  import { z } from "zod";
33
33
 
@@ -107,7 +107,7 @@ export type Article = {
107
107
  * literally the page's render model, and `WorkflowOutputOf<typeof digest>` in
108
108
  * `client.tsx` is that type, derived rather than restated.
109
109
  */
110
- export async function digestFlow(input: { url: string }, ctx: WorkflowCtx) {
110
+ export async function digestFlow(input: { url: string }, ctx: WorkflowContext) {
111
111
  const article = await ctx.step("fetchArticle", () => fetchArticle(input.url));
112
112
  // `maxAttempts: 6` was `summarize.maxRetries = 5` — five retries AFTER the
113
113
  // first attempt, so six in all. The retry policy moved from a property on the
@@ -138,7 +138,7 @@ export async function digestFlow(input: { url: string }, ctx: WorkflowCtx) {
138
138
  */
139
139
  export async function fetchArticle(url: string): Promise<Article> {
140
140
  const { hostname } = new URL(url);
141
- await report(`Reading ${hostname}…`);
141
+ await stepReport(`Reading ${hostname}…`);
142
142
 
143
143
  // `stepFetch`, not `fetch`, and the rule has no exception for a step that
144
144
  // makes only one request: the global pins nothing, so it offers h2 in ALPN
@@ -147,12 +147,12 @@ export async function fetchArticle(url: string): Promise<Article> {
147
147
  // reset with no HTTP status, which `toStepError` below has nothing to read.
148
148
  // It also reports a connection failure with its whole `cause` chain instead
149
149
  // of a bare `TypeError: fetch failed`. Redirects are followed by default.
150
- // `stepFetchOk` rather than `stepFetch` + an `ok` check: it makes the
150
+ // `stepFetchOrFail` rather than `stepFetch` + an `ok` check: it makes the
151
151
  // retryable/terminal split for us — a 404 or a 403 answers the same way on
152
152
  // the fourth attempt, while a rate limit is exactly what retries are for, and
153
153
  // its `Retry-After` reaches the engine's schedule instead of the default
154
154
  // backoff. It also puts the server's own error text in the message.
155
- const response = await stepFetchOk(url, {
155
+ const response = await stepFetchOrFail(url, {
156
156
  // Some sites answer a bare request with a challenge page; asking for HTML
157
157
  // at least says what we want. Nothing here defeats a real bot wall, and a
158
158
  // template pretending otherwise would be the dishonest version.
@@ -191,19 +191,19 @@ export async function summarize(article: Article): Promise<Digest> {
191
191
  // built with `omitUndefined` rather than a conditional spread — the guard is
192
192
  // then the value, which is the case that primitive is for.
193
193
  const model = lastChance ? FALLBACK_MODEL : undefined;
194
- await report(
194
+ await stepReport(
195
195
  lastChance
196
196
  ? `Last attempt (${step?.attempt} of ${step?.maxAttempts}): asking for something simpler.`
197
197
  : "Pulling out the claims worth keeping.",
198
198
  );
199
199
 
200
- // `stepGenerateJsonClassified` unwraps the fence a model puts around JSON,
200
+ // `stepGenerateJsonOrFail` unwraps the fence a model puts around JSON,
201
201
  // parses it, and validates it against `DigestReply` — and throws PLAINLY when
202
202
  // any of those misses, which is the whole retry policy in one distinction: a
203
203
  // model that answered with prose may answer correctly on the next attempt,
204
- // where a 401 will not. The `Classified` suffix is what makes the 401 half
204
+ // where a 401 will not. The `OrFail` suffix is what makes the 401 half
205
205
  // terminal: it is `stepGenerateJson` with `throwStepError` already applied.
206
- const parsed = await stepGenerateJsonClassified(
206
+ const parsed = await stepGenerateJsonOrFail(
207
207
  `Title: ${article.title}\nURL: ${article.url}\n\n${article.text}`,
208
208
  {
209
209
  schema: DigestReply,
@@ -246,7 +246,7 @@ export async function summarize(article: Article): Promise<Digest> {
246
246
  * file(digest))`. Anything at BODY level is the bug, not an exception.
247
247
  */
248
248
  export async function file(_digest: Digest): Promise<string> {
249
- await report("Filing the digest.");
249
+ await stepReport("Filing the digest.");
250
250
  // A real desk would write the digest to its database here. The stub writes
251
251
  // nothing, which is what the `_` says — and it is a stub because `ctx.db` is
252
252
  // the half of a tool context a step still cannot reach.
@@ -282,8 +282,5 @@ export function extractTitle(html: string): string | undefined {
282
282
  * structure are bytes the summary does not read.
283
283
  */
284
284
  export function extractText(html: string): string {
285
- return htmlToText(html, { maxChars: MAX_ARTICLE_CHARS })
286
- .replace(/\s+/g, " ")
287
- .trim()
288
- .slice(0, MAX_ARTICLE_CHARS);
285
+ return htmlToText(html, { maxChars: MAX_ARTICLE_CHARS }).replace(/\s+/g, " ").trim();
289
286
  }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The def a DEPLOYED agent runs: authored, plus the `system-prompt.md` beside
3
+ * it.
4
+ *
5
+ * This template declares no `tools/` at all — every calculation is the
6
+ * `run_code` builtin's — so the prompt is the only thing discovery adds here,
7
+ * and it is what half the tests below are about. Importing `./agent.ts`
8
+ * directly would measure a tutor whose prompt is the framework default, i.e.
9
+ * an agent that was never told to compute in code.
10
+ */
11
+ import agentDef from "virtual:aai/agent";
12
+ import { toAgentConfig } from "@alexkroman1/aai/manifest";
13
+ import { describe, expect, test } from "vitest";
14
+
15
+ /**
16
+ * What a starter's spec may assert.
17
+ *
18
+ * `aai build` runs these tests before it bundles, so an assertion pinning this
19
+ * tutor's own identity — its literal name, the wording of its greeting, the
20
+ * model id it happens to run today — turns the first customization into a build
21
+ * failure in a file the author never wrote. Every test here therefore asserts a
22
+ * property that survives a rename, a voice, a reworded prompt and a model swap,
23
+ * on the RESOLVED config rather than on the def's empty fields.
24
+ *
25
+ * `run_code` is the one thing named literally, and deliberately: taking it away
26
+ * is not a customization of Math Buddy but a deletion of its subject — the
27
+ * prompt is nothing but recipes for it — and the tutor left behind does
28
+ * arithmetic from memory, which reads exactly like a correct answer until it is
29
+ * wrong.
30
+ *
31
+ * What is NOT here is anything about the code the tutor writes or the answer it
32
+ * comes back with: that needs a model and a sandbox, so it belongs to
33
+ * `agent.eval.test.ts`, which supplies both. This tier's question is the one
34
+ * that comes first — was the tutor handed anything to run at all.
35
+ */
36
+ describe("math-buddy template", () => {
37
+ test("config passes manifest validation", () => {
38
+ // Same conversion `aai build`/`aai deploy` run — and the only thing that
39
+ // says this template's declared LLM descriptor is well formed before a
40
+ // live session tries to open one from it.
41
+ expect(() => toAgentConfig(agentDef)).not.toThrow();
42
+ });
43
+
44
+ test("exports an agent the platform can name", () => {
45
+ // Not the literal: what has to hold is that there IS a name and that the
46
+ // conversion carries it through — `AgentName` refuses a blank one, and the
47
+ // studio lists a deployed agent by exactly this string.
48
+ expect(agentDef.name).toBeTruthy();
49
+ expect(toAgentConfig(agentDef).name).toBe(agentDef.name);
50
+ });
51
+
52
+ test("run_code is declared, and the prompt the deploy carries asks for it", () => {
53
+ const config = toAgentConfig(agentDef);
54
+ // Two halves, each of which fails silently and produces a plausible tutor.
55
+ // Without the declaration the model has nothing to run, so it computes in
56
+ // its head and says the answer with the same confidence either way. Without
57
+ // the prompt reaching the CONFIG — the "I edited system-prompt.md and
58
+ // nothing changed" failure `withSystemPrompt` exists to catch, since the
59
+ // file is discovered by the build rather than imported by `agent.ts` — the
60
+ // recipes are gone and what deploys is a general assistant that happens to
61
+ // have a sandbox attached. The framework default says nothing about
62
+ // `run_code`, which is what makes the second assertion a real check on
63
+ // discovery rather than a restatement of the first.
64
+ expect(config.builtinTools).toContain("run_code");
65
+ expect(config.systemPrompt).toContain("run_code");
66
+ });
67
+
68
+ test("whichever model this tutor runs on, the conversion carries its tuning", () => {
69
+ const config = toAgentConfig(agentDef);
70
+ if (config.mode !== "pipeline") {
71
+ // Switched the def to `s2s`? Then one model listens and talks, and there
72
+ // is no separate LLM stage left for anything to be carried on.
73
+ expect(config.mode).toBe("s2s");
74
+ expect(config.llm).toBeUndefined();
75
+ return;
76
+ }
77
+ if (agentDef.llm === undefined) {
78
+ // Dropped the declaration to take the default cascade: it still resolves
79
+ // to a NAMED model, because the gateway refuses an unknown id with a 400
80
+ // at the first session — "no model" is not a state a deploy may reach.
81
+ expect(config.llm?.options.model).toBeTruthy();
82
+ return;
83
+ }
84
+ // A model choice is the only reason this tutor declares a stage at all —
85
+ // a quick, cheap one, since `run_code` does the arithmetic and what is left
86
+ // is turn-taking speed. So the descriptor is checked whole rather than by
87
+ // `kind`: one that arrived with its options dropped would deploy the
88
+ // gateway's default model instead, quietly slower, with nothing on the line
89
+ // saying so. Read off the def rather than pinned, because swapping the id
90
+ // is the first tuning an author of this template tries.
91
+ expect(config.llm?.kind).toBe(agentDef.llm.kind);
92
+ expect(config.llm?.options).toEqual(agentDef.llm.options);
93
+ expect(config.llm?.options.model).toBeTruthy();
94
+ });
95
+
96
+ test("every stage its mode needs is filled, declared or defaulted", () => {
97
+ // This template's other half: it declares the LLM and nothing else, so STT
98
+ // and TTS are injected at parse time (see `defaultProviders`) and the tutor
99
+ // can still hear and speak. Asserted per MODE so it survives a swap —
100
+ // declare `stt`/`tts` and the rest still default; declare `s2s` and there
101
+ // is no cascade to fill, which is the one thing that must never happen by
102
+ // fallthrough.
103
+ const config = toAgentConfig(agentDef);
104
+ if (config.mode === "s2s") {
105
+ expect(config.s2s?.kind).toBeTruthy();
106
+ expect(config.stt).toBeUndefined();
107
+ expect(config.tts).toBeUndefined();
108
+ return;
109
+ }
110
+ expect(config.mode).toBe("pipeline");
111
+ for (const stage of ["stt", "llm", "tts"] as const) {
112
+ expect(config[stage]?.kind, stage).toBe(agentDef[stage]?.kind ?? "assemblyai");
113
+ }
114
+ });
115
+
116
+ test("the caller is told what to ask, and the greeting survives the conversion", () => {
117
+ // A voice agent has no buttons, so the opener is the only place a caller
118
+ // learns that this one wants arithmetic, conversions and dice rather than
119
+ // conversation. It rides to the browser in `/client-config` beside `name`,
120
+ // so what has to hold is that there is one and the conversion carries it:
121
+ // a greeting lost at that boundary is replaced by the framework's generic
122
+ // opener, which invites the caller to ask for anything at all.
123
+ expect(agentDef.greeting).toBeTruthy();
124
+ expect(toAgentConfig(agentDef).greeting).toBe(agentDef.greeting);
125
+ });
126
+ });
@@ -13,8 +13,10 @@
13
13
  /**
14
14
  * The def a DEPLOYED agent runs: authored, plus what `tools/` declares.
15
15
  *
16
- * The glob is written HERE rather than reached for from a shared helper because
17
- * this file SHIPS a scaffolded project has no repo helper to import. Without
16
+ * Taken from `virtual:aai/agent` rather than a hand-written glob: the plugin
17
+ * expands it against THIS file's own directory, so the spec needs no glob and
18
+ * no shared helper — which matters because this file SHIPS, and a scaffolded
19
+ * project has no repo helper to import. Without
18
20
  * it the eval would drive an agent with no `recommend` at all, which is the one
19
21
  * failure a green eval must never be able to hide.
20
22
  *
@@ -14,7 +14,13 @@ import "@alexkroman1/aai-ui/styles.css";
14
14
  * reconnect is nagging. `useToolCallStart` and `useEvent` are for exactly this —
15
15
  * they fire once, carry no history, and drive throwaway `useState`.
16
16
  */
17
- import { Button, client, useAgentState, useEvent, useToolCallStart } from "@alexkroman1/aai-ui";
17
+ import {
18
+ Button,
19
+ mountClient,
20
+ useAgentState,
21
+ useEvent,
22
+ useToolCallStart,
23
+ } from "@alexkroman1/aai-ui";
18
24
  import { useEffect, useState } from "react";
19
25
  import { MOODS, nightProjection } from "./shared.ts";
20
26
 
@@ -111,7 +117,7 @@ function RecSidebar() {
111
117
  );
112
118
  }
113
119
 
114
- client({
120
+ mountClient({
115
121
  name: "Night Owl",
116
122
  icon: "\u{1F989}",
117
123
  subtitle: "A cozy companion for the small hours",
@@ -0,0 +1,127 @@
1
+ /** The def a DEPLOYED agent runs: authored, plus what `system-prompt.md` says. */
2
+ import agentDef from "virtual:aai/agent";
3
+ import { AgentConfigSchema, toAgentConfig } from "@alexkroman1/aai/manifest";
4
+ import { describe, expect, test } from "vitest";
5
+
6
+ /**
7
+ * What a starter's spec may assert.
8
+ *
9
+ * `aai init` scaffolds this template verbatim, and `aai build` runs these tests
10
+ * before it bundles — so an assertion pinning Penny's own identity (her literal
11
+ * name, her voice, her model, the wording of a house rule) turns a user's first
12
+ * customization into a build failure in a file they never wrote. Every test
13
+ * here therefore asserts a property that survives those edits, on the RESOLVED
14
+ * config rather than on the def's empty fields.
15
+ *
16
+ * What is deliberately NOT here: whether Penny actually reaches for `run_code`
17
+ * instead of dividing in her head, whether she looks a rate up rather than
18
+ * quoting a remembered one, and whether she keeps the not-financial-advice
19
+ * caveat. Those are claims about a live model and belong to
20
+ * `agent.eval.test.ts`, which drives them against one. This tier asserts the
21
+ * WIRING those runs depend on — a template whose builtins never reached the
22
+ * config fails an eval as a behaviour problem, in a report nobody reads as
23
+ * "the tool was not there".
24
+ */
25
+ describe("personal-finance template", () => {
26
+ test("config passes manifest validation", () => {
27
+ // Same conversion `aai build`/`aai deploy` run. It is also what checks the
28
+ // two builtin NAMES against the SDK's own enum, so a typo in
29
+ // `builtinTools` fails here rather than shipping an agent whose prompt
30
+ // commands a tool the platform never resolved.
31
+ expect(() => toAgentConfig(agentDef)).not.toThrow();
32
+ });
33
+
34
+ test("exports an agent the platform can name", () => {
35
+ // Not the literal: what has to hold is that there IS a name and the
36
+ // conversion carries it through — the studio lists a deployed agent by
37
+ // exactly this string, and renaming her is the first edit this template
38
+ // invites.
39
+ expect(agentDef.name).toBeTruthy();
40
+ expect(toAgentConfig(agentDef).name).toBe(agentDef.name);
41
+ });
42
+
43
+ test("every stage its mode needs is filled, declared or defaulted", () => {
44
+ // This template declares no provider at all — it is a prompt and two
45
+ // builtins — so the default all-AssemblyAI cascade is what makes it run
46
+ // the moment it is deployed. Asserted per MODE so it stays true after a
47
+ // swap: declare `stt`/`llm`/`tts` and the rest still default; declare
48
+ // `s2s` and there is no cascade to fill, which is the one thing that must
49
+ // never happen by fallthrough.
50
+ const config = toAgentConfig(agentDef);
51
+ if (config.mode === "s2s") {
52
+ expect(config.s2s?.kind).toBeTruthy();
53
+ expect(config.stt).toBeUndefined();
54
+ expect(config.tts).toBeUndefined();
55
+ } else if (config.mode === "text") {
56
+ expect(config.llm?.kind).toBeTruthy();
57
+ } else {
58
+ expect(config.mode).toBe("pipeline");
59
+ expect(config.stt?.kind).toBeTruthy();
60
+ expect(config.llm?.kind).toBeTruthy();
61
+ expect(config.tts?.kind).toBeTruthy();
62
+ }
63
+ });
64
+
65
+ test("both builtins survive into the config a deploy carries", () => {
66
+ const builtins = toAgentConfig(agentDef).builtinTools ?? [];
67
+
68
+ // `run_code` is the arithmetic rule's only mechanism. The prompt forbids
69
+ // Penny working ANY figure out in her head — a tip, a split, a payment, a
70
+ // projection — so without the builtin the rule has nothing to point at and
71
+ // degrades into a model inventing numbers the caller then spends money on.
72
+ expect(builtins).toContain("run_code");
73
+
74
+ // `fetch_json` is the only route to a number that MOVES. A rate or a coin
75
+ // price the model remembers is months stale and carries no source, and it
76
+ // arrives in exactly the confident tone a fetched one would — which is why
77
+ // a finance starter that cannot make a request is worse than one that
78
+ // declines to answer.
79
+ expect(builtins).toContain("fetch_json");
80
+
81
+ // Asserted on the CONFIG rather than the def because that is what a deploy
82
+ // ships, and because `DEFAULT_BUILTIN_TOOLS` is empty: a builtin is
83
+ // something an agent asks for, never something it has to notice and switch
84
+ // off. So a dropped entry is not a quieter Penny, it is the same Penny with
85
+ // no way to be right. Adding builtins beside these two is an ordinary edit;
86
+ // losing one is the regression.
87
+ });
88
+
89
+ test("every builtin the prompt tells Penny to use is one she declares", () => {
90
+ // The pairing this template is made of: the prose holds the endpoints and
91
+ // the formulas, each list headed by the tool that consumes it, and
92
+ // `agent.ts` holds the array that makes those tools exist. The failure is
93
+ // silent in both directions — a prompt commanding `fetch_json` at an agent
94
+ // that never declared it produces a model apologizing for a tool it cannot
95
+ // see, and a builtin dropped from `agent.ts` alone leaves the endpoint list
96
+ // addressed to nothing — and neither shows up in a diff of either file.
97
+ const config = toAgentConfig(agentDef);
98
+ const declared = config.builtinTools ?? [];
99
+
100
+ // Which snake_case tokens in the prose are tool NAMES is a question for the
101
+ // SDK's own schema rather than a catalog restated here: this prompt also
102
+ // names `vs_currencies`, `include_24hr_change`, `per_person` and
103
+ // `annual_rate`, so matching every underscored word would redden on a
104
+ // formula, and a copied list of builtins goes stale the first time the SDK
105
+ // adds one.
106
+ const isBuiltin = (name: string) =>
107
+ AgentConfigSchema.safeParse({ ...config, builtinTools: [name] }).success;
108
+ const commanded = [
109
+ ...new Set(config.systemPrompt.match(/\b[a-z][a-z0-9]*(?:_[a-z0-9]+)+\b/g) ?? []),
110
+ ].filter(isBuiltin);
111
+
112
+ // Non-vacuity, and it earns its keep twice: a prompt naming no builtin at
113
+ // all would leave the loop below asserting nothing, and it is also the state
114
+ // this template lands in when `system-prompt.md` is not applied — the
115
+ // framework default names no builtin, so "I edited the prompt and nothing
116
+ // changed" fails here instead of passing quietly with Penny's rules nowhere
117
+ // in her context.
118
+ expect(commanded.length).toBeGreaterThan(0);
119
+ for (const name of commanded) {
120
+ expect(declared, `the prompt tells Penny to use ${name}`).toContain(name);
121
+ }
122
+
123
+ // The converse is deliberately NOT asserted: declaring a builtin the prompt
124
+ // never mentions is an ordinary edit, and the model learns about it from its
125
+ // own tool schema rather than from the prose.
126
+ });
127
+ });
@@ -1,6 +1,6 @@
1
1
  import { agent } from "@alexkroman1/aai";
2
2
  import { toAgentConfig } from "@alexkroman1/aai/manifest";
3
- import { assemblyAIS2s, openaiS2s } from "@alexkroman1/aai/s2s";
3
+ import { assemblyAIS2s, openAIS2s } from "@alexkroman1/aai/s2s";
4
4
  import { assemblyAIStt, deepgramStt, elevenLabsStt, sonioxStt } from "@alexkroman1/aai/stt";
5
5
  import { ASSEMBLYAI_TTS_VOICES, assemblyAITts, cartesiaTts, rimeTts } from "@alexkroman1/aai/tts";
6
6
  import { describe, expect, test } from "vitest";
@@ -99,7 +99,7 @@ describe("swapping any other stage", () => {
99
99
  test("S2S: an explicit opt-in, and it REPLACES the pipeline rather than joining it", () => {
100
100
  for (const [s2s, kind] of [
101
101
  [assemblyAIS2s(), "assemblyai"],
102
- [openaiS2s({ voice: "alloy" }), "openai-realtime"],
102
+ [openAIS2s({ voice: "alloy" }), "openai-realtime"],
103
103
  ] as const) {
104
104
  const config = toAgentConfig(agent({ name: "Line", s2s }));
105
105
  expect(config.s2s?.kind).toBe(kind);
@@ -1,6 +1,7 @@
1
+ import { formatMoney } from "@alexkroman1/aai/utils";
1
2
  import "@alexkroman1/aai-ui/styles.css";
2
- import { client, useAgentState } from "@alexkroman1/aai-ui";
3
- import { formatPrice, orderProjection, pizzaPrice } from "./shared.ts";
3
+ import { mountClient, useAgentState } from "@alexkroman1/aai-ui";
4
+ import { orderProjection, pizzaPrice } from "./shared.ts";
4
5
 
5
6
  function PizzaIcon({ size }: { size: string }) {
6
7
  const dim = size === "small" ? 36 : size === "large" ? 52 : 44;
@@ -67,7 +68,7 @@ function OrderSidebar() {
67
68
  </p>
68
69
  </div>
69
70
  <p className="text-sm font-bold whitespace-nowrap text-aai-primary">
70
- {formatPrice(pizzaPrice(p))}
71
+ {formatMoney(pizzaPrice(p))}
71
72
  </p>
72
73
  </div>
73
74
  ))}
@@ -79,7 +80,7 @@ function OrderSidebar() {
79
80
  );
80
81
  }
81
82
 
82
- client({
83
+ mountClient({
83
84
  name: "Pizza Palace",
84
85
  sidebar: OrderSidebar,
85
86
  theme: {
@@ -1,4 +1,5 @@
1
1
  import { type DeepReadonly, sessionSlot } from "@alexkroman1/aai";
2
+ import { formatMoney } from "@alexkroman1/aai/utils";
2
3
 
3
4
  export const SIZES = ["small", "medium", "large"] as const;
4
5
  export const CRUSTS = ["thin", "regular", "thick", "stuffed"] as const;
@@ -37,19 +38,13 @@ export function calculateTotal(pizzas: readonly ReadonlyPizza[]): number {
37
38
  return pizzas.reduce((total, pizza) => total + pizzaPrice(pizza), 0);
38
39
  }
39
40
 
40
- /** The one money format. Tool results, the projection, and the sidebar all
41
- * show prices through this, so they can never disagree on rounding. */
42
- export function formatPrice(amount: number): string {
43
- return `$${amount.toFixed(2)}`;
44
- }
45
-
46
41
  /**
47
42
  * The menu as prompt prose, generated from `MENU` so the agent can never
48
43
  * quote a price the pricing code doesn't charge.
49
44
  */
50
45
  export function menuText(): string {
51
46
  const price = (amount: number, upcharge = false) =>
52
- amount === 0 ? "free" : `${upcharge ? "+" : ""}${formatPrice(amount)}`;
47
+ amount === 0 ? "free" : `${upcharge ? "+" : ""}${formatMoney(amount)}`;
53
48
  const list = (items: Record<string, number>, upcharge = false) =>
54
49
  Object.entries(items)
55
50
  .map(([name, amount]) => `${name.replaceAll("_", " ")} (${price(amount, upcharge)})`)
@@ -169,7 +164,7 @@ export function orderView(order: FrozenOrderState): OrderView {
169
164
  const placed = order.placed;
170
165
  return {
171
166
  pizzas: order.pizzas,
172
- total: placed?.total ?? formatPrice(calculateTotal(order.pizzas)),
167
+ total: placed?.total ?? formatMoney(calculateTotal(order.pizzas)),
173
168
  orderPlaced: Boolean(placed),
174
169
  ...(placed
175
170
  ? { orderNumber: placed.orderNumber, estimatedMinutes: placed.estimatedMinutes }