@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
@@ -22,8 +22,10 @@
22
22
  /**
23
23
  * The def a DEPLOYED agent runs: authored, plus what `tools/` declares.
24
24
  *
25
- * The glob is written HERE rather than reached for from a shared helper because
26
- * this file SHIPS a scaffolded project has no repo helper to import. Without
25
+ * Taken from `virtual:aai/agent` rather than a hand-written glob: the plugin
26
+ * expands it against THIS file's own directory, so the spec needs no glob and
27
+ * no shared helper — which matters because this file SHIPS, and a scaffolded
28
+ * project has no repo helper to import. Without
27
29
  * it the desk would have no tools and would answer every one of these from
28
30
  * memory, which is the failure these cases exist to catch.
29
31
  *
@@ -57,10 +57,14 @@ describe("health-assistant template", () => {
57
57
  test("both tools are discovered from tools/", () => {
58
58
  // `agent()` takes no `tools` field: a file in `tools/` IS the tool. A
59
59
  // template whose tools are never resolved ships a model with none.
60
- expect(Object.keys(agentDef.tools ?? {}).sort()).toEqual([
61
- "check_drug_interaction",
62
- "medication_lookup",
63
- ]);
60
+ // `arrayContaining` rather than an exact list: a tool you add is the edit
61
+ // this template invites, and it must not redden a test you did not write.
62
+ // Losing one of these two still fails, which is the regression worth
63
+ // catching — discovery silently finding nothing looks exactly like a
64
+ // template with no tools.
65
+ expect(Object.keys(agentDef.tools ?? {})).toEqual(
66
+ expect.arrayContaining(["check_drug_interaction", "medication_lookup"]),
67
+ );
64
68
  });
65
69
  });
66
70
 
@@ -1,10 +1,11 @@
1
1
  import "@alexkroman1/aai-ui/styles.css";
2
- import type { AgentState, Session } from "@alexkroman1/aai-ui";
2
+ import type { AgentState } from "@alexkroman1/aai-ui";
3
3
  import {
4
4
  AutoScroll,
5
- client,
5
+ mountClient,
6
6
  useConversation,
7
- useSession,
7
+ useSessionActions,
8
+ useSessionError,
8
9
  useSessionSelector,
9
10
  useTheme,
10
11
  } from "@alexkroman1/aai-ui";
@@ -98,7 +99,7 @@ function Transcript() {
98
99
  // The one theme read left in this file, and the case `useTheme()` is still
99
100
  // for: `scrollbar-color` takes TWO values and has no utility class, so it has
100
101
  // to be a style — and reading it off the theme beats re-pinning the two hex
101
- // codes the `client({ theme })` block below already declares.
102
+ // codes the `mountClient({ theme })` block below already declares.
102
103
  const theme = useTheme();
103
104
 
104
105
  return (
@@ -146,8 +147,16 @@ function Transcript() {
146
147
  {transcript.text}
147
148
  </div>
148
149
  )}
150
+ {/* Same contract as the shipped `MessageList`'s indicator: the blinking
151
+ block is the only sign the parser is working, and to a screen reader it
152
+ is one unpronounceable glyph. */}
149
153
  {thinking && (
150
- <div className="animate-pulse" style={{ color: GREEN_DIM }}>
154
+ <div
155
+ role="status"
156
+ aria-label="The parser is thinking"
157
+ className="animate-pulse"
158
+ style={{ color: GREEN_DIM }}
159
+ >
151
160
  &#9612;
152
161
  </div>
153
162
  )}
@@ -155,12 +164,18 @@ function Transcript() {
155
164
  );
156
165
  }
157
166
 
167
+ /** Module scope, so the selector has a STABLE identity: an inline arrow makes
168
+ * `useSyncExternalStoreWithSelector` rebuild its selection memo every render,
169
+ * and this runs on every snapshot push — each STT partial and streaming
170
+ * delta. Counting with `reduce` rather than `filter().length` for the same
171
+ * reason: the array copy is thrown away, and it grows for the whole adventure. */
172
+ const userTurns = (snapshot: { messages: { role: string }[] }): number =>
173
+ snapshot.messages.reduce((n, message) => (message.role === "user" ? n + 1 : n), 0);
174
+
158
175
  /** The turn counter — a NUMBER out of the selector, so a new message array with
159
176
  * the same user-message count re-renders nothing. */
160
177
  function TurnCount() {
161
- const turns = useSessionSelector(
162
- (snapshot) => snapshot.messages.filter((message) => message.role === "user").length,
163
- );
178
+ const turns = useSessionSelector(userTurns);
164
179
  return <span>Turns: {turns}</span>;
165
180
  }
166
181
 
@@ -207,7 +222,7 @@ function StatusDot() {
207
222
  * never told an unannounced one appeared.
208
223
  */
209
224
  function ErrorBanner() {
210
- const error = useSessionSelector((snapshot) => snapshot.error);
225
+ const error = useSessionError();
211
226
  if (!error) return null;
212
227
  return (
213
228
  <div
@@ -215,33 +230,22 @@ function ErrorBanner() {
215
230
  className="px-5 py-2 text-xs"
216
231
  style={{ background: "#3a0000", color: "#ff4141" }}
217
232
  >
218
- ERROR: {error.message}
233
+ ERROR: {error.message} ({error.code})
219
234
  </div>
220
235
  );
221
236
  }
222
237
 
223
- /**
224
- * Start a fresh conversation without leaving the game screen.
238
+ /*
239
+ * Pause/resume, new game and hang-up.
225
240
  *
226
- * Written out here rather than reached for on the session, because there is no
227
- * one method that does it: `reset()` clears the CONVERSATION and keeps the
228
- * session, which is wrong for any agent that also keeps session-scoped state —
229
- * this game's world would come back with the next tool call. `end()` drops the
230
- * resume identity, so the redial is a brand-new session (fresh world, opening
231
- * scene included), and `start()` puts the player straight into it rather than
232
- * back at the title screen.
241
+ * `useSessionActions()` is the narrow way `<Controls>` reaches the methods, and
242
+ * it is published now so this row takes the three it presses and one
243
+ * selector for the flag it reads, instead of a whole-snapshot `useSession()`
244
+ * that re-rendered the footer on every partial transcript.
233
245
  */
234
- function newConversation(session: Session): void {
235
- session.end();
236
- session.start();
237
- }
238
-
239
- /** Pause/resume, new game and hang-up. The only place a whole-session
240
- * subscription is still needed: the ACTIONS live on `useSession()`, and
241
- * `useSessionCore` — the narrow way `<Controls>` reaches them — is not on the
242
- * public surface. */
243
246
  function Footer() {
244
- const session = useSession();
247
+ const { toggle, restart, end } = useSessionActions();
248
+ const running = useSessionSelector((snapshot) => snapshot.running);
245
249
  return (
246
250
  <div
247
251
  className="flex items-center justify-between px-5 py-2 shrink-0 gap-3"
@@ -253,9 +257,9 @@ function Footer() {
253
257
  type="button"
254
258
  className="px-4 py-1 bg-transparent cursor-pointer uppercase tracking-wider font-mono text-[11px]"
255
259
  style={{ color: GREEN_DIM, border: `1px solid ${GREEN_DARK}` }}
256
- onClick={session.toggle}
260
+ onClick={toggle}
257
261
  >
258
- {session.running ? "[P]ause" : "[R]esume"}
262
+ {running ? "[P]ause" : "[R]esume"}
259
263
  </button>
260
264
  {/* The one-click new conversation the default shell's `<Controls>`
261
265
  gives every other template — a custom `component:` renders no
@@ -266,7 +270,7 @@ function Footer() {
266
270
  type="button"
267
271
  className="px-4 py-1 bg-transparent cursor-pointer uppercase tracking-wider font-mono text-[11px]"
268
272
  style={{ color: GREEN_DIM, border: `1px solid ${GREEN_DARK}` }}
269
- onClick={() => newConversation(session)}
273
+ onClick={restart}
270
274
  >
271
275
  [N]ew Game
272
276
  </button>
@@ -276,7 +280,7 @@ function Footer() {
276
280
  type="button"
277
281
  className="px-4 py-1 bg-transparent cursor-pointer uppercase tracking-wider font-mono text-[11px]"
278
282
  style={{ color: GREEN_DIM, border: `1px solid ${GREEN_DARK}` }}
279
- onClick={() => session.end()}
283
+ onClick={() => end()}
280
284
  >
281
285
  [Q]uit
282
286
  </button>
@@ -315,7 +319,7 @@ function Crt({ children }: { children: ReactNode }) {
315
319
  }
316
320
 
317
321
  function TitleScreen() {
318
- const session = useSession();
322
+ const { start } = useSessionActions();
319
323
  return (
320
324
  <Crt>
321
325
  <div
@@ -345,7 +349,7 @@ function TitleScreen() {
345
349
  border: `1px solid ${GREEN}`,
346
350
  animation: "ic-pulse 2s ease-in-out infinite",
347
351
  }}
348
- onClick={session.start}
352
+ onClick={start}
349
353
  >
350
354
  Begin Adventure
351
355
  </button>
@@ -381,7 +385,7 @@ function InfocomAdventure() {
381
385
  );
382
386
  }
383
387
 
384
- client({
388
+ mountClient({
385
389
  component: InfocomAdventure,
386
390
  theme: {
387
391
  bg: CRT_BG,
@@ -26,7 +26,7 @@
26
26
  */
27
27
 
28
28
  import {
29
- createWorkflowCtx,
29
+ createWorkflowContext,
30
30
  schemaInputIssues,
31
31
  stubGatewayRoute,
32
32
  stubStepInfo,
@@ -51,7 +51,13 @@ describe("the agent declares itself a workflow app", () => {
51
51
  test("under the name the page starts a run by", () => {
52
52
  // `api.start("digest", …)` in client.tsx names this key. Nothing else
53
53
  // records it, so a rename here is a 400 there rather than a compile error.
54
- expect(Object.keys(agentDef.workflows ?? {})).toEqual(["digest"]);
54
+ // `toContain` rather than an exact key list: adding a second workflow is an
55
+ // invited edit and must not redden a test the author did not write. The
56
+ // NAME is still pinned, deliberately — the page starts a run by this
57
+ // string, so renaming the key is a runtime 400 rather than a compile
58
+ // error, and this pin is the only thing that says so. Rename it here and
59
+ // in `client.tsx` together.
60
+ expect(Object.keys(agentDef.workflows ?? {})).toContain("digest");
55
61
  expect(agentDef.workflows?.digest).toBe(digest);
56
62
  });
57
63
  });
@@ -164,7 +170,7 @@ describe("fetchArticle", () => {
164
170
  });
165
171
 
166
172
  test("fails FATALLY on a 404 and plainly on a 503", async () => {
167
- // `stepFetchOk` builds the message, so it is the SDK's one spelling for a
173
+ // `stepFetchOrFail` builds the message, so it is the SDK's one spelling for a
168
174
  // bad response — the REQUEST, the status, and a preview of any body.
169
175
  stubPage("", 404);
170
176
  await expect(fetchArticle("https://example.com/gone")).rejects.toThrow(
@@ -258,7 +264,7 @@ describe("summarize", () => {
258
264
  // which is the only place the policy is observable at all. `runSteps: false`
259
265
  // because the subject is the declared policy rather than the work: the steps
260
266
  // would otherwise need a page and a model.
261
- const ctx = createWorkflowCtx({ runSteps: false });
267
+ const ctx = createWorkflowContext({ runSteps: false });
262
268
  await digestFlow({ url: "https://example.com/a" }, ctx);
263
269
 
264
270
  const summarizeStep = ctx.steps.find((step) => step.name === "summarize");
@@ -13,8 +13,8 @@
13
13
  * the entire product, and the declaration says so:
14
14
  *
15
15
  * - `workflowApp()` is `agent({ …, page: "static" })` with the discriminant
16
- * already set. The declaration is not decoration: `createServer` declines
17
- * `/websocket` with a reason (so a page mounted with `client()` by mistake
16
+ * already set. The declaration is not decoration: `createRuntimeServer` declines
17
+ * `/websocket` with a reason (so a page mounted with `mountClient()` by mistake
18
18
  * fails the same way in `aai dev` and in production rather than only after a
19
19
  * deploy) and telephony defaults off.
20
20
  * - There is no `stt`/`llm`/`tts`, no `tools` and no `systemPrompt` — and they
@@ -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.
@@ -19,16 +19,18 @@
19
19
  * page used to promise "the run continues without it" and then had no way back
20
20
  * to the run it was promising about.
21
21
  *
22
- * `key` is the handle that survives, and `recover` is what reads it back. Two
23
- * lines of wiring, one decision:
22
+ * A correlation KEY is the handle that survives, and this page writes none of
23
+ * it: `useWorkflowSubmit` mints an opaque per-page key into `sessionStorage`,
24
+ * records every run under it, and asks `find("digest", key)` as it mounts —
25
+ * so a reload lands back on the same headline, the same progress log and the
26
+ * same buttons. Six templates used to write those two options each, which is
27
+ * what made it the default.
24
28
  *
25
- * - **The key is OPAQUE and lives in `sessionStorage`.** It names nothing about
26
- * the person or the link `useRunKey()` is the SDK's, and its module argues
27
- * what that rules out (a key derived from the URL being digested, a `?key=`
28
- * parameter) and why. Six templates had written those twenty lines each.
29
- * - **`recover: true`** makes the hook ask `find("digest", key)` as it mounts
30
- * and follow whatever run comes back, so a reload lands back on the same
31
- * headline, the same progress log and the same buttons.
29
+ * What the page can still say is which key: `useRunKey({ storage: "local" })`
30
+ * for a run meant to outlive the tab (`podcast-digest`), an ACCOUNT's own id
31
+ * for an app with logins, `recover: false` for a form that must always open
32
+ * empty. `use-run-key.ts` argues what a key may not be — derived from the URL
33
+ * being digested, or carried in a `?key=` parameter.
32
34
  *
33
35
  * Deployed, this needs the correlation-key index, which is a `DATABASE_URL`
34
36
  * away — `agent.ts` says what happens without one (the runs are still durable;
@@ -47,7 +49,7 @@
47
49
  *
48
50
  * `useWorkflowSubmit` answers "where has this got to" from the world's own
49
51
  * record — pending, running, completed. `<WorkflowProgress>` answers "what is it
50
- * 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
51
53
  * page with only the first shows "Working…" for the length of the run; a page
52
54
  * with only the second cannot tell a finished run from a quiet one. Both are
53
55
  * cheap: one stream each, ended by the agent when there is nothing left to say.
@@ -55,14 +57,14 @@
55
57
  * Progress also REPLAYS — chunks are retained with the run — so a reload mid-run
56
58
  * catches up rather than starting from whatever arrives next. That only pays off
57
59
  * because the reload can name its run again: `<WorkflowProgress runId>` is handed
58
- * `run?.runId`, so before `recover` a refresh replayed a log for nobody.
60
+ * `run?.runId`, so before the recovery a refresh replayed a log for nobody.
59
61
  * `lines={1}` is
60
62
  * what narrows it to the newest line, because on a page this small that is the
61
63
  * whole of what a status wants; `transcription-workflow` renders the full log,
62
64
  * where a fan-out makes the history worth seeing.
63
65
  */
64
66
 
65
- import { page, useRunKey, useWorkflowSubmit, WorkflowProgress } from "@alexkroman1/aai-ui";
67
+ import { BulletList, mountPage, useWorkflowSubmit, WorkflowProgress } from "@alexkroman1/aai-ui";
66
68
  import "@alexkroman1/aai-ui/styles.css";
67
69
  // ERASED at build time, so naming the agent's own type costs the browser bundle
68
70
  // nothing — and it is what stops this file restating a shape `workflows/
@@ -90,32 +92,22 @@ function pendingNote(startedHere: boolean, found: boolean): string {
90
92
 
91
93
  export function App() {
92
94
  const [url, setUrl] = useState("");
93
- // This tab's handle on its own runs — minted once and remembered, which is
94
- // what a later load produces to find the run again.
95
- const key = useRunKey();
96
- // Did THIS load start the run? A reload cannot have, and that is the only way
97
- // the page can tell "working on what you just submitted" from "picking up
98
- // where you left off" — the hook reports the run, not who asked for it.
99
- const [startedHere, setStartedHere] = useState(false);
100
95
  // The generic is what makes `run.status === "completed"` narrow to a TYPED
101
96
  // `run.output` instead of `unknown`. `error` is the agent's own sentence for a
102
97
  // rejected input, which is better copy than anything this page could write, and
103
98
  // `wake` is bound to whatever run the hook is following — the whole reason this
104
99
  // page no longer holds a `createWorkflowApi()` of its own.
105
- const { submit, run, pending, error, wake } = useWorkflowSubmit<typeof digest>("digest", {
106
- // Recorded with the run, and read back on the next load. Neither half is
107
- // useful alone: without the key there is nothing to find the run by, and
108
- // without `recover` the key is only ever written.
109
- key,
110
- recover: true,
111
- });
100
+ // No `key` and no `recover`: this tab's handle on its own runs is minted and
101
+ // remembered by the hook, and read back as it mounts. See the module doc for
102
+ // what a page says when it wants a different one.
103
+ const { submit, run, pending, error, wake, startedHere } =
104
+ useWorkflowSubmit<typeof digest>("digest");
112
105
 
113
106
  // `submit()` resolves as soon as the run exists — deliberately not when it
114
107
  // finishes. That is the whole mechanism: the digest sleeps for a while, and
115
108
  // this page is free to be closed in the meantime.
116
109
  const onSubmit = (event: React.FormEvent) => {
117
110
  event.preventDefault();
118
- setStartedHere(true);
119
111
  void submit({ url });
120
112
  };
121
113
 
@@ -129,6 +121,11 @@ export function App() {
129
121
  required
130
122
  value={url}
131
123
  onChange={(e) => setUrl(e.target.value)}
124
+ // A placeholder is not a name: it disappears the moment anything is
125
+ // typed, and a screen reader reaches an unlabelled box. The declared
126
+ // fields in `@alexkroman1/aai-ui` say the same thing — a `label`, or
127
+ // an `aria-label` where the row has no room for one.
128
+ aria-label="Article URL"
132
129
  // A REAL article, because a placeholder is a suggestion and this one
133
130
  // gets typed. `https://example.com/article` 404s, and the bare
134
131
  // `example.com` a reader trims it to has no prose on it — so the first
@@ -143,7 +140,13 @@ export function App() {
143
140
  </button>
144
141
  </form>
145
142
 
146
- {error !== undefined && <p className="text-red-600">{error}</p>}
143
+ {/* `<Form>` carries `role="alert"` for the templates that declare their
144
+ fields; a hand-written form has to say it itself. */}
145
+ {error !== undefined && (
146
+ <p role="alert" className="text-red-600">
147
+ {error}
148
+ </p>
149
+ )}
147
150
 
148
151
  {/* A run that has not settled says so. `pending` is not derivable from the
149
152
  snapshot alone — an id the agent never knew leaves `run` undefined,
@@ -172,16 +175,19 @@ export function App() {
172
175
  </button>
173
176
  )}
174
177
 
175
- {run?.status === "failed" && <p className="text-red-600">That one failed: {run.error}</p>}
178
+ {/* `role="alert"`, like the submit error above: this is the outcome the
179
+ reader has been waiting for, and it can arrive long after they looked
180
+ away. */}
181
+ {run?.status === "failed" && (
182
+ <p role="alert" className="text-red-600">
183
+ That one failed: {run.error}
184
+ </p>
185
+ )}
176
186
 
177
187
  {run?.status === "completed" && (
178
188
  <article className="flex flex-col gap-3">
179
189
  <h2 className="text-xl">{run.output.headline}</h2>
180
- <ul className="flex list-disc flex-col gap-1 pl-5">
181
- {run.output.points.map((point) => (
182
- <li key={point}>{point}</li>
183
- ))}
184
- </ul>
190
+ <BulletList items={run.output.points} />
185
191
  <p className="text-sm opacity-70">Filed {run.output.filedAt}</p>
186
192
  </article>
187
193
  )}
@@ -189,4 +195,4 @@ export function App() {
189
195
  );
190
196
  }
191
197
 
192
- page({ name: "Link Digest", component: App });
198
+ 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
  }
@@ -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
  *
@@ -32,7 +32,12 @@ describe("night-owl template", () => {
32
32
  });
33
33
 
34
34
  test("recommend is discovered from tools/", () => {
35
- expect(Object.keys(agentDef.tools ?? {})).toEqual(["recommend"]);
35
+ // `toContain` rather than an exact list: a file in `tools/` IS a tool, so
36
+ // adding one is the edit this template most invites, and an exact list
37
+ // would make that edit fail a test the author never wrote. What has to hold
38
+ // is that discovery ran at all — a template whose `tools/` is never
39
+ // resolved ships a model with no tools.
40
+ expect(Object.keys(agentDef.tools ?? {})).toContain("recommend");
36
41
  });
37
42
 
38
43
  test("the projection an untouched session pushes is an empty log", () => {
@@ -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",
@@ -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";
@@ -13,25 +13,42 @@ describe("pipeline-simple template", () => {
13
13
  expect(() => toAgentConfig(agentDef)).not.toThrow();
14
14
  });
15
15
 
16
- test("declares only the LLM stage on the def", () => {
17
- // The template's point: swap one stage, leave the rest unset. The
18
- // unset stages are filled with the AssemblyAI defaults at parse time.
19
- expect(agentDef.name).toBe("pipeline-simple");
20
- expect(agentDef.llm).toBeDefined();
21
- expect(agentDef.stt).toBeUndefined();
22
- expect(agentDef.tts).toBeUndefined();
16
+ test("exports an agent the platform can name", () => {
17
+ // Not the literal. `aai init <project>` scaffolds this file verbatim, so a
18
+ // pinned name is a test about the TEMPLATE inside somebody else's project —
19
+ // and renaming the agent is the first thing they will do. What has to hold
20
+ // is that there is a name and the conversion carries it through.
21
+ expect(agentDef.name).toBeTruthy();
22
+ expect(toAgentConfig(agentDef).name).toBe(agentDef.name);
23
23
  });
24
24
 
25
- test("LLM descriptor is Anthropic", () => {
26
- expect(agentDef.llm?.kind).toBe("anthropic");
25
+ test("declares at least one stage itself, which is what makes it a pipeline", () => {
26
+ // WHICH stage is deliberately not asserted: `agent.ts` invites you to move
27
+ // the declaration ("Declare only the stage you want elsewhere"), so naming
28
+ // one here would redden on the edit the comment above it asks for.
29
+ const declared = (["stt", "llm", "tts"] as const).filter(
30
+ (stage) => agentDef[stage] !== undefined,
31
+ );
32
+ expect(declared.length).toBeGreaterThan(0);
27
33
  });
28
34
 
29
- test("unset stages fill to AssemblyAI in the deployable config", () => {
35
+ test("a stage you declare survives, and every stage you leave unset defaults", () => {
36
+ // The template's whole subject, stated so it survives a swap: the config a
37
+ // deploy carries agrees with the def wherever the def has an opinion, and
38
+ // fills the AssemblyAI default wherever it does not.
30
39
  const config = toAgentConfig(agentDef);
31
- expect(config.mode).toBe("pipeline");
32
- expect(config.stt?.kind).toBe("assemblyai");
33
- expect(config.tts?.kind).toBe("assemblyai");
34
- expect(config.llm?.kind).toBe("anthropic");
40
+ if (config.mode !== "pipeline") {
41
+ // Switched the def to `s2s`? Then there is no cascade to fill — S2S
42
+ // REPLACES the pipeline rather than joining it, which the last describe
43
+ // below is the worked example for.
44
+ expect(config.mode).toBe("s2s");
45
+ expect(config.stt).toBeUndefined();
46
+ expect(config.tts).toBeUndefined();
47
+ return;
48
+ }
49
+ for (const stage of ["stt", "llm", "tts"] as const) {
50
+ expect(config[stage]?.kind, stage).toBe(agentDef[stage]?.kind ?? "assemblyai");
51
+ }
35
52
  });
36
53
  });
37
54
 
@@ -82,7 +99,7 @@ describe("swapping any other stage", () => {
82
99
  test("S2S: an explicit opt-in, and it REPLACES the pipeline rather than joining it", () => {
83
100
  for (const [s2s, kind] of [
84
101
  [assemblyAIS2s(), "assemblyai"],
85
- [openaiS2s({ voice: "alloy" }), "openai-realtime"],
102
+ [openAIS2s({ voice: "alloy" }), "openai-realtime"],
86
103
  ] as const) {
87
104
  const config = toAgentConfig(agent({ name: "Line", s2s }));
88
105
  expect(config.s2s?.kind).toBe(kind);