@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
@@ -64,9 +64,9 @@
64
64
  * parsing. It is the section below's subject, not this one's.
65
65
  */
66
66
 
67
- import { type FeedItem, pageMetadata, parseFeed } from "@alexkroman1/aai/html";
68
- import { report } from "@alexkroman1/aai/step";
69
- import { FatalError, stepFetchOk } from "@alexkroman1/aai/step-errors";
67
+ import { type FeedItem, type ParsedFeed, pageMetadata, parseFeed } from "@alexkroman1/aai/html";
68
+ import { mapConcurrent, stepReport } from "@alexkroman1/aai/step";
69
+ import { FatalError, stepFetchOrFail } from "@alexkroman1/aai/step-errors";
70
70
  import { isRecord, omitUndefined, safeJsonParse } from "@alexkroman1/aai/utils";
71
71
  import { z } from "zod";
72
72
 
@@ -74,19 +74,32 @@ import { z } from "zod";
74
74
  const REQUEST_TIMEOUT_MS = 30_000;
75
75
 
76
76
  /**
77
- * A show, reduced to what the rest of the run needs — plus the body, when
78
- * resolving it already had to download one.
77
+ * How many pasted links are resolved at once.
79
78
  *
80
- * `xml` is the difference between one request per feed and two. Both web paths
81
- * fetch the feed to decide whether it IS a feed (`looksLikePodcastFeed`), and
82
- * without somewhere to put that body the reader downloads the identical
83
- * document a second time. The Apple and Spotify paths resolve a URL without
84
- * ever reading the feed, so they leave it unset and the reader fetches once.
79
+ * Small because the far side is Apple and Spotify, who rate-limit, and because
80
+ * a digest is a handful of shows rather than a fan-out the win is turning N
81
+ * sequential round trips into a couple of overlapped ones, not saturating a
82
+ * link.
83
+ */
84
+ const RESOLVE_CONCURRENCY = 4;
85
+
86
+ /**
87
+ * A show, reduced to what the rest of the run needs — plus the PARSE, when
88
+ * resolving it already had to do one.
89
+ *
90
+ * `parsed` is the difference between one request per feed and two, and between
91
+ * one parse and three. Both web paths fetch the feed to decide whether it IS a
92
+ * feed, and without somewhere to put the result the reader downloads and
93
+ * re-parses the identical document. Re-parsing is the more expensive half:
94
+ * `parseFeed` builds a DOM and runs `htmlToText` over every item's title AND
95
+ * description, and a podcast feed routinely carries 300+ entries of HTML show
96
+ * notes. The Apple and Spotify paths resolve a URL without ever reading the
97
+ * feed, so they leave it unset and the reader fetches once.
85
98
  */
86
99
  export type PodcastFeed = {
87
100
  feedUrl: string;
88
101
  title: string;
89
- xml?: string;
102
+ parsed?: ParsedFeed;
90
103
  };
91
104
 
92
105
  /** One episode with audio attached — the unit everything downstream works on. */
@@ -114,12 +127,17 @@ export async function discoverEpisodes(
114
127
  podcastChannels: string,
115
128
  maxEpisodes: number,
116
129
  ): Promise<Episode[]> {
117
- await report("Finding recent podcast episodes.");
130
+ await stepReport("Finding recent podcast episodes.");
118
131
  const links = parsePodcastChannels(podcastChannels);
119
132
  if (links.length === 0) throw new FatalError("Add at least one podcast link.");
120
133
 
121
- const feeds: PodcastFeed[] = [];
122
- for (const url of links) feeds.push(await resolvePodcastFeed(url));
134
+ // Overlapped, not sequential: each resolution is one to three HTTP round
135
+ // trips with a 30s ceiling, and the links are independent. Bounded rather
136
+ // than a bare `Promise.all` because Apple and Spotify both rate-limit — and
137
+ // legal here because this whole function is a STEP BODY (`ctx.step` wraps it
138
+ // in `digest.ts`), so the journal's name+occurrence rule does not apply to
139
+ // what happens inside it.
140
+ const feeds = await mapConcurrent(links, RESOLVE_CONCURRENCY, resolvePodcastFeed);
123
141
 
124
142
  const episodes = (await Promise.all(feeds.map((feed) => readPodcastFeed(feed))))
125
143
  .flat()
@@ -154,7 +172,10 @@ async function resolvePodcastFeed(url: string): Promise<PodcastFeed> {
154
172
  */
155
173
  async function resolveWebPodcastFeed(url: string): Promise<PodcastFeed> {
156
174
  const body = await fetchText(url);
157
- if (looksLikePodcastFeed(body)) return feedFrom(url, body);
175
+ // Parsed ONCE and carried: the "is this a feed" test, the channel title and
176
+ // the item list are three reads of one parse, not three parses.
177
+ const direct = parseFeed(body);
178
+ if (carriesAudio(direct)) return feedFrom(url, direct);
158
179
 
159
180
  const discovered = discoverFeedUrl(body, url);
160
181
  if (!discovered) {
@@ -167,9 +188,9 @@ async function resolveWebPodcastFeed(url: string): Promise<PodcastFeed> {
167
188
  // The advertised URL is verified rather than trusted: plenty of pages point
168
189
  // `application/rss+xml` at a blog feed with no audio in it, and finding that
169
190
  // out here names the page, where finding it out later names an empty digest.
170
- // The body is carried forward rather than re-fetched — see `PodcastFeed.xml`.
171
- const verified = await fetchText(discovered);
172
- if (!looksLikePodcastFeed(verified)) {
191
+ // The parse is carried forward rather than redone — see `PodcastFeed.parsed`.
192
+ const verified = parseFeed(await fetchText(discovered));
193
+ if (!carriesAudio(verified)) {
173
194
  throw new FatalError(`The feed at ${discovered} does not look like a podcast RSS feed.`);
174
195
  }
175
196
  return feedFrom(discovered, verified);
@@ -236,8 +257,9 @@ async function resolveSpotifyPodcastFeed(url: string): Promise<PodcastFeed> {
236
257
 
237
258
  /** Every item in the feed that has audio attached, newest first by the caller. */
238
259
  async function readPodcastFeed(feed: PodcastFeed): Promise<Episode[]> {
239
- const xml = feed.xml ?? (await fetchText(feed.feedUrl));
240
- const parsed = parseFeed(xml);
260
+ // Already parsed on the web paths; the Apple and Spotify paths resolve a URL
261
+ // without ever reading the feed, so this is where those two download it.
262
+ const parsed = feed.parsed ?? parseFeed(await fetchText(feed.feedUrl));
241
263
  // `parsed.title` is the CHANNEL's, which is the whole reason to parse: the
242
264
  // `indexOf` read this replaces took the first `<title>` at any depth.
243
265
  const podcastTitle = parsed?.title ?? feed.title;
@@ -393,17 +415,23 @@ export function titleMatchesSpotify(
393
415
  );
394
416
  }
395
417
 
396
- /** A feed document that actually carries audio — both halves are required. */
397
- export function looksLikePodcastFeed(xml: string): boolean {
398
- const parsed = parseFeed(xml);
399
- // `parseFeed` answering at all is the "is this a feed" half, and it is
400
- // stricter than the `<rss` test it replaces in the direction that matters:
401
- // an HTML page mentioning `<rss` in prose is not a feed. It is also wider
402
- // where being wide is right — an Atom podcast feed has no `<rss` root and
403
- // was refused outright.
418
+ /**
419
+ * A parse that actually carries audio — both halves are required.
420
+ *
421
+ * `parseFeed` answering at all is the "is this a feed" half, and it is stricter
422
+ * than the `<rss` test it replaces in the direction that matters: an HTML page
423
+ * mentioning `<rss` in prose is not a feed. It is also wider where being wide is
424
+ * right — an Atom podcast feed has no `<rss` root and was refused outright.
425
+ */
426
+ function carriesAudio(parsed: ParsedFeed | undefined): parsed is ParsedFeed {
404
427
  return parsed?.items.some((item) => item.enclosureUrl !== undefined) ?? false;
405
428
  }
406
429
 
430
+ /** {@link carriesAudio} over a document that has not been parsed yet. */
431
+ export function looksLikePodcastFeed(xml: string): boolean {
432
+ return carriesAudio(parseFeed(xml));
433
+ }
434
+
407
435
  /** The first feed a page advertises, resolved against the page's own URL. */
408
436
  export function discoverFeedUrl(html: string, pageUrl: string): string | undefined {
409
437
  // `pageMetadata` reads `<link>` off a parse, so attribute ORDER stops
@@ -441,11 +469,11 @@ function publishedAt(episode: Episode): number {
441
469
  return Number.isNaN(parsed) ? 0 : parsed;
442
470
  }
443
471
 
444
- function feedFrom(feedUrl: string, body: string): PodcastFeed {
472
+ function feedFrom(feedUrl: string, parsed: ParsedFeed): PodcastFeed {
445
473
  return {
446
474
  feedUrl,
447
- title: parseFeed(body)?.title ?? hostOf(feedUrl),
448
- xml: body,
475
+ title: parsed.title ?? hostOf(feedUrl),
476
+ parsed,
449
477
  };
450
478
  }
451
479
 
@@ -529,7 +557,7 @@ function appleResults(body: unknown): AppleResult[] {
529
557
  /**
530
558
  * Every outbound call in this file, with one timeout and one failure policy.
531
559
  *
532
- * `stepFetchOk` is `stepFetch` plus the non-2xx branch — see its own doc. Three
560
+ * `stepFetchOrFail` is `stepFetch` plus the non-2xx branch — see its own doc. Three
533
561
  * things come with it that a bare `fetch` here would each have to re-derive:
534
562
  * HTTP/1.1 (so a rate limit arrives as a `503` with `Retry-After` rather than
535
563
  * an h2 stream reset carrying no status), the transient/terminal verdict the
@@ -537,7 +565,7 @@ function appleResults(body: unknown): AppleResult[] {
537
565
  * the iTunes endpoints is the difference between "403" and a sentence.
538
566
  */
539
567
  async function fetchText(url: string): Promise<string> {
540
- const response = await stepFetchOk(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
568
+ const response = await stepFetchOrFail(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
541
569
  return await response.text();
542
570
  }
543
571
 
@@ -7,7 +7,7 @@
7
7
  * webhook shapes and the branch between them, Block Kit assembly, mrkdwn
8
8
  * escaping, the 4xx/5xx split and the advice each refusal deserves. All of it
9
9
  * is `@alexkroman1/aai/channels` now — `slackChannel()` names the destination,
10
- * `sendToChannelClassified` posts and classifies — and what is left here is the
10
+ * `sendToChannelOrFail` posts and classifies — and what is left here is the
11
11
  * part that is actually about podcasts: turning episode digests into a
12
12
  * {@link ChannelMessage}.
13
13
  *
@@ -22,13 +22,13 @@
22
22
  * The step BOUNDARY is not here — only a body holds a `ctx`, and the call this
23
23
  * file is reached through is `ctx.step("postDigest", …)` in `digest.ts`. What
24
24
  * stays here is what a step DOES: the digest rendered as a `ChannelMessage` and
25
- * one `sendToChannelClassified` call. Deciding which steps exist is the
25
+ * one `sendToChannelOrFail` call. Deciding which steps exist is the
26
26
  * template's job; what happens inside one is the SDK's.
27
27
  */
28
28
 
29
29
  import { type ChannelMessage, slackChannel } from "@alexkroman1/aai/channels";
30
- import { report } from "@alexkroman1/aai/step";
31
- import { sendToChannelClassified } from "@alexkroman1/aai/step-errors";
30
+ import { stepReport } from "@alexkroman1/aai/step";
31
+ import { sendToChannelOrFail } from "@alexkroman1/aai/step-errors";
32
32
  import type { EpisodeDigest } from "./digest.ts";
33
33
 
34
34
  /** Everything the message needs, so rendering can stay a pure function. */
@@ -46,14 +46,14 @@ export type SlackDigestInput = {
46
46
  *
47
47
  * It is three lines because the interesting decisions moved. `slackChannel()` builds
48
48
  * the descriptor, {@link renderDigestMessage} says what the message contains,
49
- * and `sendToChannelClassified` does the render-post-classify round — throwing
49
+ * and `sendToChannelOrFail` does the render-post-classify round — throwing
50
50
  * a `FatalError` on a 4xx (a revoked webhook and a wrong variable name answer
51
51
  * identically on every retry, so retrying only delays the real error) and a
52
52
  * `RetryableError` carrying Slack's own `Retry-After` on a 5xx.
53
53
  */
54
54
  export async function sendDigestToSlack(input: SlackDigestInput): Promise<string> {
55
- await report("Posting the digest to Slack.");
56
- return await sendToChannelClassified(
55
+ await stepReport("Posting the digest to Slack.");
56
+ return await sendToChannelOrFail(
57
57
  slackChannel({ webhookUrl: input.slackWebhookUrl, textParam: input.slackWorkflowTextParam }),
58
58
  renderDigestMessage(input),
59
59
  );
@@ -40,7 +40,7 @@
40
40
  //
41
41
  // What no eval here can show is a delivery RESUMING a run, so no case claims
42
42
  // it. The answered arm is `agent.test.ts`'s, through
43
- // `createWorkflowCtx({ hooks })`, which is the only tier that can send a
43
+ // `createWorkflowContext({ hooks })`, which is the only tier that can send a
44
44
  // payload at all; a real HTTP POST to the public callback route is
45
45
  // `aai-cli`'s `dev-workflow.scenario.test.ts`'s, and is not yet written.
46
46
  // * **The provider is scripted, in BOTH modes**, through `stepFetch`'s
@@ -62,7 +62,7 @@
62
62
  * this file SHIPS — see `agent.test.ts`.
63
63
  */
64
64
  import agentDef from "virtual:aai/agent";
65
- import { stubGatewayRoute } from "@alexkroman1/aai/testing";
65
+ import { routeStepFetch, type StepRoute, stubGatewayRoute } from "@alexkroman1/aai/testing";
66
66
  import { installStubStepFetch } from "@alexkroman1/aai/testing/vitest";
67
67
  import {
68
68
  describeToolCalls,
@@ -138,22 +138,30 @@ function stubProvider(options: { hold?: boolean; ending?: Ending } = {}): Script
138
138
  const gate = Promise.withResolvers<void>();
139
139
  const model = stubGatewayRoute(RECAP_JSON);
140
140
  let polls = 0;
141
- const stub = installStubStepFetch(async (request) => {
142
- const recapped = model.route(request);
143
- if (recapped) return recapped;
141
+ // Two legs: the model, then the provider's job API. Anything neither answers is a
142
+ // finding, which is `routeStepFetch`'s default rather than this file's throw.
143
+ // Annotated, so the leg's contract is visible where it is written: answer the
144
+ // requests you recognise, `undefined` for everything else, and let
145
+ // `routeStepFetch` decide what an unrecognised one means.
146
+ const provider: StepRoute = (request) => {
144
147
  if (request.method === "POST") return { body: { id: TRANSCRIPT_ID, status: "queued" } };
145
148
  // The compensation. A real DELETE removes the transcript from the account,
146
149
  // which is what makes "a failed run leaves nothing behind" a claim rather
147
150
  // than a comment — so the assertion that matters is that this was CALLED.
148
151
  if (request.method === "DELETE") return { body: {} };
149
- if (request.method === "GET") {
150
- polls += 1;
151
- if (options.hold === true && polls === 1) await gate.promise;
152
- return options.ending === "error"
153
- ? { body: { status: "error", error: "that recording could not be decoded" } }
154
- : { body: { status: "completed", text: TRANSCRIPT_TEXT, audio_duration: 254 } };
155
- }
156
- throw new Error(`unexpected step request in an eval: ${request.method} ${request.url}`);
152
+ if (request.method !== "GET") return;
153
+ polls += 1;
154
+ return options.ending === "error"
155
+ ? { body: { status: "error", error: "that recording could not be decoded" } }
156
+ : { body: { status: "completed", text: TRANSCRIPT_TEXT, audio_duration: 254 } };
157
+ };
158
+ const route = routeStepFetch([model.route, provider]);
159
+ const stub = installStubStepFetch(async (request) => {
160
+ const answered = route(request);
161
+ // Held AFTER routing, so the poll this returns is the one the script owed
162
+ // it — and `polls` has already counted this request.
163
+ if (options.hold === true && request.method === "GET" && polls === 1) await gate.promise;
164
+ return answered;
157
165
  });
158
166
  return { calls: stub.calls, release: () => gate.resolve() };
159
167
  }
@@ -33,7 +33,7 @@
33
33
  * **The branch this file used to name as its biggest gap no longer exists.** It
34
34
  * was `recapFlow`'s `if (isWorkflowSuspend(err)) throw err;` — the guard whose
35
35
  * absence had once deleted the transcript the run was waiting for — and it was
36
- * unpinnable here by construction, since `createWorkflowCtx`'s `sleep` is
36
+ * unpinnable here by construction, since `createWorkflowContext`'s `sleep` is
37
37
  * RECORDED and its `waitFor` answers out of `hooks`, so no wait it serves ever
38
38
  * suspended. A wait now hands the body a promise that never settles, so a
39
39
  * suspension cannot reach a `catch` at all and there is no branch left to test:
@@ -47,7 +47,7 @@ import type { WorkflowClient } from "@alexkroman1/aai";
47
47
  import {
48
48
  createRunSnapshot,
49
49
  createToolContext,
50
- createWorkflowCtx,
50
+ createWorkflowContext,
51
51
  parseSchemaInput,
52
52
  schemaInputIssues,
53
53
  stubGatewayRoute,
@@ -55,7 +55,7 @@ import {
55
55
  } from "@alexkroman1/aai/testing";
56
56
  import {
57
57
  installStubStepFetch,
58
- mockWorkflows,
58
+ installStubWorkflows,
59
59
  installStubGateway as stubGateway,
60
60
  } from "@alexkroman1/aai/testing/vitest";
61
61
  import type { WorkflowRunSnapshot } from "@alexkroman1/aai/workflow-api";
@@ -89,14 +89,14 @@ const run = toolRunner(agentDef);
89
89
  /**
90
90
  * A `ctx.workflows` that records `start` and answers the lookups from a fixture.
91
91
  *
92
- * `mockWorkflows` (`@alexkroman1/aai/testing/vitest`) is the whole thing — a
92
+ * `installStubWorkflows` (`@alexkroman1/aai/testing/vitest`) is the whole thing — a
93
93
  * `vi.fn` per method over one `runs` list, with `stream`/`streamTail` left
94
94
  * rejecting because `recap_progress` reads progress through `lastLine` and
95
95
  * composing those two by hand is the hazard `lastLine` exists to remove. What
96
96
  * is local is only which workflow this desk declares.
97
97
  */
98
98
  function stubWorkflows(runs: WorkflowRunSnapshot[] = []): WorkflowClient {
99
- return mockWorkflows({ runs, names: ["recap"] });
99
+ return installStubWorkflows({ runs, names: ["recap"] });
100
100
  }
101
101
 
102
102
  /** A finished recap, as the workflow's output reaches the tools. */
@@ -116,7 +116,13 @@ function finishedOutput(over: { kept?: boolean; answered?: boolean } = {}) {
116
116
 
117
117
  describe("the agent declares its workflow", () => {
118
118
  test("under the name ctx.workflows.start resolves it by", () => {
119
- expect(Object.keys(agentDef.workflows ?? {})).toEqual(["recap"]);
119
+ // `toContain` rather than an exact key list: a second workflow is an
120
+ // invited edit and must not redden a test the author did not write. The
121
+ // NAME is still pinned, deliberately — this key is a STRING to everything
122
+ // outside this file (the REST route, `ctx.workflows.get`, a schedule), so
123
+ // renaming it is a runtime 404 rather than a compile error, and nothing
124
+ // else says so.
125
+ expect(Object.keys(agentDef.workflows ?? {})).toContain("recap");
120
126
  expect(agentDef.workflows?.recap).toBe(recap);
121
127
  });
122
128
 
@@ -143,14 +149,21 @@ describe("the agent declares its workflow", () => {
143
149
  });
144
150
 
145
151
  test("discovers every tool in tools/, by file name", () => {
146
- // Discovered, not declared: every name here is a file in `tools/`.
147
- expect(Object.keys(agentDef.tools).sort()).toEqual([
148
- "cancel_recap",
149
- "keep_transcript",
150
- "recap_progress",
151
- "recap_status",
152
- "request_recap",
153
- ]);
152
+ // Discovered, not declared: every name here is a file in `tools/`. Asserted
153
+ // with `arrayContaining`, because dropping a file into `tools/` is the
154
+ // cheapest edit this template invites and an exact list would turn it into
155
+ // a failing test in somebody else's project. What still fails is a tool
156
+ // going MISSING — which is what a broken discovery looks like, and it looks
157
+ // identical to a template that never had tools.
158
+ expect(Object.keys(agentDef.tools)).toEqual(
159
+ expect.arrayContaining([
160
+ "cancel_recap",
161
+ "keep_transcript",
162
+ "recap_progress",
163
+ "recap_status",
164
+ "request_recap",
165
+ ]),
166
+ );
154
167
  });
155
168
  });
156
169
 
@@ -386,7 +399,7 @@ describe("cancel_recap", () => {
386
399
  *
387
400
  * Published into `stepFetch`'s OWN slot, not over `globalThis.fetch`. Every
388
401
  * request in this file goes through `stepFetch` — `request()` and
389
- * `discardTranscript` reach it directly, `stepTranscribeSubmitClassified`
402
+ * `discardTranscript` reach it directly, `stepTranscribeSubmitOrFail`
390
403
  * through the SDK — and `step-fetch.ts` falls back to `globalThis.fetch` only
391
404
  * when nothing is published. A global stub therefore passed while exercising a
392
405
  * path production never takes; every sibling template already stubs the slot,
@@ -673,7 +686,7 @@ describe("summarize", () => {
673
686
  // The policy is an argument to `ctx.step` now, so it is observable only at
674
687
  // the call. `runSteps: false` and a skeleton of results: the subject is what
675
688
  // the body ASKED FOR.
676
- const ctx = createWorkflowCtx({
689
+ const ctx = createWorkflowContext({
677
690
  runSteps: false,
678
691
  results: {
679
692
  submitRecording: { id: "t_1" },
@@ -711,7 +724,7 @@ describe("awaitTranscript — the polling port, and the callback over it", () =>
711
724
  { status: "processing" },
712
725
  { status: "completed", text: "Done.", audio_duration: 60 },
713
726
  ]);
714
- const state = await awaitTranscript("t_1", createWorkflowCtx());
727
+ const state = await awaitTranscript("t_1", createWorkflowContext());
715
728
  expect(state).toMatchObject({ status: "completed", text: "Done." });
716
729
  expect(polls()).toBe(3);
717
730
  });
@@ -720,13 +733,15 @@ describe("awaitTranscript — the polling port, and the callback over it", () =>
720
733
  // `error` is terminal: polling a failed job to the bound would spend twenty
721
734
  // minutes learning what the first answer already said.
722
735
  const polls = stubStatuses([{ status: "error", error: "Transcoding failed" }]);
723
- await expect(awaitTranscript("t_1", createWorkflowCtx())).rejects.toThrow(/Transcoding failed/);
736
+ await expect(awaitTranscript("t_1", createWorkflowContext())).rejects.toThrow(
737
+ /Transcoding failed/,
738
+ );
724
739
  expect(polls()).toBe(1);
725
740
  });
726
741
 
727
742
  test("gives up at the bound rather than polling a stuck job forever", async () => {
728
743
  stubStatuses([{ status: "processing" }]);
729
- await expect(awaitTranscript("t_1", createWorkflowCtx())).rejects.toThrow(/Gave up/);
744
+ await expect(awaitTranscript("t_1", createWorkflowContext())).rejects.toThrow(/Gave up/);
730
745
  });
731
746
 
732
747
  /** A job still `processing` for `polls` turns, then completed. */
@@ -745,7 +760,7 @@ describe("awaitTranscript — the polling port, and the callback over it", () =>
745
760
  // the constant promises is eight sleeps, so the earliest honest turn to say
746
761
  // it is the ninth. Here the job finishes on that ninth poll, two minutes in
747
762
  // and not a second over, so the caller is told nothing.
748
- const ctx = createWorkflowCtx();
763
+ const ctx = createWorkflowContext();
749
764
  stubSlowJob(8);
750
765
 
751
766
  await awaitTranscript("t_1", ctx);
@@ -760,7 +775,7 @@ describe("awaitTranscript — the polling port, and the callback over it", () =>
760
775
  // two minutes of waiting behind it — so the note goes out, and the eleven
761
776
  // polls after it say nothing more. A note per poll would be a caller told
762
777
  // the same sentence every fifteen seconds.
763
- const ctx = createWorkflowCtx();
778
+ const ctx = createWorkflowContext();
764
779
  stubSlowJob(20);
765
780
 
766
781
  await awaitTranscript("t_1", ctx);
@@ -775,7 +790,7 @@ describe("awaitTranscript — the polling port, and the callback over it", () =>
775
790
  // cases are the REGRESSION guard for this section: the poll-only arm has to
776
791
  // keep behaving exactly as it did before there was a callback at all.
777
792
  //
778
- // `createWorkflowCtx` is the only tier that can drive the answered branch:
793
+ // `createWorkflowContext` is the only tier that can drive the answered branch:
779
794
  // its `waitFor` reads `hooks` by token, so supplying a payload IS the delivery
780
795
  // landing and omitting one IS the window closing. The eval tier cannot —
781
796
  // nothing there can signal — so it only ever sees the fallback, which is
@@ -793,7 +808,7 @@ describe("awaitTranscript — the polling port, and the callback over it", () =>
793
808
  { status: "processing" },
794
809
  { status: "completed", text: "Done.", audio_duration: 60 },
795
810
  ]);
796
- const ctx = createWorkflowCtx({
811
+ const ctx = createWorkflowContext({
797
812
  hooks: { [NUDGE]: { transcript_id: "t_1", status: "completed" } },
798
813
  });
799
814
 
@@ -818,7 +833,7 @@ describe("awaitTranscript — the polling port, and the callback over it", () =>
818
833
  { status: "processing" },
819
834
  { status: "completed", text: "The real transcript.", audio_duration: 60 },
820
835
  ]);
821
- const ctx = createWorkflowCtx({
836
+ const ctx = createWorkflowContext({
822
837
  hooks: { [NUDGE]: { transcript_id: "t_1", status: "completed", text: "A LIE." } },
823
838
  });
824
839
 
@@ -837,7 +852,7 @@ describe("awaitTranscript — the polling port, and the callback over it", () =>
837
852
  // read as a failed run and answer by deleting the transcript. So the wait
838
853
  // may not be inside the loop, and this is what pins that: twenty turns, ONE
839
854
  // `waitFor`, and every other wait a plain sleep.
840
- const ctx = createWorkflowCtx({ hooks: { [NUDGE]: {} } });
855
+ const ctx = createWorkflowContext({ hooks: { [NUDGE]: {} } });
841
856
  stubSlowJob(20);
842
857
 
843
858
  await awaitTranscript("t_1", ctx, NUDGE);
@@ -856,7 +871,7 @@ describe("awaitTranscript — the polling port, and the callback over it", () =>
856
871
  { status: "processing" },
857
872
  { status: "completed", text: "Done.", audio_duration: 60 },
858
873
  ]);
859
- const ctx = createWorkflowCtx();
874
+ const ctx = createWorkflowContext();
860
875
 
861
876
  const state = await awaitTranscript("t_1", ctx, NUDGE);
862
877
 
@@ -872,7 +887,7 @@ describe("awaitTranscript — the polling port, and the callback over it", () =>
872
887
  // window — so whichever arm a run is on, a caller hears the sentence at the
873
888
  // same point. The note goes out at the TOP of a poll, so attempt 2 is the
874
889
  // first turn with a whole closed window behind it.
875
- const ctx = createWorkflowCtx();
890
+ const ctx = createWorkflowContext();
876
891
  stubSlowJob(20);
877
892
 
878
893
  await awaitTranscript("t_1", ctx, NUDGE);
@@ -891,7 +906,7 @@ describe("awaitTranscript — the polling port, and the callback over it", () =>
891
906
  // The mirror of the case above, and what stops the note being a thing every
892
907
  // callback run says: the delivery lands, attempt 2 finds the job done, and
893
908
  // nobody is told a recording is a long one.
894
- const ctx = createWorkflowCtx({ hooks: { [NUDGE]: {} } });
909
+ const ctx = createWorkflowContext({ hooks: { [NUDGE]: {} } });
895
910
  stubSlowJob(1);
896
911
 
897
912
  await awaitTranscript("t_1", ctx, NUDGE);
@@ -917,7 +932,9 @@ describe("askWhetherToKeep — the expense port", () => {
917
932
  * What is pinned is unchanged: the three outcomes and the safe default.
918
933
  */
919
934
  const gateCtx = (answer?: { keep: boolean }) =>
920
- createWorkflowCtx(answer === undefined ? {} : { hooks: { [retentionToken("s_1")]: answer } });
935
+ createWorkflowContext(
936
+ answer === undefined ? {} : { hooks: { [retentionToken("s_1")]: answer } },
937
+ );
921
938
 
922
939
  test("keeps the transcript when the caller says to, and deletes nothing", async () => {
923
940
  const provider = installStubStepFetch();
@@ -989,7 +1006,7 @@ describe("compensate — the saga port", () => {
989
1006
  { label: "first", undo: async () => void order.push("first") },
990
1007
  ],
991
1008
  "because",
992
- createWorkflowCtx(),
1009
+ createWorkflowContext(),
993
1010
  );
994
1011
  expect(order).toEqual(["second", "first"]);
995
1012
  });
@@ -1011,7 +1028,7 @@ describe("compensate — the saga port", () => {
1011
1028
  { label: "fine", undo: async () => void order.push("fine") },
1012
1029
  ],
1013
1030
  "because",
1014
- createWorkflowCtx(),
1031
+ createWorkflowContext(),
1015
1032
  ),
1016
1033
  ).resolves.toBeUndefined();
1017
1034
  expect(order).toEqual(["fine"]);
@@ -1022,7 +1039,7 @@ describe("compensate — the saga port", () => {
1022
1039
  // run that narrated an unwind it did not perform would be lying to the
1023
1040
  // caller reading its progress.
1024
1041
  await expect(
1025
- compensate([], "nothing was acquired", createWorkflowCtx()),
1042
+ compensate([], "nothing was acquired", createWorkflowContext()),
1026
1043
  ).resolves.toBeUndefined();
1027
1044
  });
1028
1045
  });