@alexkroman1/aai-cli 7.0.0 → 8.1.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/README.md +1 -0
- package/dist/{_bundler-B4RqNF5Z.mjs → _bundler-CDuYl5Gb.mjs} +1 -1
- package/dist/{_dev-server-CiKFH9qw.mjs → _dev-server-Dt3TCjhj.mjs} +6 -2
- package/dist/{_init-CE4HKdgV.mjs → _init-BhEfQ6Yi.mjs} +1 -1
- package/dist/_resource-commands.d.ts +23 -0
- package/dist/{_slug-api-nRFaBEPJ.mjs → _slug-api-z-USxPtL.mjs} +14 -10
- package/dist/_slug-api.d.ts +5 -2
- package/dist/{_templates-FI3xPEMj.mjs → _templates-4WcKOjS5.mjs} +4 -5
- package/dist/_templates.d.ts +3 -4
- package/dist/{build-6jfaakgc.mjs → build-BBxf8vom.mjs} +2 -2
- package/dist/cli.mjs +338 -346
- package/dist/{deploy-DvYeD8ia.mjs → deploy-DRC8jWr8.mjs} +1 -1
- package/dist/{dev-B59JIfWO.mjs → dev-QuuOOUtv.mjs} +1 -1
- package/dist/eval-DfQ8cm5p.mjs +56 -0
- package/dist/eval.d.ts +43 -0
- package/dist/{init-CpJVjEg0.mjs → init-CUo87gcI.mjs} +2 -2
- package/dist/{logs-9q-psa4q.mjs → logs-CQ2T46dr.mjs} +1 -1
- package/dist/scaffold/CLAUDE.md +409 -68
- package/dist/scaffold/package.json +5 -4
- package/dist/scaffold/server.mjs +10 -3
- package/dist/scaffold/tsconfig.json +0 -1
- package/dist/{secret-CVvSLIDV.mjs → secret-Ct34kdgU.mjs} +1 -1
- package/dist/{storage-BvUrnvM3.mjs → storage-i_79ZwAR.mjs} +1 -1
- package/dist/{studio--MUV0cid.mjs → studio-CzTzPqe1.mjs} +1 -1
- package/dist/templates/briefing-desk/agent.test.ts +350 -0
- package/dist/templates/briefing-desk/agent.ts +30 -0
- package/dist/templates/briefing-desk/shared.ts +200 -0
- package/dist/templates/briefing-desk/system-prompt.md +39 -0
- package/dist/templates/briefing-desk/tools/briefing_so_far.ts +35 -0
- package/dist/templates/briefing-desk/tools/research_topic.ts +88 -0
- package/dist/templates/briefing-desk/tools/verify_claim.ts +77 -0
- package/dist/templates/call-audit/agent.eval.test.ts +474 -0
- package/dist/templates/code-interpreter/agent.eval.test.ts +165 -0
- package/dist/templates/dispatch-center/agent.eval.test.ts +349 -0
- package/dist/templates/embedded-assets/agent.eval.test.ts +138 -0
- package/dist/templates/health-assistant/agent.eval.test.ts +167 -0
- package/dist/templates/infocom-adventure/agent.eval.test.ts +158 -0
- package/dist/templates/infocom-adventure/agent.test.ts +49 -18
- package/dist/templates/infocom-adventure/agent.ts +21 -0
- package/dist/templates/infocom-adventure/shared.ts +20 -9
- package/dist/templates/infocom-adventure/system-prompt.md +1 -1
- package/dist/templates/infocom-adventure/tools/game_state_move.ts +3 -2
- package/dist/templates/link-digest/agent.eval.test.ts +195 -0
- package/dist/templates/math-buddy/agent.eval.test.ts +146 -0
- package/dist/templates/night-owl/agent.eval.test.ts +272 -0
- package/dist/templates/personal-finance/agent.eval.test.ts +168 -0
- package/dist/templates/personal-finance/system-prompt.md +4 -0
- package/dist/templates/pipeline-simple/agent.eval.test.ts +78 -0
- package/dist/templates/pipeline-simple/agent.ts +2 -2
- package/dist/templates/pizza-ordering/agent.eval.test.ts +171 -0
- package/dist/templates/pizza-ordering/agent.test.ts +15 -0
- package/dist/templates/pizza-ordering/shared.ts +20 -1
- package/dist/templates/pizza-ordering/system-prompt.md +6 -0
- package/dist/templates/plan-and-execute/agent.eval.test.ts +209 -0
- package/dist/templates/podcast-digest/agent.eval.test.ts +411 -0
- package/dist/templates/podcast-digest/agent.test.ts +30 -59
- package/dist/templates/podcast-digest/agent.ts +1 -1
- package/dist/templates/podcast-digest/workflows/digest.ts +52 -19
- package/dist/templates/podcast-digest/workflows/slack.ts +48 -169
- package/dist/templates/recap-workflow/agent.eval.test.ts +366 -0
- package/dist/templates/redline/agent.eval.test.ts +209 -0
- package/dist/templates/research-workflow/agent.eval.test.ts +329 -0
- package/dist/templates/retail/address.ts +87 -1
- package/dist/templates/retail/agent.eval.test.ts +314 -0
- package/dist/templates/retail/agent.test.ts +171 -91
- package/dist/templates/retail/agent.ts +35 -1
- package/dist/templates/retail/cancel.ts +116 -0
- package/dist/templates/retail/client.tsx +39 -0
- package/dist/templates/retail/payment.ts +107 -0
- package/dist/templates/retail/pending.test.ts +196 -0
- package/dist/templates/retail/pending.ts +137 -0
- package/dist/templates/retail/registry.test.ts +119 -8
- package/dist/templates/retail/returns.ts +102 -0
- package/dist/templates/retail/shared.test.ts +42 -0
- package/dist/templates/retail/shared.ts +35 -0
- package/dist/templates/retail/store.test.ts +6 -4
- package/dist/templates/retail/store.ts +113 -57
- package/dist/templates/retail/swap.test.ts +4 -4
- package/dist/templates/retail/swap.ts +248 -15
- package/dist/templates/retail/system-prompt.md +62 -30
- package/dist/templates/retail/tools/cancel_change.ts +33 -0
- package/dist/templates/retail/tools/cancel_pending_order.ts +15 -59
- package/dist/templates/retail/tools/confirm_change.ts +42 -0
- package/dist/templates/retail/tools/exchange_delivered_order_items.ts +21 -66
- package/dist/templates/retail/tools/modify_pending_order_address.ts +12 -24
- package/dist/templates/retail/tools/modify_pending_order_items.ts +22 -60
- package/dist/templates/retail/tools/modify_pending_order_payment.ts +15 -77
- package/dist/templates/retail/tools/modify_user_address.ts +12 -15
- package/dist/templates/retail/tools/return_delivered_order_items.ts +16 -71
- package/dist/templates/retail/tools/transfer_to_human_agents.ts +4 -2
- package/dist/templates/simple/agent.eval.test.ts +54 -0
- package/dist/templates/solo-rpg/agent.eval.test.ts +320 -0
- package/dist/templates/spoken-summary/agent.eval.test.ts +342 -0
- package/dist/templates/support-line/agent.eval.test.ts +273 -0
- package/dist/templates/support-line/system-prompt.md +2 -1
- package/dist/templates/support-line/tools/answer_question.ts +56 -7
- package/dist/templates/transcription-workflow/agent.eval.test.ts +234 -0
- package/dist/templates/travel-concierge/agent.eval.test.ts +323 -0
- package/dist/templates/travel-concierge/agent.test.ts +110 -10
- package/dist/templates/travel-concierge/agent.ts +18 -9
- package/dist/templates/travel-concierge/shared.ts +110 -9
- package/dist/templates/travel-concierge/system-prompt.md +7 -1
- package/dist/templates/travel-concierge/tools/book_car_rental.ts +6 -3
- package/dist/templates/travel-concierge/tools/book_excursion.ts +6 -3
- package/dist/templates/travel-concierge/tools/book_hotel.ts +6 -3
- package/dist/templates/travel-concierge/tools/cancel_ticket.ts +6 -3
- package/dist/templates/travel-concierge/tools/search_car_rentals.ts +9 -5
- package/dist/templates/travel-concierge/tools/search_excursions.ts +9 -5
- package/dist/templates/travel-concierge/tools/search_flights.ts +9 -6
- package/dist/templates/travel-concierge/tools/search_hotels.ts +9 -5
- package/dist/templates/travel-concierge/tools/update_ticket.ts +6 -3
- package/dist/templates/web-researcher/agent.eval.test.ts +121 -0
- package/dist/templates/web-researcher/system-prompt.md +15 -4
- package/dist/{test-9kPKJI-w.mjs → test-BvyT6sAc.mjs} +24 -10
- package/dist/test.d.ts +29 -5
- package/dist/{worker-bundler-COxnqstQ.mjs → worker-bundler-CCVEDjm1.mjs} +45 -25
- package/dist/worker-bundler.mjs +1 -1
- package/dist/{workflow-D2AQf2Pl.mjs → workflow-BflATMmU.mjs} +19 -11
- package/package.json +5 -5
- package/dist/eject-C9WJyyr2.mjs +0 -116
- package/dist/eject.d.ts +0 -60
- package/dist/templates/infocom-adventure/tools/game_state_history.ts +0 -14
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
// Copyright 2026 the AAI authors. MIT license.
|
|
2
|
+
// An EVAL for a WORKFLOW APP: does the run actually do the work? Run it with
|
|
3
|
+
// `aai eval`.
|
|
4
|
+
//
|
|
5
|
+
// `agent.test.ts` asserts about the declaration, drives each step on its own,
|
|
6
|
+
// and covers the feed scraping and the Slack rendering as pure functions. This
|
|
7
|
+
// drives the WHOLE BODY — `dailyDigestFlow` from the top — and what it is here
|
|
8
|
+
// to check is the one thing no per-step spec can see: that **the run is the
|
|
9
|
+
// schedule**. There is no cron in this template. A durable `sleep()` between
|
|
10
|
+
// digests IS the scheduler, so "does it post three digests two hours apart"
|
|
11
|
+
// is a question about the BODY's loop and about nothing else.
|
|
12
|
+
//
|
|
13
|
+
// EVERY CASE HERE IS SCRIPTED IN BOTH MODES, which is unusual and deliberate.
|
|
14
|
+
// Two reasons, and neither is convenience:
|
|
15
|
+
//
|
|
16
|
+
// * **Every leg of this run is HTTP through one slot.** The feeds, the two
|
|
17
|
+
// transcription calls, the model and the Slack post all go through
|
|
18
|
+
// `stepFetch`, and publishing a fake REPLACES — so there is no arrangement
|
|
19
|
+
// in which some legs are real and the Slack post is not. A live run would
|
|
20
|
+
// have to POST a summarized digest to a real Slack workspace, which a
|
|
21
|
+
// template eval may not do.
|
|
22
|
+
// * **The claims are arithmetic.** How many sleeps a run of three digests
|
|
23
|
+
// asks for, which episode was polled how many times, what a digest says
|
|
24
|
+
// about an episode nobody could transcribe — a live provider can neither
|
|
25
|
+
// confirm nor deny any of them. It can only be asked and then have its
|
|
26
|
+
// answer accepted, which is not evidence.
|
|
27
|
+
//
|
|
28
|
+
// So a LIVE run of this file makes no provider call and costs nothing. That is
|
|
29
|
+
// the honest report rather than a gap being papered over: what a live run would
|
|
30
|
+
// add here is a real transcript, and `spoken-summary` and `call-audit` both
|
|
31
|
+
// measure that against a real recording already.
|
|
32
|
+
//
|
|
33
|
+
// WHAT NO EVAL HERE COVERS: durability — which for THIS template is most of
|
|
34
|
+
// what it is for. Imported through vitest with no bundler in the path, a
|
|
35
|
+
// `"use workflow"` body is an ordinary async function, so the multi-day
|
|
36
|
+
// suspension that makes a digest arrive tomorrow is not exercised; the sleep is
|
|
37
|
+
// RECORDED and skipped. `run.slept` below is that admission written as an
|
|
38
|
+
// assertion, and it is the only way to check a seven-day schedule without
|
|
39
|
+
// waiting a week. `aai-cli`'s `dev-workflow.scenario.test.ts` is the tier that
|
|
40
|
+
// really suspends and resumes a run.
|
|
41
|
+
import { TRANSCRIBE_API } from "@alexkroman1/aai/step";
|
|
42
|
+
import { installStubStepFetch } from "@alexkroman1/aai/testing/vitest";
|
|
43
|
+
import { describeWorkflowEval } from "@alexkroman1/aai-runtime/eval/vitest";
|
|
44
|
+
import { expect } from "vitest";
|
|
45
|
+
import agentDef, { dailyDigest } from "./agent.ts";
|
|
46
|
+
import { MAX_POLL_ATTEMPTS, POLL_DELAY, scheduleIntervalMs } from "./workflows/digest.ts";
|
|
47
|
+
|
|
48
|
+
/** The feed every case reads. Not a real host — nothing here leaves the process. */
|
|
49
|
+
const FEED_URL = "https://feeds.example.test/rebuild.xml";
|
|
50
|
+
|
|
51
|
+
/** A classic incoming webhook, which takes Block Kit. */
|
|
52
|
+
const SLACK_WEBHOOK = "https://hooks.slack.com/services/T00000000/B00000000/eval";
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The three episodes, newest first — and each transcript carries a SENTINEL
|
|
56
|
+
* word nothing else in the run produces.
|
|
57
|
+
*
|
|
58
|
+
* That is what makes "the right transcript reached the right episode" an
|
|
59
|
+
* assertion rather than a hope: the scripted model echoes whichever sentinel it
|
|
60
|
+
* was shown, so a batch that crossed two episodes' transcripts shows up as the
|
|
61
|
+
* wrong word in the wrong digest entry. With N jobs in flight finishing out of
|
|
62
|
+
* order, that is exactly the mistake worth catching.
|
|
63
|
+
*/
|
|
64
|
+
const EPISODES = [
|
|
65
|
+
{
|
|
66
|
+
title: "The migration nobody owns",
|
|
67
|
+
audioUrl: "https://cdn.example.test/rebuild-13.mp3",
|
|
68
|
+
published: "Wed, 13 Aug 2026 09:00:00 GMT",
|
|
69
|
+
sentinel: "quokka",
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
title: "Cutting the release train in half",
|
|
73
|
+
audioUrl: "https://cdn.example.test/rebuild-12.mp3",
|
|
74
|
+
published: "Tue, 12 Aug 2026 09:00:00 GMT",
|
|
75
|
+
sentinel: "narwhal",
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
title: "Hiring for the platform team",
|
|
79
|
+
audioUrl: "https://cdn.example.test/rebuild-11.mp3",
|
|
80
|
+
published: "Mon, 11 Aug 2026 09:00:00 GMT",
|
|
81
|
+
sentinel: "pangolin",
|
|
82
|
+
},
|
|
83
|
+
] as const;
|
|
84
|
+
|
|
85
|
+
/** A podcast RSS document: an `<rss>` root and an `<enclosure url=>` per item. */
|
|
86
|
+
const FEED_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
|
87
|
+
<rss version="2.0"><channel>
|
|
88
|
+
<title>The Rebuild</title>
|
|
89
|
+
${EPISODES.map(
|
|
90
|
+
(episode) => `<item>
|
|
91
|
+
<title>${episode.title}</title>
|
|
92
|
+
<link>https://example.test/${episode.sentinel}</link>
|
|
93
|
+
<guid isPermaLink="false">${episode.sentinel}</guid>
|
|
94
|
+
<pubDate>${episode.published}</pubDate>
|
|
95
|
+
<enclosure url="${episode.audioUrl}" type="audio/mpeg" length="1234"/>
|
|
96
|
+
</item>`,
|
|
97
|
+
).join("\n")}
|
|
98
|
+
</channel></rss>`;
|
|
99
|
+
|
|
100
|
+
/** How one episode's transcription behaves. */
|
|
101
|
+
type EpisodeScript = {
|
|
102
|
+
/** Polls that answer "still working" before this job completes. Defaults to 0. */
|
|
103
|
+
pendingPolls?: number;
|
|
104
|
+
/** Refuse the SUBMIT with this status instead. A 4xx is terminal. */
|
|
105
|
+
submitStatus?: number;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Answer the whole world in memory: the feed, the two transcription calls, the
|
|
110
|
+
* model, and Slack.
|
|
111
|
+
*
|
|
112
|
+
* ONE handler, because publishing a `stepFetch` REPLACES — and this run has
|
|
113
|
+
* four different far sides, so routing by URL is the only shape available.
|
|
114
|
+
*
|
|
115
|
+
* **The transcription legs are hand-routed here rather than handed to
|
|
116
|
+
* `installStubTranscribe`, and that is a gap rather than a preference.** That
|
|
117
|
+
* fake counts `pendingPolls` GLOBALLY and stages a `failure` per LEG, so
|
|
118
|
+
* neither "these two episodes finish on different rounds" nor "this ONE episode
|
|
119
|
+
* is broken" is expressible through it — and both are this template's subject,
|
|
120
|
+
* it being the only one that has a BATCH of jobs in flight. The URLs are built
|
|
121
|
+
* from the SDK's own `TRANSCRIBE_API` constant, so a case still cannot pass
|
|
122
|
+
* because the fake and the step agree on a typo.
|
|
123
|
+
*/
|
|
124
|
+
function scriptWorld(
|
|
125
|
+
scripts: Readonly<Record<string, EpisodeScript>> = {},
|
|
126
|
+
slack: { status?: number; body?: unknown } = {},
|
|
127
|
+
) {
|
|
128
|
+
/** Minted job id → the audio URL it was submitted for. */
|
|
129
|
+
const jobs = new Map<string, string>();
|
|
130
|
+
/** Job id → polls answered so far. */
|
|
131
|
+
const polls = new Map<string, number>();
|
|
132
|
+
let minted = 0;
|
|
133
|
+
|
|
134
|
+
return installStubStepFetch((request) => {
|
|
135
|
+
if (request.url === FEED_URL) return { body: FEED_XML };
|
|
136
|
+
|
|
137
|
+
if (request.url === `${TRANSCRIBE_API}/v2/transcript` && request.method === "POST") {
|
|
138
|
+
const audioUrl = String(
|
|
139
|
+
(JSON.parse(String(request.body ?? "{}")) as { audio_url?: string }).audio_url,
|
|
140
|
+
);
|
|
141
|
+
const script = scripts[audioUrl] ?? {};
|
|
142
|
+
if (script.submitStatus !== undefined) {
|
|
143
|
+
return { status: script.submitStatus, body: { error: `no audio at ${audioUrl}` } };
|
|
144
|
+
}
|
|
145
|
+
minted += 1;
|
|
146
|
+
const id = `job_${minted}`;
|
|
147
|
+
jobs.set(id, audioUrl);
|
|
148
|
+
return { body: { id } };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (request.url.startsWith(`${TRANSCRIBE_API}/v2/transcript/`)) {
|
|
152
|
+
const id = request.url.slice(request.url.lastIndexOf("/") + 1);
|
|
153
|
+
const seen = polls.get(id) ?? 0;
|
|
154
|
+
polls.set(id, seen + 1);
|
|
155
|
+
const audioUrl = jobs.get(id) ?? "";
|
|
156
|
+
if (seen < (scripts[audioUrl]?.pendingPolls ?? 0)) return { body: { status: "processing" } };
|
|
157
|
+
const episode = EPISODES.find((one) => one.audioUrl === audioUrl);
|
|
158
|
+
return {
|
|
159
|
+
body: {
|
|
160
|
+
status: "completed",
|
|
161
|
+
text: `The hosts spent the whole hour on the ${episode?.sentinel ?? "unknown"}.`,
|
|
162
|
+
audio_duration: 1800,
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (request.url.includes("/chat/completions")) {
|
|
168
|
+
// The reply ECHOES whichever sentinel the prompt carried, which is what
|
|
169
|
+
// makes the transcript→episode correlation assertable end to end.
|
|
170
|
+
const prompt = String(request.body ?? "");
|
|
171
|
+
const seen = EPISODES.find((one) => prompt.includes(one.sentinel))?.sentinel ?? "nothing";
|
|
172
|
+
return {
|
|
173
|
+
body: {
|
|
174
|
+
choices: [
|
|
175
|
+
{
|
|
176
|
+
message: {
|
|
177
|
+
content: JSON.stringify({
|
|
178
|
+
summary: `An hour about the ${seen}.`,
|
|
179
|
+
keyPoints: [`The ${seen} is the decision`, "Nobody owns the follow-up"],
|
|
180
|
+
}),
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
],
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (request.url.startsWith("https://hooks.slack.com/")) {
|
|
189
|
+
return { status: slack.status ?? 200, body: slack.body ?? "ok" };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return { status: 404, body: { error: `no route for ${request.method} ${request.url}` } };
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The bodies of every Slack post the run made, parsed. */
|
|
197
|
+
function slackPosts(world: ReturnType<typeof scriptWorld>): Record<string, unknown>[] {
|
|
198
|
+
return world.calls
|
|
199
|
+
.filter((call) => call.url.startsWith("https://hooks.slack.com/"))
|
|
200
|
+
.map((call) => JSON.parse(String(call.body ?? "{}")) as Record<string, unknown>);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** How many times the job for `audioUrl` was polled. */
|
|
204
|
+
function pollsFor(world: ReturnType<typeof scriptWorld>, audioUrl: string): number {
|
|
205
|
+
const submitted = world.calls.filter(
|
|
206
|
+
(call) => call.url === `${TRANSCRIBE_API}/v2/transcript` && call.method === "POST",
|
|
207
|
+
);
|
|
208
|
+
const at = submitted.findIndex((call) => String(call.body ?? "").includes(audioUrl));
|
|
209
|
+
if (at < 0) return 0;
|
|
210
|
+
return world.calls.filter((call) => call.url.endsWith(`/v2/transcript/job_${at + 1}`)).length;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The whole input, with only the fields a case cares about spelled out. */
|
|
214
|
+
function input(overrides: Partial<Parameters<typeof dailyDigest.run>[0]> = {}) {
|
|
215
|
+
return {
|
|
216
|
+
podcastChannels: FEED_URL,
|
|
217
|
+
slackWebhookUrl: SLACK_WEBHOOK,
|
|
218
|
+
slackWorkflowTextParam: "text",
|
|
219
|
+
maxEpisodesPerDigest: 2,
|
|
220
|
+
intervalEvery: 1,
|
|
221
|
+
intervalUnit: "days" as const,
|
|
222
|
+
daysToRun: 1,
|
|
223
|
+
...overrides,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
describeWorkflowEval(agentDef, (test) => {
|
|
228
|
+
test("the RUN is the schedule: N digests, N-1 recorded sleeps, no cron", async ({ app }) => {
|
|
229
|
+
// The case this template exists for. Three digests two hours apart is
|
|
230
|
+
// ordinarily six hours of wall clock; here the durable waits are RECORDED
|
|
231
|
+
// rather than taken, which is the only way to assert a schedule at all.
|
|
232
|
+
const world = scriptWorld();
|
|
233
|
+
|
|
234
|
+
const run = await app.run(
|
|
235
|
+
dailyDigest,
|
|
236
|
+
input({ daysToRun: 3, intervalEvery: 2, intervalUnit: "hours" }),
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
// The error FIRST, so a failed run names its own reason instead of
|
|
240
|
+
// reporting "expected 'failed' to be 'completed'".
|
|
241
|
+
expect(run.error).toBeUndefined();
|
|
242
|
+
expect(run.status).toBe("completed");
|
|
243
|
+
const output = run.output;
|
|
244
|
+
if (output === undefined) expect.fail("a completed run must carry an output");
|
|
245
|
+
|
|
246
|
+
// THE INVARIANT: one sleep BETWEEN digests and none after the last, because
|
|
247
|
+
// a run that has delivered everything it owes should end rather than sleep
|
|
248
|
+
// for two hours and then end.
|
|
249
|
+
const interval = scheduleIntervalMs(2, "hours");
|
|
250
|
+
expect(run.slept).toEqual([{ duration: interval }, { duration: interval }]);
|
|
251
|
+
expect(run.slept).toHaveLength(output.digestsScheduled - 1);
|
|
252
|
+
// And no poll waits are mixed in: every job finished on its first poll, so
|
|
253
|
+
// every recorded sleep above is a SCHEDULE sleep.
|
|
254
|
+
expect(run.slept.every((one) => one.duration === interval)).toBe(true);
|
|
255
|
+
|
|
256
|
+
expect(output.digestsScheduled).toBe(3);
|
|
257
|
+
expect(output.digestsSent).toBe(3);
|
|
258
|
+
expect(output.scheduleInterval).toBe("2 hours");
|
|
259
|
+
expect(output.deliveryTarget).toBe("Slack webhook");
|
|
260
|
+
|
|
261
|
+
// Three posts, numbered, each a Block Kit payload with the notification
|
|
262
|
+
// line an incoming webhook needs.
|
|
263
|
+
const posts = slackPosts(world);
|
|
264
|
+
expect(posts).toHaveLength(3);
|
|
265
|
+
expect(posts.map((post) => post.text)).toEqual([
|
|
266
|
+
"Podcast digest 1/3: 2 episode summaries",
|
|
267
|
+
"Podcast digest 2/3: 2 episode summaries",
|
|
268
|
+
"Podcast digest 3/3: 2 episode summaries",
|
|
269
|
+
]);
|
|
270
|
+
for (const post of posts) expect(Array.isArray(post.blocks)).toBe(true);
|
|
271
|
+
|
|
272
|
+
// The last digest is what the page renders, and its clock came from a STEP
|
|
273
|
+
// — a `new Date()` in the body would answer differently on every replay.
|
|
274
|
+
expect(output.lastDigest?.slackStatus).toBe("ok");
|
|
275
|
+
expect(Number.isFinite(Date.parse(output.lastDigest?.sentAt ?? ""))).toBe(true);
|
|
276
|
+
// Two episodes, newest first, and each carrying ITS OWN transcript.
|
|
277
|
+
expect(output.lastDigest?.episodes.map((one) => one.title)).toEqual([
|
|
278
|
+
EPISODES[0].title,
|
|
279
|
+
EPISODES[1].title,
|
|
280
|
+
]);
|
|
281
|
+
expect(output.lastDigest?.episodes[0]?.summary).toContain(EPISODES[0].sentinel);
|
|
282
|
+
expect(output.lastDigest?.episodes[1]?.summary).toContain(EPISODES[1].sentinel);
|
|
283
|
+
|
|
284
|
+
// The feed is re-read once per digest, which is what makes a repeating run
|
|
285
|
+
// pick up what is NEW rather than re-posting yesterday's list.
|
|
286
|
+
expect(world.calls.filter((call) => call.url === FEED_URL)).toHaveLength(3);
|
|
287
|
+
expect(run.reported.filter((line) => line === "Finding recent podcast episodes.")).toHaveLength(
|
|
288
|
+
3,
|
|
289
|
+
);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("a finished episode is never polled again, so the slow one holds nothing up", async ({
|
|
293
|
+
app,
|
|
294
|
+
}) => {
|
|
295
|
+
// The batch poll loop, which is this template's one genuinely new mechanism:
|
|
296
|
+
// N episodes in flight finish out of ORDER, so `pending` has to SHRINK. A
|
|
297
|
+
// loop that waited for all N every round would poll the finished episode
|
|
298
|
+
// four times too — cheap here and, with `maxEpisodesPerDigest` up to 20 and
|
|
299
|
+
// a twenty-second wait per round, the difference between a digest arriving
|
|
300
|
+
// and a digest timing out.
|
|
301
|
+
//
|
|
302
|
+
// The NEWEST episode is the slow one deliberately: it makes completion
|
|
303
|
+
// order and publication order disagree, which is the other half of what
|
|
304
|
+
// this case pins.
|
|
305
|
+
const world = scriptWorld({
|
|
306
|
+
[EPISODES[0].audioUrl]: { pendingPolls: 3 },
|
|
307
|
+
[EPISODES[1].audioUrl]: { pendingPolls: 0 },
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const run = await app.run(dailyDigest, input());
|
|
311
|
+
|
|
312
|
+
expect(run.error).toBeUndefined();
|
|
313
|
+
const output = run.output;
|
|
314
|
+
if (output === undefined) expect.fail("a completed run must carry an output");
|
|
315
|
+
|
|
316
|
+
// The fast episode was polled ONCE and then dropped out of `pending`; the
|
|
317
|
+
// slow one took four rounds. Five polls, not eight.
|
|
318
|
+
expect(pollsFor(world, EPISODES[1].audioUrl)).toBe(1);
|
|
319
|
+
expect(pollsFor(world, EPISODES[0].audioUrl)).toBe(4);
|
|
320
|
+
expect(world.calls.filter((call) => call.url.includes("/v2/transcript/"))).toHaveLength(5);
|
|
321
|
+
// Three waits for four rounds — asked for, and recorded rather than taken.
|
|
322
|
+
expect(run.slept).toEqual([
|
|
323
|
+
{ duration: POLL_DELAY },
|
|
324
|
+
{ duration: POLL_DELAY },
|
|
325
|
+
{ duration: POLL_DELAY },
|
|
326
|
+
]);
|
|
327
|
+
|
|
328
|
+
// And the digest is in PUBLICATION order, not completion order. The feed is
|
|
329
|
+
// sorted newest-first for a reason, and a reader should not be able to tell
|
|
330
|
+
// which episode the provider happened to finish first.
|
|
331
|
+
expect(output.lastDigest?.episodes.map((one) => one.title)).toEqual([
|
|
332
|
+
EPISODES[0].title,
|
|
333
|
+
EPISODES[1].title,
|
|
334
|
+
]);
|
|
335
|
+
// Each entry still carries its own transcript, which is the assertion that
|
|
336
|
+
// a batch crossing two episodes' results would fail.
|
|
337
|
+
expect(output.lastDigest?.episodes[0]?.summary).toContain(EPISODES[0].sentinel);
|
|
338
|
+
expect(output.lastDigest?.episodes[1]?.summary).toContain(EPISODES[1].sentinel);
|
|
339
|
+
expect(output.lastDigest?.episodes.every((one) => one.transcriptSource === "assemblyai")).toBe(
|
|
340
|
+
true,
|
|
341
|
+
);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("one broken episode does not sink the digest, and the message says why", async ({ app }) => {
|
|
345
|
+
// The partial-failure policy, driven end to end: a 400 on ONE episode's
|
|
346
|
+
// submit is terminal — the same URL answers the same way on the fourth
|
|
347
|
+
// attempt — so it becomes an `unavailable` VALUE rather than a throw. The
|
|
348
|
+
// digest of the other episode still goes out.
|
|
349
|
+
const world = scriptWorld({ [EPISODES[0].audioUrl]: { submitStatus: 400 } });
|
|
350
|
+
|
|
351
|
+
const run = await app.run(dailyDigest, input());
|
|
352
|
+
|
|
353
|
+
expect(run.error).toBeUndefined();
|
|
354
|
+
expect(run.status).toBe("completed");
|
|
355
|
+
const episodes = run.output?.lastDigest?.episodes ?? [];
|
|
356
|
+
expect(episodes).toHaveLength(2);
|
|
357
|
+
|
|
358
|
+
// The broken one is an ENTRY with a stated reason, not a gap. Four
|
|
359
|
+
// summaries and silence looks like the feed simply had four episodes.
|
|
360
|
+
const broken = episodes[0];
|
|
361
|
+
expect(broken?.title).toBe(EPISODES[0].title);
|
|
362
|
+
expect(broken?.transcriptSource).toBe("unavailable");
|
|
363
|
+
expect(broken?.summary).toContain("could not be transcribed");
|
|
364
|
+
expect(broken?.summary).toContain(EPISODES[0].audioUrl);
|
|
365
|
+
expect(broken?.keyPoints).toEqual(["No transcript was available to summarize."]);
|
|
366
|
+
|
|
367
|
+
// The good one is untouched.
|
|
368
|
+
expect(episodes[1]?.transcriptSource).toBe("assemblyai");
|
|
369
|
+
expect(episodes[1]?.summary).toContain(EPISODES[1].sentinel);
|
|
370
|
+
|
|
371
|
+
// The model was asked about ONE episode, not two — a broken episode costs
|
|
372
|
+
// nothing beyond the submit that refused it.
|
|
373
|
+
expect(world.calls.filter((call) => call.url.includes("/chat/completions"))).toHaveLength(1);
|
|
374
|
+
// And the reason reaches SLACK, which is the only place a reader will see
|
|
375
|
+
// it. A digest that hid the failure is the outcome this refuses.
|
|
376
|
+
expect(JSON.stringify(slackPosts(world)[0])).toContain("could not be transcribed");
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("a job that never finishes degrades instead of replaying forever", async ({ app }) => {
|
|
380
|
+
// The poll budget is bounded, and running out of it is NOT an error: a
|
|
381
|
+
// partial digest beats none, and the reason names where the transcript
|
|
382
|
+
// still is. An unbounded loop is the failure this replaces — a run the
|
|
383
|
+
// platform would replay for as long as the provider stayed quiet.
|
|
384
|
+
const world = scriptWorld({
|
|
385
|
+
// Past the budget, so this job is never done.
|
|
386
|
+
[EPISODES[0].audioUrl]: { pendingPolls: MAX_POLL_ATTEMPTS + 1 },
|
|
387
|
+
[EPISODES[1].audioUrl]: { pendingPolls: 0 },
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
const run = await app.run(dailyDigest, input());
|
|
391
|
+
|
|
392
|
+
expect(run.error).toBeUndefined();
|
|
393
|
+
expect(run.status).toBe("completed");
|
|
394
|
+
const episodes = run.output?.lastDigest?.episodes ?? [];
|
|
395
|
+
|
|
396
|
+
const stuck = episodes[0];
|
|
397
|
+
expect(stuck?.transcriptSource).toBe("unavailable");
|
|
398
|
+
// It names the CHECK COUNT and where the transcript can still be read,
|
|
399
|
+
// which is the difference between "we gave up" and "we gave up, here it is".
|
|
400
|
+
expect(stuck?.summary).toContain(`after ${MAX_POLL_ATTEMPTS} checks`);
|
|
401
|
+
expect(stuck?.summary).toContain(`${TRANSCRIBE_API}/v2/transcript/`);
|
|
402
|
+
// The other episode still shipped.
|
|
403
|
+
expect(episodes[1]?.transcriptSource).toBe("assemblyai");
|
|
404
|
+
|
|
405
|
+
// The budget really bounded it: one round per attempt, and the fast episode
|
|
406
|
+
// dropped out after the first.
|
|
407
|
+
expect(pollsFor(world, EPISODES[0].audioUrl)).toBe(MAX_POLL_ATTEMPTS);
|
|
408
|
+
expect(pollsFor(world, EPISODES[1].audioUrl)).toBe(1);
|
|
409
|
+
expect(run.slept).toHaveLength(MAX_POLL_ATTEMPTS);
|
|
410
|
+
});
|
|
411
|
+
});
|
|
@@ -48,15 +48,7 @@ import {
|
|
|
48
48
|
stableEpisodeId,
|
|
49
49
|
titleMatchesSpotify,
|
|
50
50
|
} from "./workflows/feeds.ts";
|
|
51
|
-
import {
|
|
52
|
-
escapeSlack,
|
|
53
|
-
isSlackWebhookUrl,
|
|
54
|
-
isSlackWorkflowTriggerUrl,
|
|
55
|
-
renderPlainTextDigest,
|
|
56
|
-
renderSlackPayload,
|
|
57
|
-
sendDigestToSlack,
|
|
58
|
-
slackAdvice,
|
|
59
|
-
} from "./workflows/slack.ts";
|
|
51
|
+
import { renderDigestMessage, sendDigestToSlack } from "./workflows/slack.ts";
|
|
60
52
|
|
|
61
53
|
/**
|
|
62
54
|
* Validate through the SDK's reader, as `start()` does.
|
|
@@ -290,60 +282,39 @@ describe("reading a feed", () => {
|
|
|
290
282
|
});
|
|
291
283
|
});
|
|
292
284
|
|
|
293
|
-
describe("
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
test("
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
});
|
|
314
|
-
|
|
315
|
-
test("sends flat variables to a workflow trigger, under the configured name", () => {
|
|
316
|
-
const payload = renderSlackPayload({
|
|
317
|
-
...slackInput("https://hooks.slack.com/triggers/T/B/a"),
|
|
318
|
-
slackWorkflowTextParam: "digest_body",
|
|
285
|
+
describe("the digest as a channel message", () => {
|
|
286
|
+
/**
|
|
287
|
+
* What is left to test here after the channel concept landed: the MESSAGE,
|
|
288
|
+
* not the payload. Slack's two webhook shapes, the Block Kit assembly, the
|
|
289
|
+
* mrkdwn escaping and the advice each refusal deserves are
|
|
290
|
+
* `@alexkroman1/aai/channels`' and are covered by its own specs — a template
|
|
291
|
+
* asserting them again would pin the SDK's rendering from the outside, which
|
|
292
|
+
* is exactly the duplication moving them was for.
|
|
293
|
+
*/
|
|
294
|
+
test("carries every episode as its own section, linked and attributed", () => {
|
|
295
|
+
const message = renderDigestMessage(slackInput("https://hooks.slack.com/services/T/B/a"));
|
|
296
|
+
|
|
297
|
+
expect(message.heading).toBe("Podcast digest 1/2");
|
|
298
|
+
expect(message.subtitle).toContain("Feeds:");
|
|
299
|
+
expect(message.sections).toHaveLength(1);
|
|
300
|
+
expect(message.sections?.[0]).toMatchObject({
|
|
301
|
+
title: "Example Episode",
|
|
302
|
+
subtitle: "Example Podcast",
|
|
303
|
+
body: "A concise episode summary.",
|
|
304
|
+
bullets: ["First point", "Second point"],
|
|
319
305
|
});
|
|
320
|
-
expect(
|
|
321
|
-
expect(payload).not.toHaveProperty("blocks");
|
|
322
|
-
expect(String(payload.digest_body)).toContain("Podcast digest 1/2");
|
|
323
|
-
});
|
|
324
|
-
|
|
325
|
-
test("a trigger body carries every episode's summary and points", () => {
|
|
326
|
-
const text = renderPlainTextDigest(slackInput("https://hooks.slack.com/triggers/T/B/a"));
|
|
327
|
-
expect(text).toContain("Example Episode");
|
|
328
|
-
expect(text).toContain("A concise episode summary.");
|
|
329
|
-
expect(text).toContain("- First point");
|
|
330
|
-
});
|
|
331
|
-
|
|
332
|
-
test("escapes only Slack's three reserved characters, ampersand first", () => {
|
|
333
|
-
expect(escapeSlack("Tom & Jerry <b>")).toBe("Tom & Jerry <b>");
|
|
334
|
-
// Apostrophes are not reserved — escaping them would litter every summary.
|
|
335
|
-
expect(escapeSlack("it's fine")).toBe("it's fine");
|
|
306
|
+
expect(message.sections?.[0]?.url).toContain("http");
|
|
336
307
|
});
|
|
337
308
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
309
|
+
/**
|
|
310
|
+
* `text` is the notification line, and on a Slack workflow trigger it is the
|
|
311
|
+
* WHOLE message — so it has to stand on its own rather than repeat the
|
|
312
|
+
* heading a trigger will never render.
|
|
313
|
+
*/
|
|
314
|
+
test("says how much arrived in the notification line", () => {
|
|
315
|
+
const message = renderDigestMessage(slackInput("https://hooks.slack.com/triggers/T/B/a"));
|
|
342
316
|
|
|
343
|
-
|
|
344
|
-
const advice = slackAdvice("https://hooks.slack.com/services/T/B/a", "invalid_payload");
|
|
345
|
-
expect(advice).toContain("revoked");
|
|
346
|
-
expect(advice).not.toContain("workflow");
|
|
317
|
+
expect(message.text).toBe("Podcast digest 1/2: 1 episode summaries");
|
|
347
318
|
});
|
|
348
319
|
});
|
|
349
320
|
|
|
@@ -43,9 +43,9 @@
|
|
|
43
43
|
*/
|
|
44
44
|
|
|
45
45
|
import { type WorkflowDef, workflow, workflowApp } from "@alexkroman1/aai";
|
|
46
|
+
import { isSlackWebhookUrl } from "@alexkroman1/aai/channels";
|
|
46
47
|
import { z } from "zod";
|
|
47
48
|
import { type DailyDigestOutput, dailyDigestFlow } from "./workflows/digest.ts";
|
|
48
|
-
import { isSlackWebhookUrl } from "./workflows/slack.ts";
|
|
49
49
|
|
|
50
50
|
/** Somebody pastes a list; anything not http(s) is a typo worth catching here. */
|
|
51
51
|
function isHttpUrl(value: string): boolean {
|
|
@@ -63,11 +63,23 @@ import type { dailyDigest } from "../agent.ts";
|
|
|
63
63
|
import { discoverEpisodes, type Episode } from "./feeds.ts";
|
|
64
64
|
import { sendDigestToSlack } from "./slack.ts";
|
|
65
65
|
|
|
66
|
-
/**
|
|
67
|
-
|
|
66
|
+
/**
|
|
67
|
+
* Between polling rounds. Transcription is minutes, so this is not a busy wait.
|
|
68
|
+
*
|
|
69
|
+
* Exported so `agent.eval.test.ts` can assert on the wait a run RECORDED without
|
|
70
|
+
* restating the number — an eval skips the sleep, so the duration asked for is
|
|
71
|
+
* the only observable there is.
|
|
72
|
+
*/
|
|
73
|
+
export const POLL_DELAY = "20 seconds";
|
|
68
74
|
|
|
69
|
-
/**
|
|
70
|
-
|
|
75
|
+
/**
|
|
76
|
+
* 180 rounds x 20s = an hour, which is far past any podcast episode.
|
|
77
|
+
*
|
|
78
|
+
* Exported for the same reason as {@link POLL_DELAY}: the eval drives a job past
|
|
79
|
+
* this budget and reads the degraded entry, and a restated 180 in two files is a
|
|
80
|
+
* second place for the number to be wrong.
|
|
81
|
+
*/
|
|
82
|
+
export const MAX_POLL_ATTEMPTS = 180;
|
|
71
83
|
|
|
72
84
|
/**
|
|
73
85
|
* How much transcript the model reads.
|
|
@@ -218,33 +230,54 @@ export async function dailyDigestFlow(input: DigestInput): Promise<DailyDigestOu
|
|
|
218
230
|
* an hour becomes `unavailable` with a reason, and the digest goes out with the
|
|
219
231
|
* other four — a partial digest being obviously better than none, and the
|
|
220
232
|
* reason being printed where a reader will see it.
|
|
233
|
+
*
|
|
234
|
+
* **What comes back is in the order it went in**, which is not what a first
|
|
235
|
+
* draft does. Appending each episode as it finishes — and concatenating the
|
|
236
|
+
* ones that never did on the end — makes the digest's running order a report of
|
|
237
|
+
* TRANSCRIPTION LATENCY: `discoverEpisodes` sorted the feed newest first, and a
|
|
238
|
+
* reader then sees whichever episode the provider happened to finish first at
|
|
239
|
+
* the top. Found by `agent.eval.test.ts`, which is the only tier that can see
|
|
240
|
+
* it, a per-step spec having no batch to order.
|
|
221
241
|
*/
|
|
222
242
|
async function waitForTranscripts(jobs: TranscriptJob[]): Promise<TranscriptState[]> {
|
|
223
243
|
let pending = jobs;
|
|
224
|
-
|
|
244
|
+
// Keyed by episode id rather than appended, and that is what keeps the digest
|
|
245
|
+
// in PUBLICATION order — see this function's doc.
|
|
246
|
+
const settled = new Map<string, TranscriptState>();
|
|
225
247
|
|
|
226
248
|
for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS && pending.length > 0; attempt += 1) {
|
|
227
249
|
const polled = await mapConcurrent(pending, POLL_CONCURRENCY, pollTranscript);
|
|
228
250
|
for (const state of polled) {
|
|
229
|
-
if (state.transcriptStatus !== "submitted")
|
|
251
|
+
if (state.transcriptStatus !== "submitted") settled.set(state.id, state);
|
|
230
252
|
}
|
|
231
253
|
pending = polled.filter((state) => state.transcriptStatus === "submitted");
|
|
232
254
|
if (pending.length > 0) await sleep(POLL_DELAY);
|
|
233
255
|
}
|
|
234
256
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
257
|
+
for (const job of pending) settled.set(job.id, gaveUpOn(job));
|
|
258
|
+
// The list `jobs` arrived in, which `discoverEpisodes` sorted newest first.
|
|
259
|
+
return jobs.map((job) => settled.get(job.id) ?? gaveUpOn(job));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* One episode the run is done waiting for.
|
|
264
|
+
*
|
|
265
|
+
* Its own function because {@link waitForTranscripts} needs it in two places —
|
|
266
|
+
* the jobs still pending when the budget ran out, and the unreachable fallback
|
|
267
|
+
* the ordered rebuild above needs for a lookup that cannot miss.
|
|
268
|
+
*/
|
|
269
|
+
function gaveUpOn(job: TranscriptJob): TranscriptState {
|
|
270
|
+
return {
|
|
271
|
+
...job,
|
|
272
|
+
transcriptStatus: "unavailable" as const,
|
|
273
|
+
reason:
|
|
274
|
+
job.transcriptStatus === "submitted"
|
|
275
|
+
? // The transcript is not lost — it is still on the provider, and the
|
|
276
|
+
// message says where, the same way the other transcription templates do.
|
|
277
|
+
`Transcript ${job.transcriptId} was still unfinished after ${MAX_POLL_ATTEMPTS} ` +
|
|
278
|
+
`checks. It is not lost — read it with GET ${TRANSCRIBE_API}/v2/transcript/${job.transcriptId}.`
|
|
279
|
+
: job.reason,
|
|
280
|
+
};
|
|
248
281
|
}
|
|
249
282
|
|
|
250
283
|
/**
|