@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.
- package/bin.mjs +1 -1
- package/dist/{_agent-BTwZJu4P.mjs → _agent-BzUeqOdj.mjs} +33 -10
- package/dist/_agent.d.ts +23 -0
- package/dist/{_bundler-C31rlcYz.mjs → _bundler-DolUCMxu.mjs} +9 -8
- package/dist/_cli-common.d.ts +45 -6
- package/dist/{_config-CmJOFsAP.mjs → _config-DYzC6WMD.mjs} +7 -4
- package/dist/_config.d.ts +25 -1
- package/dist/_dev-env.d.ts +1 -1
- package/dist/{_dev-server-DWvkNoIS.mjs → _dev-server-CSMqF8PN.mjs} +28 -30
- package/dist/_dev-vite-config.d.ts +3 -3
- package/dist/_e2e-test-utils.d.ts +15 -7
- package/dist/{_init-BURbdHNM.mjs → _init-CQ8idAwo.mjs} +64 -30
- package/dist/{_output-CKkmWs7i.mjs → _output-DBP9Op_d.mjs} +43 -3
- package/dist/_output.d.ts +24 -7
- package/dist/{_preflight-Co1G9Jww.mjs → _preflight-CcAR577V.mjs} +3 -3
- package/dist/_resource-commands.d.ts +9 -9
- package/dist/{_server-common-6e0QI4mq.mjs → _server-common-vILJp3it.mjs} +3 -3
- package/dist/{_slug-api-CfGQq1Tt.mjs → _slug-api-CGvc2YSQ.mjs} +19 -10
- package/dist/_slug-api.d.ts +19 -0
- package/dist/{_studio-B1waXMuR.mjs → _studio-DnR_BqFp.mjs} +9 -5
- package/dist/_studio-commands.d.ts +13 -13
- package/dist/{_templates-0Zr2z6yA.mjs → _templates-CK4oKoeX.mjs} +3 -3
- package/dist/{_typecheck-gate-BXvUNnfs.mjs → _typecheck-gate-U3jcil8L.mjs} +5 -4
- package/dist/_typecheck-gate.d.ts +3 -1
- package/dist/{_ui-u7T4YooX.mjs → _ui-DCt4qZrk.mjs} +1 -1
- package/dist/{_utils-B8QmtFhK.mjs → _utils-D5JGcjiW.mjs} +44 -2
- package/dist/_utils.d.ts +35 -0
- package/dist/{_vite-env-D9Qj6KPX.mjs → _vite-env-kejF0p2L.mjs} +1 -1
- package/dist/{_workflow-determinism-f9J-5Zlj.mjs → _workflow-determinism-CvpqMpdh.mjs} +19 -27
- package/dist/_workflow-determinism.d.ts +3 -3
- package/dist/build-Mxk8gWvX.mjs +108 -0
- package/dist/build.d.ts +5 -0
- package/dist/cli.mjs +172 -116
- package/dist/{client-bundler-j5c46x0G.mjs → client-bundler-BJgREAh6.mjs} +4 -4
- package/dist/client-bundler.mjs +1 -1
- package/dist/{delete-X_CRgCf6.mjs → delete-BdFnbnVR.mjs} +6 -6
- package/dist/{deploy-BzZ-r4iM.mjs → deploy-uAJ4NukN.mjs} +14 -14
- package/dist/{dev-BDykFvcc.mjs → dev-DApPSaE_.mjs} +5 -5
- package/dist/{eval-0qEOdm0l.mjs → eval-BK47A_K5.mjs} +27 -11
- package/dist/eval.d.ts +2 -0
- package/dist/{init-BtJI306C.mjs → init-DukDxECd.mjs} +13 -21
- package/dist/{login-DeUETobb.mjs → login-DaLrPOFg.mjs} +6 -8
- package/dist/{logs-hWnJ66Bl.mjs → logs-7BRwq0cG.mjs} +22 -15
- package/dist/project-config.mjs +1 -1
- package/dist/scaffold/.env.example +17 -1
- package/dist/scaffold/CLAUDE.md +84 -56
- package/dist/scaffold/package.json +6 -5
- package/dist/scaffold/server.mjs +129 -25
- package/dist/secret-Cjp912yF.mjs +162 -0
- package/dist/secret.d.ts +48 -13
- package/dist/{studio-BxAS_FQQ.mjs → studio-CpHlNHUZ.mjs} +15 -17
- package/dist/templates/briefing-desk/agent.test.ts +7 -3
- package/dist/templates/call-audit/agent.test.ts +25 -9
- package/dist/templates/call-audit/agent.ts +2 -2
- package/dist/templates/call-audit/client.tsx +40 -58
- package/dist/templates/call-audit/workflows/audit.ts +7 -5
- package/dist/templates/call-audit/workflows/ingest.ts +7 -7
- package/dist/templates/call-audit/workflows/media.ts +12 -37
- package/dist/templates/call-audit/workflows/summarize.ts +8 -8
- package/dist/templates/call-audit/workflows/sync-api.ts +3 -3
- package/dist/templates/dispatch-center/agent.test.ts +7 -7
- package/dist/templates/dispatch-center/client.tsx +53 -61
- package/dist/templates/dispatch-center/shared.ts +0 -8
- package/dist/templates/dispatch-center/tools/ops_dashboard.ts +4 -1
- package/dist/templates/embedded-assets/agent.eval.test.ts +4 -2
- package/dist/templates/embedded-assets/agent.test.ts +7 -1
- package/dist/templates/health-assistant/agent.eval.test.ts +4 -2
- package/dist/templates/health-assistant/agent.test.ts +8 -4
- package/dist/templates/infocom-adventure/client.tsx +40 -36
- package/dist/templates/link-digest/agent.test.ts +10 -4
- package/dist/templates/link-digest/agent.ts +2 -2
- package/dist/templates/link-digest/client.tsx +42 -36
- package/dist/templates/link-digest/workflows/digest.ts +13 -16
- package/dist/templates/night-owl/agent.eval.test.ts +4 -2
- package/dist/templates/night-owl/agent.test.ts +6 -1
- package/dist/templates/night-owl/client.tsx +8 -2
- package/dist/templates/pipeline-simple/agent.test.ts +33 -16
- package/dist/templates/pizza-ordering/client.tsx +5 -4
- package/dist/templates/pizza-ordering/shared.ts +3 -8
- package/dist/templates/pizza-ordering/tools/add_pizza.ts +3 -2
- package/dist/templates/pizza-ordering/tools/place_order.ts +3 -2
- package/dist/templates/pizza-ordering/tools/remove_pizza.ts +3 -2
- package/dist/templates/pizza-ordering/tools/update_pizza.ts +3 -2
- package/dist/templates/pizza-ordering/tools/view_order.ts +3 -2
- package/dist/templates/plan-and-execute/agent.test.ts +10 -5
- package/dist/templates/plan-and-execute/client.tsx +2 -2
- package/dist/templates/podcast-digest/agent.test.ts +33 -14
- package/dist/templates/podcast-digest/client.tsx +27 -36
- package/dist/templates/podcast-digest/workflows/digest.ts +21 -19
- package/dist/templates/podcast-digest/workflows/feeds.ts +61 -33
- package/dist/templates/podcast-digest/workflows/slack.ts +7 -7
- package/dist/templates/recap-workflow/agent.eval.test.ts +21 -13
- package/dist/templates/recap-workflow/agent.test.ts +49 -32
- package/dist/templates/recap-workflow/workflows/recap.ts +24 -28
- package/dist/templates/redline/agent.eval.test.ts +5 -8
- package/dist/templates/redline/agent.test.ts +8 -2
- package/dist/templates/redline/client.tsx +47 -48
- package/dist/templates/redline/workflows/redline.ts +17 -17
- package/dist/templates/research-workflow/agent.eval.test.ts +5 -5
- package/dist/templates/research-workflow/agent.test.ts +17 -8
- package/dist/templates/research-workflow/workflows/research.ts +28 -30
- package/dist/templates/retail/agent.test.ts +17 -15
- package/dist/templates/retail/cancel.ts +7 -6
- package/dist/templates/retail/client.tsx +39 -58
- package/dist/templates/retail/payment.ts +4 -3
- package/dist/templates/retail/pending.test.ts +19 -19
- package/dist/templates/retail/registry.test.ts +99 -58
- package/dist/templates/retail/store.test.ts +5 -5
- package/dist/templates/retail/swap.ts +8 -11
- package/dist/templates/simple/agent.test.ts +39 -6
- package/dist/templates/solo-rpg/agent.eval.test.ts +2 -16
- package/dist/templates/solo-rpg/agent.test.ts +12 -12
- package/dist/templates/solo-rpg/client.tsx +3 -3
- package/dist/templates/spoken-summary/agent.test.ts +21 -12
- package/dist/templates/spoken-summary/agent.ts +3 -3
- package/dist/templates/spoken-summary/client.tsx +36 -47
- package/dist/templates/spoken-summary/workflows/summarize.ts +11 -11
- package/dist/templates/spoken-summary/workflows/transcribe.ts +14 -14
- package/dist/templates/support-line/client.tsx +4 -3
- package/dist/templates/support-line/prompts.ts +0 -2
- package/dist/templates/transcription-workflow/agent.test.ts +36 -20
- package/dist/templates/transcription-workflow/agent.ts +2 -2
- package/dist/templates/transcription-workflow/client.tsx +34 -40
- package/dist/templates/transcription-workflow/recover.ts +1 -1
- package/dist/templates/transcription-workflow/run-panel.tsx +34 -14
- package/dist/templates/transcription-workflow/total-latency.tsx +46 -20
- package/dist/templates/transcription-workflow/workflows/batch.ts +15 -15
- package/dist/templates/transcription-workflow/workflows/downsample.ts +1 -1
- package/dist/templates/transcription-workflow/workflows/normalize.ts +15 -19
- package/dist/templates/transcription-workflow/workflows/stream.ts +12 -12
- package/dist/templates/transcription-workflow/workflows/sync-api.ts +3 -3
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +23 -34
- package/dist/templates/travel-concierge/agent.eval.test.ts +2 -2
- package/dist/templates/travel-concierge/agent.test.ts +15 -7
- package/dist/templates/travel-concierge/client.tsx +2 -2
- package/dist/templates/travel-concierge/shared.ts +10 -14
- package/dist/templates/travel-concierge/tools/lookup_booking.ts +4 -3
- package/dist/templates/travel-concierge/tools/search_car_rentals.ts +3 -2
- package/dist/templates/travel-concierge/tools/search_excursions.ts +3 -2
- package/dist/templates/travel-concierge/tools/search_flights.ts +3 -2
- package/dist/templates/travel-concierge/tools/search_hotels.ts +3 -2
- package/dist/test-D032V2cA.mjs +242 -0
- package/dist/test.d.ts +97 -31
- package/dist/typecheck.mjs +2 -2
- package/dist/worker-bundler.mjs +3 -3
- package/dist/{workflow-BBu5oEDw.mjs → workflow-CVioyO3V.mjs} +8 -7
- package/package.json +10 -10
- package/dist/bin.d.mts +0 -2
- package/dist/build-BLlLOrng.mjs +0 -66
- package/dist/secret-B-3T5tvr.mjs +0 -61
- package/dist/test-CiLab-AA.mjs +0 -182
- 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 {
|
|
69
|
-
import { FatalError,
|
|
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
|
-
*
|
|
78
|
-
* resolving it already had to download one.
|
|
77
|
+
* How many pasted links are resolved at once.
|
|
79
78
|
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
|
171
|
-
const verified = await fetchText(discovered);
|
|
172
|
-
if (!
|
|
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
|
-
|
|
240
|
-
|
|
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
|
-
/**
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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,
|
|
472
|
+
function feedFrom(feedUrl: string, parsed: ParsedFeed): PodcastFeed {
|
|
445
473
|
return {
|
|
446
474
|
feedUrl,
|
|
447
|
-
title:
|
|
448
|
-
|
|
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
|
-
* `
|
|
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
|
|
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
|
-
* `
|
|
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 `
|
|
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 {
|
|
31
|
-
import {
|
|
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 `
|
|
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
|
|
56
|
-
return await
|
|
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
|
-
// `
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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 `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
* `
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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, `
|
|
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 =
|
|
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",
|
|
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",
|
|
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",
|
|
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 =
|
|
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 =
|
|
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
|
-
// `
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
1042
|
+
compensate([], "nothing was acquired", createWorkflowContext()),
|
|
1026
1043
|
).resolves.toBeUndefined();
|
|
1027
1044
|
});
|
|
1028
1045
|
});
|