@alexkroman1/aai-cli 7.0.0 → 8.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 (122) hide show
  1. package/README.md +1 -0
  2. package/dist/{_bundler-B4RqNF5Z.mjs → _bundler-CDuYl5Gb.mjs} +1 -1
  3. package/dist/{_dev-server-CiKFH9qw.mjs → _dev-server-Me06wgeF.mjs} +2 -2
  4. package/dist/{_init-CE4HKdgV.mjs → _init-BhEfQ6Yi.mjs} +1 -1
  5. package/dist/_resource-commands.d.ts +23 -0
  6. package/dist/{_slug-api-nRFaBEPJ.mjs → _slug-api-z-USxPtL.mjs} +14 -10
  7. package/dist/_slug-api.d.ts +5 -2
  8. package/dist/{_templates-FI3xPEMj.mjs → _templates-4WcKOjS5.mjs} +4 -5
  9. package/dist/_templates.d.ts +3 -4
  10. package/dist/{build-6jfaakgc.mjs → build-BBxf8vom.mjs} +2 -2
  11. package/dist/cli.mjs +338 -346
  12. package/dist/{deploy-DvYeD8ia.mjs → deploy-DRC8jWr8.mjs} +1 -1
  13. package/dist/{dev-B59JIfWO.mjs → dev-BvcctCn2.mjs} +1 -1
  14. package/dist/eval-DfQ8cm5p.mjs +56 -0
  15. package/dist/eval.d.ts +43 -0
  16. package/dist/{init-CpJVjEg0.mjs → init-CUo87gcI.mjs} +2 -2
  17. package/dist/{logs-9q-psa4q.mjs → logs-CQ2T46dr.mjs} +1 -1
  18. package/dist/scaffold/CLAUDE.md +409 -68
  19. package/dist/scaffold/package.json +5 -4
  20. package/dist/scaffold/server.mjs +10 -3
  21. package/dist/scaffold/tsconfig.json +0 -1
  22. package/dist/{secret-CVvSLIDV.mjs → secret-Ct34kdgU.mjs} +1 -1
  23. package/dist/{storage-BvUrnvM3.mjs → storage-i_79ZwAR.mjs} +1 -1
  24. package/dist/{studio--MUV0cid.mjs → studio-CzTzPqe1.mjs} +1 -1
  25. package/dist/templates/briefing-desk/agent.test.ts +350 -0
  26. package/dist/templates/briefing-desk/agent.ts +30 -0
  27. package/dist/templates/briefing-desk/shared.ts +200 -0
  28. package/dist/templates/briefing-desk/system-prompt.md +39 -0
  29. package/dist/templates/briefing-desk/tools/briefing_so_far.ts +35 -0
  30. package/dist/templates/briefing-desk/tools/research_topic.ts +88 -0
  31. package/dist/templates/briefing-desk/tools/verify_claim.ts +77 -0
  32. package/dist/templates/call-audit/agent.eval.test.ts +474 -0
  33. package/dist/templates/code-interpreter/agent.eval.test.ts +165 -0
  34. package/dist/templates/dispatch-center/agent.eval.test.ts +349 -0
  35. package/dist/templates/embedded-assets/agent.eval.test.ts +138 -0
  36. package/dist/templates/health-assistant/agent.eval.test.ts +167 -0
  37. package/dist/templates/infocom-adventure/agent.eval.test.ts +158 -0
  38. package/dist/templates/infocom-adventure/agent.test.ts +49 -18
  39. package/dist/templates/infocom-adventure/agent.ts +21 -0
  40. package/dist/templates/infocom-adventure/shared.ts +20 -9
  41. package/dist/templates/infocom-adventure/system-prompt.md +1 -1
  42. package/dist/templates/infocom-adventure/tools/game_state_move.ts +3 -2
  43. package/dist/templates/link-digest/agent.eval.test.ts +195 -0
  44. package/dist/templates/math-buddy/agent.eval.test.ts +146 -0
  45. package/dist/templates/night-owl/agent.eval.test.ts +272 -0
  46. package/dist/templates/personal-finance/agent.eval.test.ts +168 -0
  47. package/dist/templates/personal-finance/system-prompt.md +4 -0
  48. package/dist/templates/pipeline-simple/agent.eval.test.ts +78 -0
  49. package/dist/templates/pipeline-simple/agent.ts +2 -2
  50. package/dist/templates/pizza-ordering/agent.eval.test.ts +171 -0
  51. package/dist/templates/pizza-ordering/agent.test.ts +15 -0
  52. package/dist/templates/pizza-ordering/shared.ts +20 -1
  53. package/dist/templates/pizza-ordering/system-prompt.md +6 -0
  54. package/dist/templates/plan-and-execute/agent.eval.test.ts +209 -0
  55. package/dist/templates/podcast-digest/agent.eval.test.ts +411 -0
  56. package/dist/templates/podcast-digest/agent.test.ts +30 -59
  57. package/dist/templates/podcast-digest/agent.ts +1 -1
  58. package/dist/templates/podcast-digest/workflows/digest.ts +52 -19
  59. package/dist/templates/podcast-digest/workflows/slack.ts +48 -169
  60. package/dist/templates/recap-workflow/agent.eval.test.ts +366 -0
  61. package/dist/templates/redline/agent.eval.test.ts +209 -0
  62. package/dist/templates/research-workflow/agent.eval.test.ts +329 -0
  63. package/dist/templates/retail/address.ts +87 -1
  64. package/dist/templates/retail/agent.eval.test.ts +314 -0
  65. package/dist/templates/retail/agent.test.ts +171 -91
  66. package/dist/templates/retail/agent.ts +35 -1
  67. package/dist/templates/retail/cancel.ts +116 -0
  68. package/dist/templates/retail/client.tsx +39 -0
  69. package/dist/templates/retail/payment.ts +107 -0
  70. package/dist/templates/retail/pending.test.ts +196 -0
  71. package/dist/templates/retail/pending.ts +137 -0
  72. package/dist/templates/retail/registry.test.ts +119 -8
  73. package/dist/templates/retail/returns.ts +102 -0
  74. package/dist/templates/retail/shared.test.ts +42 -0
  75. package/dist/templates/retail/shared.ts +35 -0
  76. package/dist/templates/retail/store.test.ts +6 -4
  77. package/dist/templates/retail/store.ts +113 -57
  78. package/dist/templates/retail/swap.test.ts +4 -4
  79. package/dist/templates/retail/swap.ts +248 -15
  80. package/dist/templates/retail/system-prompt.md +62 -30
  81. package/dist/templates/retail/tools/cancel_change.ts +33 -0
  82. package/dist/templates/retail/tools/cancel_pending_order.ts +15 -59
  83. package/dist/templates/retail/tools/confirm_change.ts +42 -0
  84. package/dist/templates/retail/tools/exchange_delivered_order_items.ts +21 -66
  85. package/dist/templates/retail/tools/modify_pending_order_address.ts +12 -24
  86. package/dist/templates/retail/tools/modify_pending_order_items.ts +22 -60
  87. package/dist/templates/retail/tools/modify_pending_order_payment.ts +15 -77
  88. package/dist/templates/retail/tools/modify_user_address.ts +12 -15
  89. package/dist/templates/retail/tools/return_delivered_order_items.ts +16 -71
  90. package/dist/templates/retail/tools/transfer_to_human_agents.ts +4 -2
  91. package/dist/templates/simple/agent.eval.test.ts +54 -0
  92. package/dist/templates/solo-rpg/agent.eval.test.ts +320 -0
  93. package/dist/templates/spoken-summary/agent.eval.test.ts +342 -0
  94. package/dist/templates/support-line/agent.eval.test.ts +273 -0
  95. package/dist/templates/support-line/system-prompt.md +2 -1
  96. package/dist/templates/support-line/tools/answer_question.ts +56 -7
  97. package/dist/templates/transcription-workflow/agent.eval.test.ts +234 -0
  98. package/dist/templates/travel-concierge/agent.eval.test.ts +323 -0
  99. package/dist/templates/travel-concierge/agent.test.ts +110 -10
  100. package/dist/templates/travel-concierge/agent.ts +18 -9
  101. package/dist/templates/travel-concierge/shared.ts +110 -9
  102. package/dist/templates/travel-concierge/system-prompt.md +7 -1
  103. package/dist/templates/travel-concierge/tools/book_car_rental.ts +6 -3
  104. package/dist/templates/travel-concierge/tools/book_excursion.ts +6 -3
  105. package/dist/templates/travel-concierge/tools/book_hotel.ts +6 -3
  106. package/dist/templates/travel-concierge/tools/cancel_ticket.ts +6 -3
  107. package/dist/templates/travel-concierge/tools/search_car_rentals.ts +9 -5
  108. package/dist/templates/travel-concierge/tools/search_excursions.ts +9 -5
  109. package/dist/templates/travel-concierge/tools/search_flights.ts +9 -6
  110. package/dist/templates/travel-concierge/tools/search_hotels.ts +9 -5
  111. package/dist/templates/travel-concierge/tools/update_ticket.ts +6 -3
  112. package/dist/templates/web-researcher/agent.eval.test.ts +121 -0
  113. package/dist/templates/web-researcher/system-prompt.md +15 -4
  114. package/dist/{test-9kPKJI-w.mjs → test-BvyT6sAc.mjs} +24 -10
  115. package/dist/test.d.ts +29 -5
  116. package/dist/{worker-bundler-COxnqstQ.mjs → worker-bundler-CCVEDjm1.mjs} +45 -25
  117. package/dist/worker-bundler.mjs +1 -1
  118. package/dist/{workflow-D2AQf2Pl.mjs → workflow-BflATMmU.mjs} +19 -11
  119. package/package.json +5 -5
  120. package/dist/eject-C9WJyyr2.mjs +0 -116
  121. package/dist/eject.d.ts +0 -60
  122. package/dist/templates/infocom-adventure/tools/game_state_history.ts +0 -14
@@ -1,42 +1,35 @@
1
1
  // Copyright 2026 the AAI authors. MIT license.
2
2
  /**
3
- * Delivering the digest — the half of this template that is about a THIRD
4
- * PARTY's contract rather than about podcasts.
3
+ * Delivering the digest — which is now almost entirely a question of what the
4
+ * MESSAGE says, because where it goes is the SDK's job.
5
5
  *
6
- * Slack has two things people call "a webhook URL" and they take different
7
- * bodies. Getting this wrong is the single most common way a run of this
8
- * template ends in a red 400 with nobody able to say why, so the distinction is
9
- * modelled here rather than left to whoever pastes the URL:
6
+ * This module used to carry the whole third-party contract: Slack's two
7
+ * webhook shapes and the branch between them, Block Kit assembly, mrkdwn
8
+ * escaping, the 4xx/5xx split and the advice each refusal deserves. All of it
9
+ * is `@alexkroman1/aai/channels` now `slackChannel()` names the destination,
10
+ * `sendToChannelClassified` posts and classifies — and what is left here is the
11
+ * part that is actually about podcasts: turning episode digests into a
12
+ * {@link ChannelMessage}.
10
13
  *
11
- * | URL | What it wants |
12
- * | --- | --- |
13
- * | `hooks.slack.com/services/…` | A classic incoming webhook: Block Kit |
14
- * | `hooks.slack.com/triggers/…` | A workflow trigger: FLAT string variables |
14
+ * That split is the point of the channel concept. Every one of those rules is
15
+ * about SLACK rather than about this template, and a template is the wrong
16
+ * place to learn them: the trigger-vs-incoming-webhook distinction alone is
17
+ * the most common way a run of this ends in a red 400 with nobody able to say
18
+ * why.
15
19
  *
16
- * Send Block Kit to a trigger URL and Slack rejects the whole payload, because
17
- * a trigger's body is a flat map of the variables its workflow declared — there
18
- * is no `blocks` variable and there never will be. {@link renderSlackPayload}
19
- * branches on the URL for exactly that reason, and it is a pure function so the
20
- * branch is testable without a network.
20
+ * ## What a step still owns
21
21
  *
22
- * ## Why the trigger case needs a parameter NAME from the user
23
- *
24
- * A trigger's variables are named by whoever built the Slack workflow. This
25
- * template cannot know that name, so it is an input field defaulting to `text`
26
- * — the name Slack's own example uses. That is an unusual thing to put in a
27
- * form and it is the honest option: the alternative is guessing, and a guess
28
- * fails with `invalid_arguments` and no indication of which name was wrong.
22
+ * The `"use step"` stays HERE, and it has to. The Workflow DevKit's builder
23
+ * rewrites step bodies it finds in this directory; one written inside the SDK
24
+ * would be transformed by nothing, so it would run inline with no journal and
25
+ * no retry while looking durable at the call site.
29
26
  */
30
27
 
31
- import { report, stepFetch } from "@alexkroman1/aai/step";
32
- import { toStepError } from "@alexkroman1/aai/step-errors";
33
- import { responseErrorMessage } from "@alexkroman1/aai/utils";
34
- import { FatalError } from "workflow";
28
+ import { type ChannelMessage, slackChannel } from "@alexkroman1/aai/channels";
29
+ import { report } from "@alexkroman1/aai/step";
30
+ import { sendToChannelClassified } from "@alexkroman1/aai/step-errors";
35
31
  import type { EpisodeDigest } from "./digest.ts";
36
32
 
37
- /** Slack is not slow; a post that has not answered in 30s is not going to. */
38
- const POST_TIMEOUT_MS = 30_000;
39
-
40
33
  /** Everything the message needs, so rendering can stay a pure function. */
41
34
  export type SlackDigestInput = {
42
35
  slackWebhookUrl: string;
@@ -48,162 +41,48 @@ export type SlackDigestInput = {
48
41
  };
49
42
 
50
43
  /**
51
- * The step: post one digest, and classify the failure honestly.
44
+ * The step: post one digest.
52
45
  *
53
- * The 4xx/5xx split is the whole reason this is not a one-line `stepFetch`. A
54
- * revoked webhook, an unpublished workflow and a wrong variable name all answer
55
- * 4xx and will answer 4xx identically on every retry retrying them burns the
56
- * DevKit's attempts and delays the real error by minutes. A 5xx is Slack having
57
- * a bad minute, which is precisely what retries are for, and `toStepError`
58
- * carries any `Retry-After` Slack named into the schedule.
46
+ * It is three lines because the interesting decisions moved. `slackChannel()` builds
47
+ * the descriptor, {@link renderDigestMessage} says what the message contains,
48
+ * and `sendToChannelClassified` does the render-post-classify roundthrowing
49
+ * a `FatalError` on a 4xx (a revoked webhook and a wrong variable name answer
50
+ * identically on every retry, so retrying only delays the real error) and a
51
+ * `RetryableError` carrying Slack's own `Retry-After` on a 5xx.
59
52
  */
60
53
  export async function sendDigestToSlack(input: SlackDigestInput): Promise<string> {
61
54
  "use step";
62
55
 
63
56
  await report("Posting the digest to Slack.");
64
- const response = await stepFetch(input.slackWebhookUrl, {
65
- method: "POST",
66
- headers: { "Content-Type": "application/json" },
67
- body: JSON.stringify(renderSlackPayload(input)),
68
- signal: AbortSignal.timeout(POST_TIMEOUT_MS),
69
- });
70
- if (response.ok) return (await response.text()) || "ok";
71
-
72
- // `responseErrorMessage` rather than `await response.text()` and a hand-rolled
73
- // truncation: it prefers a JSON `error` field when the body has one — which
74
- // Slack's does — and falls back to the status with a bounded preview.
75
- const detail = await responseErrorMessage(response, "Slack webhook post");
76
- if (response.status >= 400 && response.status < 500) {
77
- throw new FatalError(`${slackAdvice(input.slackWebhookUrl, detail)} (HTTP ${response.status})`);
78
- }
79
- throw toStepError(response, `Slack webhook post failed: HTTP ${response.status}. ${detail}`);
80
- }
81
-
82
- /**
83
- * The sentence a person can act on, chosen from what the URL and body say.
84
- *
85
- * `workflow_not_published` is called out by name because it is the one 4xx with
86
- * a fix that is not "check your URL" — the URL is fine and the workflow behind
87
- * it was never published — and nothing in Slack's generic message says so.
88
- */
89
- export function slackAdvice(webhookUrl: string, detail: string): string {
90
- if (isSlackWorkflowTriggerUrl(webhookUrl)) {
91
- if (detail.includes("workflow_not_published")) {
92
- return "That Slack workflow trigger exists but its workflow is not published. Publish it in Slack, then start a new run.";
93
- }
94
- return `Slack rejected the workflow trigger: ${detail}. Check that the text parameter matches a variable the workflow declares.`;
95
- }
96
- return `Slack rejected the incoming webhook: ${detail}. Check that the webhook is still active and has not been revoked.`;
97
- }
98
-
99
- /** A workflow trigger, which takes flat variables and not Block Kit. */
100
- export function isSlackWorkflowTriggerUrl(url: string): boolean {
101
- try {
102
- const parsed = new URL(url);
103
- return (
104
- parsed.hostname.toLowerCase() === "hooks.slack.com" &&
105
- parsed.pathname.startsWith("/triggers/")
106
- );
107
- } catch {
108
- return false;
109
- }
57
+ return await sendToChannelClassified(
58
+ slackChannel({ webhookUrl: input.slackWebhookUrl, textParam: input.slackWorkflowTextParam }),
59
+ renderDigestMessage(input),
60
+ );
110
61
  }
111
62
 
112
63
  /**
113
- * Accepted at the form's edge: an incoming webhook or a workflow trigger, on
114
- * one of Slack's two webhook hosts.
64
+ * The digest as a channel message PURE, so a spec asserts what a run would
65
+ * post without a network, and without knowing Slack's payload shape.
115
66
  *
116
- * A host check rather than "is it a URL", and this is a security boundary as
117
- * much as a usability one: this value becomes the target of a POST carrying
118
- * summarized content, so anything that is not Slack is an exfiltration target
119
- * somebody typed into a form. Refusing at `start()` is a 400 at the call site;
120
- * refusing later would be a failed run after transcription had already been paid for.
67
+ * `text` is the notification line and, on a Slack workflow trigger, the whole
68
+ * message: the SDK folds the rest into it when the destination has no rich
69
+ * format. So it says how many episodes rather than repeating the headline.
121
70
  */
122
- export function isSlackWebhookUrl(value: string): boolean {
123
- try {
124
- const parsed = new URL(value);
125
- const host = parsed.hostname.toLowerCase();
126
- return (
127
- parsed.protocol === "https:" &&
128
- (host === "hooks.slack.com" || host === "hooks.slack-gov.com") &&
129
- parsed.pathname.length > 1
130
- );
131
- } catch {
132
- return false;
133
- }
134
- }
135
-
136
- /** Block Kit, or flat variables — see the module doc. */
137
- export function renderSlackPayload(input: SlackDigestInput): Record<string, unknown> {
138
- if (isSlackWorkflowTriggerUrl(input.slackWebhookUrl)) {
139
- return { [input.slackWorkflowTextParam || "text"]: renderPlainTextDigest(input) };
140
- }
141
- return renderSlackMessage(input);
142
- }
143
-
144
- /** The trigger body: one string, because that is all a variable can hold. */
145
- export function renderPlainTextDigest(input: SlackDigestInput): string {
146
- return [
147
- digestHeadline(input),
148
- `Feeds: ${input.podcastChannels}`,
149
- "",
150
- ...input.episodes.flatMap((episode) => [
151
- `${episode.title} — ${episode.podcastTitle}`,
152
- episode.url,
153
- episode.summary,
154
- ...episode.keyPoints.map((point) => `- ${point}`),
155
- "",
156
- ]),
157
- ]
158
- .join("\n")
159
- .trim();
160
- }
161
-
162
- /** The incoming-webhook body: Block Kit, with `text` as the notification line. */
163
- function renderSlackMessage(input: SlackDigestInput): Record<string, unknown> {
71
+ export function renderDigestMessage(input: SlackDigestInput): ChannelMessage {
164
72
  return {
165
- // Not decoration — this is what a push notification and a screen reader
166
- // read. A Block Kit payload with no `text` notifies as "[no preview]".
167
73
  text: `${digestHeadline(input)}: ${input.episodes.length} episode summaries`,
168
- blocks: [
169
- { type: "header", text: { type: "plain_text", text: digestHeadline(input) } },
170
- {
171
- type: "section",
172
- text: { type: "mrkdwn", text: `Feeds: ${escapeSlack(input.podcastChannels)}` },
173
- },
174
- { type: "divider" },
175
- ...input.episodes.flatMap((episode) => [
176
- {
177
- type: "section",
178
- text: { type: "mrkdwn", text: renderEpisodeBlock(episode) },
179
- },
180
- { type: "divider" },
181
- ]),
182
- ],
74
+ heading: digestHeadline(input),
75
+ subtitle: `Feeds: ${input.podcastChannels}`,
76
+ sections: input.episodes.map((episode) => ({
77
+ title: episode.title,
78
+ url: episode.url,
79
+ subtitle: episode.podcastTitle,
80
+ body: episode.summary,
81
+ bullets: episode.keyPoints,
82
+ })),
183
83
  };
184
84
  }
185
85
 
186
- function renderEpisodeBlock(episode: EpisodeDigest): string {
187
- return [
188
- `*<${escapeSlack(episode.url)}|${escapeSlack(episode.title)}>*`,
189
- `_${escapeSlack(episode.podcastTitle)}_`,
190
- escapeSlack(episode.summary),
191
- ...episode.keyPoints.map((point) => `• ${escapeSlack(point)}`),
192
- ].join("\n");
193
- }
194
-
195
86
  function digestHeadline(input: SlackDigestInput): string {
196
87
  return `Podcast digest ${input.digestNumber}/${input.totalDigests}`;
197
88
  }
198
-
199
- /**
200
- * The three characters Slack's mrkdwn reserves.
201
- *
202
- * Only three, and only these: Slack's own escaping rules say `&`, `<` and `>`
203
- * and nothing else, so escaping more would put backslashes in front of
204
- * apostrophes in every summary. `&` first, or the ampersands introduced by the
205
- * other two get double-escaped.
206
- */
207
- export function escapeSlack(text: string): string {
208
- return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
209
- }
@@ -0,0 +1,366 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ // An EVAL: does this desk actually behave? Run it with `aai eval`.
4
+ //
5
+ // `agent.test.ts` drives the five tools against a STUBBED `ctx.workflows`, the
6
+ // steps against a stubbed provider, and the body's two helpers with `sleep` and
7
+ // `createHook` mocked. What none of those tiers can say is whether the AGENT —
8
+ // a model, reading this system prompt, holding these five tools — hands a
9
+ // recording off at all, whether the run it starts is the same run a later turn
10
+ // finds, and whether a caller who says "forget it" is told the truth about what
11
+ // cancelling does. That is what this file is for, and it is only possible
12
+ // because `describeEval` opens a real workflow engine per case and hands its
13
+ // client to the session.
14
+ //
15
+ // Three boundaries, each of which a case here would be dishonest to imply:
16
+ //
17
+ // * **The engine is not durable.** No journal, no replay, no retry, and a
18
+ // `sleep()` is RECORDED rather than taken — so the poll loop below runs at
19
+ // full speed and the only way to observe a run in flight is to hold the
20
+ // provider's answer, which is what `hold` does.
21
+ // * **`createHook()` throws untransformed**, so the RETENTION GATE — this
22
+ // template's headline port of Temporal's `expense` — cannot be reached at
23
+ // all here, and neither can `keep_transcript`'s signal: a run that gets past
24
+ // `summarize` fails on the hook. Nothing below asserts the gate, its three
25
+ // outcomes, or its safe default; `agent.test.ts` drives
26
+ // `askWhetherToKeep` directly for those, and it is the only tier that can.
27
+ // * **The provider is scripted, in BOTH modes**, through `stepFetch`'s
28
+ // published slot — so the transcription, the recap's model call and the
29
+ // compensating DELETE are all deterministic and free, while the SESSION's
30
+ // model stays live and is what a live run measures.
31
+ //
32
+ // And what no eval here can see: anything below the audio boundary.
33
+ import { withDiscoveredTools } from "@alexkroman1/aai/testing";
34
+ import { installStubStepFetch } from "@alexkroman1/aai/testing/vitest";
35
+ import type { EvalToolCall, EvalWorkflows } from "@alexkroman1/aai-runtime/eval";
36
+ import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
37
+ import { expect } from "vitest";
38
+ import { z } from "zod";
39
+ import authoredAgent from "./agent.ts";
40
+ import { recap, SAMPLE_RECORDING } from "./shared.ts";
41
+
42
+ /**
43
+ * The def a DEPLOYED agent runs: authored, plus what `tools/` declares.
44
+ *
45
+ * Load-bearing, and not applied by `agent()` — the BUILD enumerates `tools/`,
46
+ * so an eval driving the raw default export would measure a desk with none of
47
+ * its five tools. This template declares its prompt inline in `agent.ts`, so
48
+ * there is no `system-prompt.md` for `withSystemPrompt` to layer on.
49
+ *
50
+ * The glob is written here rather than reached for from a shared helper because
51
+ * this file SHIPS — see `agent.test.ts`.
52
+ */
53
+ const agentDef = withDiscoveredTools(
54
+ authoredAgent,
55
+ import.meta.glob("./tools/*.ts", { eager: true }),
56
+ );
57
+
58
+ /**
59
+ * The key every step reads with `requireStepEnv` — the one name `agent.ts`
60
+ * declares in `requiredEnv`.
61
+ *
62
+ * Passed as the agent env so the eval's workflow engine publishes it: the
63
+ * provider calls are answered by a fake, but each asks for the key BEFORE it
64
+ * makes its request, so a run with no key fails on the credential rather than
65
+ * reaching the script. The ENVIRONMENT and nothing else — a template may not
66
+ * read a developer's CLI config.
67
+ */
68
+ const EVAL_ENV = { ASSEMBLYAI_API_KEY: process.env.ASSEMBLYAI_API_KEY ?? "eval-scripted-key" };
69
+
70
+ /** The job id the scripted provider mints for every submission. */
71
+ const TRANSCRIPT_ID = "t_eval_1";
72
+
73
+ /** What the scripted transcript says, once it completes. */
74
+ const TRANSCRIPT_TEXT =
75
+ "Smoke from the Canadian wildfires reached the eastern seaboard overnight, " +
76
+ "and air quality indexes went into the unhealthy range.";
77
+
78
+ /** The recap the scripted model writes. `spoken` is the field the phone reads. */
79
+ const RECAP_JSON = JSON.stringify({
80
+ headline: "Wildfire smoke reaches the east coast",
81
+ points: ["Smoke crossed overnight", "Air quality is unhealthy", "Advisories are in force"],
82
+ spoken: "Wildfire smoke drifted east overnight and pushed air quality into the unhealthy range.",
83
+ });
84
+
85
+ /** How the scripted provider ends a job. */
86
+ type Ending = "completed" | "error";
87
+
88
+ /** A scripted provider, and the gate that holds its first status answer. */
89
+ type ScriptedProvider = {
90
+ /** Every step request, in order — what the run really asked the provider. */
91
+ readonly calls: readonly { url: string; method: string; body?: unknown }[];
92
+ /** Let a held first poll answer. Idempotent. */
93
+ readonly release: () => void;
94
+ };
95
+
96
+ /**
97
+ * Answer AssemblyAI's batch API and the LLM gateway in memory.
98
+ *
99
+ * Through `stepFetch`'s published slot rather than `vi.stubGlobal("fetch")`,
100
+ * which is the path a step really takes — and which leaves the SESSION's model
101
+ * live, so a live case still measures the agent. An unrecognised request THROWS
102
+ * rather than answering an empty 200: a step calling something nobody expected
103
+ * is a finding, where an empty body reads as a provider that said nothing.
104
+ *
105
+ * `hold` keeps the FIRST poll pending, and it is the only way to observe a run
106
+ * that is still going: a durable `sleep` is skipped here, so an unheld run
107
+ * burns its whole poll loop in milliseconds.
108
+ */
109
+ function stubProvider(options: { hold?: boolean; ending?: Ending } = {}): ScriptedProvider {
110
+ const gate = Promise.withResolvers<void>();
111
+ let polls = 0;
112
+ const stub = installStubStepFetch(async (request) => {
113
+ if (request.url.includes("/chat/completions")) {
114
+ return { body: { choices: [{ message: { content: RECAP_JSON } }] } };
115
+ }
116
+ if (request.method === "POST") return { body: { id: TRANSCRIPT_ID, status: "queued" } };
117
+ // The compensation. A real DELETE removes the transcript from the account,
118
+ // which is what makes "a failed run leaves nothing behind" a claim rather
119
+ // than a comment — so the assertion that matters is that this was CALLED.
120
+ if (request.method === "DELETE") return { body: {} };
121
+ if (request.method === "GET") {
122
+ polls += 1;
123
+ if (options.hold === true && polls === 1) await gate.promise;
124
+ return options.ending === "error"
125
+ ? { body: { status: "error", error: "that recording could not be decoded" } }
126
+ : { body: { status: "completed", text: TRANSCRIPT_TEXT, audio_duration: 254 } };
127
+ }
128
+ throw new Error(`unexpected step request in an eval: ${request.method} ${request.url}`);
129
+ });
130
+ return { calls: stub.calls, release: () => gate.resolve() };
131
+ }
132
+
133
+ /** `request_recap`'s two answers — it started one, or it found the live one. */
134
+ const RecapStart = z.union([
135
+ z.object({ started: z.literal(true), runId: z.string().min(1) }),
136
+ z.object({ started: z.literal(false), runId: z.string().min(1), note: z.string() }),
137
+ ]);
138
+
139
+ /** `cancel_recap`'s answer. */
140
+ const Cancelled = z.object({ cancelled: z.boolean(), note: z.string() });
141
+
142
+ /**
143
+ * Every `request_recap` answer in a turn, parsed.
144
+ *
145
+ * Parsed rather than regexed: a tool result reaches the event stream as a
146
+ * serialized string, and a shape that stopped matching should fail HERE naming
147
+ * the field instead of handing the next assertion `undefined`.
148
+ */
149
+ function recapStarts(calls: readonly EvalToolCall[]): z.infer<typeof RecapStart>[] {
150
+ return calls
151
+ .filter((one) => one.name === "request_recap" && one.result !== undefined)
152
+ .map((one) => RecapStart.parse(JSON.parse(String(one.result))));
153
+ }
154
+
155
+ /** The run id the first `request_recap` of this turn reported. */
156
+ function startedRunId(calls: readonly EvalToolCall[]): string {
157
+ const [first] = recapStarts(calls);
158
+ if (first === undefined) {
159
+ throw new Error(
160
+ `the desk called no request_recap: ${calls.map((one) => one.name).join(", ") || "(no tools)"}`,
161
+ );
162
+ }
163
+ return first.runId;
164
+ }
165
+
166
+ /** Every tool call in this turn that READS a run, whichever the model picked. */
167
+ function readbacks(calls: readonly EvalToolCall[]): readonly EvalToolCall[] {
168
+ return calls.filter((one) => one.name === "recap_status" || one.name === "recap_progress");
169
+ }
170
+
171
+ /** Requests of one method the run has made so far. */
172
+ function requests(provider: ScriptedProvider, method: string) {
173
+ return provider.calls.filter((one) => one.method === method);
174
+ }
175
+
176
+ /** `text` as a regex that matches only itself. */
177
+ function literal(text: string): string {
178
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
179
+ }
180
+
181
+ /** The one utterance every case that starts work opens with. */
182
+ const ASK = "Can you write up that recording for me?";
183
+
184
+ /** The scripted session turn that answers {@link ASK}. */
185
+ const START_TURN = [
186
+ { tool: "request_recap", args: {} },
187
+ "I've started writing that up — I'll let you know when it lands.",
188
+ ] as const;
189
+
190
+ /**
191
+ * Let the run finish before the case ends.
192
+ *
193
+ * Not tidiness: the scripted provider is unpublished when the test that
194
+ * installed it finishes, so a body still mid-flight would make its next request
195
+ * against whatever the next case publishes — or against the real provider, with
196
+ * a real key. Every run here ends FAILED, on `createHook()`; see the header.
197
+ */
198
+ async function drain(workflows: EvalWorkflows | undefined, provider: ScriptedProvider) {
199
+ provider.release();
200
+ for (const run of await (workflows?.runs() ?? [])) await workflows?.settle(run.runId, recap);
201
+ }
202
+
203
+ describeEval(
204
+ agentDef,
205
+ (test) => {
206
+ test(
207
+ "starts one run for the caller and will not pay for a second",
208
+ async ({ session, workflows }) => {
209
+ // Held, so the first run is unambiguously still live when the caller
210
+ // asks again — which is the state the live-run check exists for.
211
+ const provider = stubProvider({ hold: true });
212
+
213
+ const first = await session.say(ASK);
214
+ const runId = startedRunId(first.toolCalls);
215
+ const again = await session.say("Actually, start it again from scratch, please.");
216
+
217
+ // Temporal's workflow-id reuse policy, as this desk spells it: a caller
218
+ // who asks twice is told about the run they already have. WHETHER the
219
+ // model calls the tool a second time is its business — it may simply
220
+ // remember — so each call it did make has to have been refused with the
221
+ // run it found.
222
+ for (const answer of recapStarts(again.toolCalls)) {
223
+ expect(answer.started).toBe(false);
224
+ expect(answer.runId).toBe(runId);
225
+ }
226
+
227
+ // The half that is not vacuous either way, and the one the caller pays
228
+ // for: ONE run, and ONE submission to the provider.
229
+ const runs = await (workflows?.runs() ?? []);
230
+ expect(runs.map((one) => one.runId)).toEqual([runId]);
231
+ expect(runs[0]?.workflow).toBe("recap");
232
+ const posts = requests(provider, "POST");
233
+ expect(posts).toHaveLength(1);
234
+ // And it submitted the recording the DESK supplies, because a phone
235
+ // caller cannot read a URL aloud.
236
+ expect(JSON.parse(String(posts[0]?.body))).toMatchObject({
237
+ audio_url: SAMPLE_RECORDING,
238
+ speaker_labels: true,
239
+ });
240
+
241
+ await drain(workflows, provider);
242
+ },
243
+ {
244
+ stubReply: [
245
+ ...START_TURN,
246
+ { tool: "request_recap", args: {} },
247
+ "There's already one running for you — I'll tell you as soon as it lands.",
248
+ ],
249
+ },
250
+ );
251
+
252
+ test(
253
+ "reads the live run back rather than guessing at it",
254
+ async ({ session, workflows }) => {
255
+ const provider = stubProvider({ hold: true });
256
+
257
+ const started = await session.say(ASK);
258
+ const runId = startedRunId(started.toolCalls);
259
+ const turn = await session.say("How's that going?");
260
+
261
+ const read = readbacks(turn.toolCalls);
262
+ expect(read.length).toBeGreaterThan(0);
263
+ const answered = read.map((one) => one.result ?? "").join("\n");
264
+
265
+ // The load-bearing half: that readback happened while the run really
266
+ // was in flight, waiting on the provider — which is the only state
267
+ // these two tools exist for.
268
+ const runs = await (workflows?.runs() ?? []);
269
+ const live = runs.find((one) => one.runId === runId);
270
+ expect(live?.status).toBe("running");
271
+ expect(requests(provider, "GET")).toHaveLength(1);
272
+
273
+ // WHICH readback tool the model picks is its business — the prompt
274
+ // offers both — so the claim is about what it was TOLD, and each has its
275
+ // own shape: `recap_progress` hands back the run's own latest line and
276
+ // `recap_status` the snapshot's status. Compared against what the RUN
277
+ // really wrote rather than against a literal, because which line that is
278
+ // depends on how far the body got: with the durable `sleep` skipped here
279
+ // the `PATIENCE` race resolves at once, so the note the caller would
280
+ // hear two minutes in is already written.
281
+ const lastLine = String(live?.reported.at(-1));
282
+ expect(live?.reported.length).toBeGreaterThan(0);
283
+ expect(answered).toMatch(new RegExp(`${literal(lastLine)}|Still working on that one`));
284
+ expect(answered).not.toMatch(/Nothing started yet/);
285
+
286
+ await drain(workflows, provider);
287
+ },
288
+ {
289
+ stubReply: [
290
+ ...START_TURN,
291
+ { tool: "recap_progress", args: {} },
292
+ "It's with the transcription service now — nothing back yet.",
293
+ ],
294
+ },
295
+ );
296
+
297
+ test(
298
+ "cancelling says plainly what it did NOT roll back, and really does not",
299
+ async ({ session, workflows }) => {
300
+ const provider = stubProvider({ hold: true });
301
+
302
+ const started = await session.say(ASK);
303
+ const runId = startedRunId(started.toolCalls);
304
+ const turn = await session.say("Forget it — cancel that, please.");
305
+
306
+ const cancel = turn.toolCalls.find((one) => one.name === "cancel_recap");
307
+ const answer = Cancelled.parse(JSON.parse(String(cancel?.result)));
308
+ expect(answer.cancelled).toBe(true);
309
+ // The sentence is a documented promise of this template, not a
310
+ // decoration: cancellation is NOT cooperative here, so the transcript
311
+ // the run had already created stays on the account and the caller is
312
+ // told so rather than left to assume a rollback.
313
+ expect(answer.note).toMatch(/left behind/);
314
+ expect(answer.note).toMatch(/does not roll back/);
315
+
316
+ // And it is TRUE, which is the part only an eval with a real run can
317
+ // check: the run is cancelled, and no compensating DELETE went out.
318
+ const runs = await (workflows?.runs() ?? []);
319
+ expect(runs.find((one) => one.runId === runId)?.status).toBe("cancelled");
320
+ expect(requests(provider, "DELETE")).toEqual([]);
321
+
322
+ // Released after the assertions on purpose: the body runs on regardless
323
+ // (there is no queue here to stop delivering to, and Temporal's
324
+ // deliver-cancellation-into-the-workflow is the one thing this template
325
+ // says does not port), so anything it does afterwards is not what the
326
+ // caller was told about.
327
+ await drain(workflows, provider);
328
+ },
329
+ {
330
+ stubReply: [
331
+ ...START_TURN,
332
+ { tool: "cancel_recap", args: {} },
333
+ "Stopped it. The partial transcript stays on file — cancelling doesn't undo that.",
334
+ ],
335
+ },
336
+ );
337
+
338
+ test("a run that fails after creating a transcript deletes it again", async ({ workflows }) => {
339
+ // Started from the CASE rather than through a tool, because the subject
340
+ // is the saga and the failure has to be injected: the provider refuses
341
+ // the job, which is the branch that unwinds the compensation stack.
342
+ // `request_recap` is what the other three cases drive.
343
+ const provider = stubProvider({ ending: "error" });
344
+
345
+ const run = await workflows?.run(recap, {
346
+ url: SAMPLE_RECORDING,
347
+ requestedBy: "eval-session",
348
+ });
349
+
350
+ expect(run?.status).toBe("failed");
351
+ expect(run?.error).toMatch(/could not transcribe/);
352
+ // The unwind, off the run's own narration — one compensation, named.
353
+ const narration = run?.reported.join("\n") ?? "";
354
+ expect(narration).toMatch(/undoing 1 step/);
355
+ expect(narration).toMatch(`Discarding transcript ${TRANSCRIPT_ID}.`);
356
+ // And it really happened: the transcript this run created was deleted
357
+ // from the account, which is the promise "a failed recap leaves nothing
358
+ // behind" rests on. An undo registered BEFORE its step, or a `catch`
359
+ // that stopped compensating, fails here.
360
+ expect(requests(provider, "DELETE").map((one) => one.url)).toEqual([
361
+ `https://api.assemblyai.com/v2/transcript/${TRANSCRIPT_ID}`,
362
+ ]);
363
+ });
364
+ },
365
+ { env: EVAL_ENV },
366
+ );