@alexkroman1/aai-cli 6.10.1 → 7.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 (162) hide show
  1. package/dist/{_agent-DpH2pBJd.mjs → _agent-BTwZJu4P.mjs} +2 -2
  2. package/dist/{_bundler-C2BDwU7r.mjs → _bundler-B4RqNF5Z.mjs} +2 -2
  3. package/dist/_dev-env.d.ts +22 -2
  4. package/dist/{_dev-server-LLLWnFBs.mjs → _dev-server-CiKFH9qw.mjs} +38 -47
  5. package/dist/_dev-server.d.ts +0 -17
  6. package/dist/{_init-C57bWAQ8.mjs → _init-CE4HKdgV.mjs} +3 -2
  7. package/dist/{_preflight-BtfaYtbE.mjs → _preflight-Co1G9Jww.mjs} +1 -1
  8. package/dist/_preflight.d.ts +1 -1
  9. package/dist/{_slug-api-BxrsJXVI.mjs → _slug-api-nRFaBEPJ.mjs} +2 -2
  10. package/dist/{_studio-U3jhKlA3.mjs → _studio-DR5yzZ1T.mjs} +2 -1
  11. package/dist/{_templates-Dxb_P2Wz.mjs → _templates-FI3xPEMj.mjs} +1 -1
  12. package/dist/_workflow-scan.d.ts +108 -0
  13. package/dist/{build-Dfza2pRI.mjs → build-6jfaakgc.mjs} +5 -2
  14. package/dist/cli.mjs +30 -25
  15. package/dist/{client-bundler-BEIqgOtd.mjs → client-bundler-CWnG42cU.mjs} +2 -1
  16. package/dist/client-bundler.mjs +1 -1
  17. package/dist/{delete-vbpjGzqs.mjs → delete-Dl_LSfvI.mjs} +3 -3
  18. package/dist/{deploy-B8lubiRT.mjs → deploy-DvYeD8ia.mjs} +5 -5
  19. package/dist/{dev-Uq5ujP8a.mjs → dev-B59JIfWO.mjs} +1 -1
  20. package/dist/{eject-31gjtaHF.mjs → eject-C9WJyyr2.mjs} +1 -1
  21. package/dist/{init-l6xfU4xX.mjs → init-CpJVjEg0.mjs} +3 -3
  22. package/dist/{login-C71-qz8F.mjs → login-BBuM1sxH.mjs} +3 -4
  23. package/dist/{logs-BWVGSjLR.mjs → logs-9q-psa4q.mjs} +2 -2
  24. package/dist/project-config.mjs +1 -1
  25. package/dist/scaffold/CLAUDE.md +93 -14
  26. package/dist/scaffold/package.json +5 -3
  27. package/dist/scaffold/server.mjs +13 -4
  28. package/dist/scaffold/vite.config.ts +1 -1
  29. package/dist/{secret-BuMuFR4B.mjs → secret-CVvSLIDV.mjs} +2 -2
  30. package/dist/{storage-DzRZ-eCw.mjs → storage-BvUrnvM3.mjs} +2 -2
  31. package/dist/{studio-BCNUpDgP.mjs → studio--MUV0cid.mjs} +4 -4
  32. package/dist/templates/call-audit/agent.test.ts +818 -0
  33. package/dist/templates/call-audit/agent.ts +171 -0
  34. package/dist/templates/call-audit/client.tsx +198 -0
  35. package/dist/templates/call-audit/workflows/audit.ts +298 -0
  36. package/dist/templates/call-audit/workflows/ingest.ts +251 -0
  37. package/dist/templates/call-audit/workflows/media.ts +637 -0
  38. package/dist/templates/call-audit/workflows/summarize.ts +201 -0
  39. package/dist/templates/call-audit/workflows/sync-api.ts +44 -0
  40. package/dist/templates/dispatch-center/agent.test.ts +149 -25
  41. package/dist/templates/dispatch-center/client.tsx +239 -129
  42. package/dist/templates/dispatch-center/shared.ts +99 -1
  43. package/dist/templates/dispatch-center/system-prompt.md +3 -1
  44. package/dist/templates/dispatch-center/tools/incident_add_note.ts +16 -13
  45. package/dist/templates/dispatch-center/tools/incident_create.ts +19 -1
  46. package/dist/templates/dispatch-center/tools/incident_escalate.ts +68 -59
  47. package/dist/templates/dispatch-center/tools/incident_triage.ts +57 -43
  48. package/dist/templates/dispatch-center/tools/incident_update_status.ts +49 -40
  49. package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +8 -2
  50. package/dist/templates/dispatch-center/tools/resources_dispatch.ts +69 -52
  51. package/dist/templates/dispatch-center/tools/resources_update_status.ts +45 -41
  52. package/dist/templates/embedded-assets/agent.test.ts +17 -14
  53. package/dist/templates/health-assistant/agent.test.ts +22 -4
  54. package/dist/templates/infocom-adventure/agent.test.ts +4 -6
  55. package/dist/templates/infocom-adventure/client.tsx +246 -164
  56. package/dist/templates/link-digest/agent.test.ts +24 -19
  57. package/dist/templates/link-digest/client.tsx +47 -61
  58. package/dist/templates/link-digest/workflows/digest.ts +19 -31
  59. package/dist/templates/night-owl/agent.test.ts +70 -19
  60. package/dist/templates/night-owl/agent.ts +5 -0
  61. package/dist/templates/night-owl/client.tsx +56 -56
  62. package/dist/templates/night-owl/shared.ts +24 -0
  63. package/dist/templates/night-owl/tools/recommend.ts +18 -6
  64. package/dist/templates/pizza-ordering/agent.test.ts +27 -27
  65. package/dist/templates/pizza-ordering/client.tsx +9 -26
  66. package/dist/templates/plan-and-execute/agent.test.ts +99 -58
  67. package/dist/templates/plan-and-execute/agent.ts +1 -1
  68. package/dist/templates/plan-and-execute/client.tsx +12 -15
  69. package/dist/templates/plan-and-execute/shared.ts +71 -2
  70. package/dist/templates/plan-and-execute/tools/plan_status.ts +18 -6
  71. package/dist/templates/plan-and-execute/tools/revise_plan.ts +18 -10
  72. package/dist/templates/plan-and-execute/tools/start_plan.ts +13 -2
  73. package/dist/templates/plan-and-execute/tools/work_next_step.ts +70 -22
  74. package/dist/templates/podcast-digest/agent.test.ts +746 -0
  75. package/dist/templates/podcast-digest/agent.ts +139 -0
  76. package/dist/templates/podcast-digest/client.tsx +154 -0
  77. package/dist/templates/podcast-digest/workflows/digest.ts +411 -0
  78. package/dist/templates/podcast-digest/workflows/feeds.ts +507 -0
  79. package/dist/templates/podcast-digest/workflows/slack.ts +209 -0
  80. package/dist/templates/recap-workflow/agent.test.ts +107 -96
  81. package/dist/templates/recap-workflow/tools/recap_progress.ts +9 -12
  82. package/dist/templates/recap-workflow/tools/recap_status.ts +8 -9
  83. package/dist/templates/recap-workflow/tools/request_recap.ts +2 -1
  84. package/dist/templates/recap-workflow/workflows/recap.ts +48 -41
  85. package/dist/templates/redline/agent.test.ts +17 -15
  86. package/dist/templates/redline/client.tsx +12 -12
  87. package/dist/templates/redline/workflows/redline.ts +19 -31
  88. package/dist/templates/research-workflow/agent.test.ts +60 -59
  89. package/dist/templates/research-workflow/tools/research_progress.ts +9 -12
  90. package/dist/templates/research-workflow/tools/research_status.ts +9 -11
  91. package/dist/templates/research-workflow/workflows/research.ts +44 -61
  92. package/dist/templates/retail/agent.test.ts +26 -23
  93. package/dist/templates/retail/client.tsx +226 -117
  94. package/dist/templates/retail/registry.test.ts +38 -6
  95. package/dist/templates/retail/store.test.ts +82 -15
  96. package/dist/templates/retail/store.ts +174 -47
  97. package/dist/templates/retail/system-prompt.md +11 -2
  98. package/dist/templates/retail/tools/cancel_pending_order.ts +2 -2
  99. package/dist/templates/retail/tools/exchange_delivered_order_items.ts +2 -2
  100. package/dist/templates/retail/tools/find_user_id_by_email.ts +8 -5
  101. package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +4 -5
  102. package/dist/templates/retail/tools/get_item_details.ts +3 -4
  103. package/dist/templates/retail/tools/get_order_details.ts +3 -4
  104. package/dist/templates/retail/tools/get_product_details.ts +3 -4
  105. package/dist/templates/retail/tools/get_user_details.ts +2 -2
  106. package/dist/templates/retail/tools/list_all_product_types.ts +5 -5
  107. package/dist/templates/retail/tools/modify_pending_order_address.ts +2 -2
  108. package/dist/templates/retail/tools/modify_pending_order_items.ts +2 -3
  109. package/dist/templates/retail/tools/modify_pending_order_payment.ts +2 -2
  110. package/dist/templates/retail/tools/modify_user_address.ts +2 -2
  111. package/dist/templates/retail/tools/return_delivered_order_items.ts +2 -2
  112. package/dist/templates/retail/tools/transfer_to_human_agents.ts +8 -4
  113. package/dist/templates/solo-rpg/agent.test.ts +227 -48
  114. package/dist/templates/solo-rpg/client.tsx +38 -37
  115. package/dist/templates/solo-rpg/shared.ts +145 -19
  116. package/dist/templates/solo-rpg/system-prompt.md +3 -2
  117. package/dist/templates/solo-rpg/tools/action_roll.ts +82 -63
  118. package/dist/templates/solo-rpg/tools/burn_momentum.ts +85 -50
  119. package/dist/templates/solo-rpg/tools/check_state.ts +24 -7
  120. package/dist/templates/solo-rpg/tools/load_game.ts +13 -1
  121. package/dist/templates/solo-rpg/tools/save_game.ts +16 -7
  122. package/dist/templates/solo-rpg/tools/setup_character.ts +22 -2
  123. package/dist/templates/solo-rpg/tools/update_state.ts +113 -100
  124. package/dist/templates/spoken-summary/agent.test.ts +314 -0
  125. package/dist/templates/spoken-summary/agent.ts +155 -0
  126. package/dist/templates/spoken-summary/client.tsx +185 -0
  127. package/dist/templates/spoken-summary/workflows/summarize.ts +237 -0
  128. package/dist/templates/spoken-summary/workflows/transcribe.ts +138 -0
  129. package/dist/templates/support-line/agent.test.ts +11 -16
  130. package/dist/templates/support-line/agent.ts +1 -1
  131. package/dist/templates/support-line/client.tsx +9 -9
  132. package/dist/templates/support-line/nodes.ts +100 -0
  133. package/dist/templates/support-line/procedure.ts +407 -0
  134. package/dist/templates/support-line/tools/answer_question.ts +17 -4
  135. package/dist/templates/transcription-workflow/agent.test.ts +246 -123
  136. package/dist/templates/transcription-workflow/agent.ts +21 -7
  137. package/dist/templates/transcription-workflow/client.tsx +17 -42
  138. package/dist/templates/transcription-workflow/workflows/batch.ts +79 -180
  139. package/dist/templates/transcription-workflow/workflows/normalize.ts +247 -0
  140. package/dist/templates/transcription-workflow/workflows/stitch.ts +0 -11
  141. package/dist/templates/transcription-workflow/workflows/stream.ts +10 -9
  142. package/dist/templates/transcription-workflow/workflows/sync-api.ts +26 -94
  143. package/dist/templates/transcription-workflow/workflows/transcribe.ts +55 -40
  144. package/dist/templates/transcription-workflow/workflows/wav.ts +31 -31
  145. package/dist/templates/travel-concierge/agent.test.ts +64 -33
  146. package/dist/templates/travel-concierge/client.tsx +11 -23
  147. package/dist/templates/travel-concierge/routing.ts +34 -15
  148. package/dist/templates/travel-concierge/shared.ts +70 -3
  149. package/dist/templates/travel-concierge/tools/book_car_rental.ts +2 -2
  150. package/dist/templates/travel-concierge/tools/book_excursion.ts +2 -2
  151. package/dist/templates/travel-concierge/tools/book_hotel.ts +2 -2
  152. package/dist/templates/travel-concierge/tools/cancel_ticket.ts +2 -2
  153. package/dist/templates/travel-concierge/tools/update_ticket.ts +2 -2
  154. package/dist/{worker-bundler-CGD4r8Kc.mjs → worker-bundler-COxnqstQ.mjs} +221 -3
  155. package/dist/worker-bundler.mjs +1 -1
  156. package/dist/{workflow-CFpxOFfQ.mjs → workflow-D2AQf2Pl.mjs} +28 -1
  157. package/dist/workflow-bundler.d.ts +6 -1
  158. package/dist/workflow.d.ts +1 -1
  159. package/package.json +5 -4
  160. package/dist/templates/support-line/graph.ts +0 -224
  161. package/dist/{_config-D_s09e7g.mjs → _config-CmJOFsAP.mjs} +1 -1
  162. /package/dist/templates/plan-and-execute/{graph.ts → procedure.ts} +0 -0
@@ -0,0 +1,209 @@
1
+ // Copyright 2026 the AAI authors. MIT license.
2
+ /**
3
+ * Delivering the digest — the half of this template that is about a THIRD
4
+ * PARTY's contract rather than about podcasts.
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:
10
+ *
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 |
15
+ *
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.
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.
29
+ */
30
+
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";
35
+ import type { EpisodeDigest } from "./digest.ts";
36
+
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
+ /** Everything the message needs, so rendering can stay a pure function. */
41
+ export type SlackDigestInput = {
42
+ slackWebhookUrl: string;
43
+ slackWorkflowTextParam: string;
44
+ podcastChannels: string;
45
+ episodes: EpisodeDigest[];
46
+ digestNumber: number;
47
+ totalDigests: number;
48
+ };
49
+
50
+ /**
51
+ * The step: post one digest, and classify the failure honestly.
52
+ *
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.
59
+ */
60
+ export async function sendDigestToSlack(input: SlackDigestInput): Promise<string> {
61
+ "use step";
62
+
63
+ 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
+ }
110
+ }
111
+
112
+ /**
113
+ * Accepted at the form's edge: an incoming webhook or a workflow trigger, on
114
+ * one of Slack's two webhook hosts.
115
+ *
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.
121
+ */
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> {
164
+ 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
+ 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
+ ],
183
+ };
184
+ }
185
+
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
+ function digestHeadline(input: SlackDigestInput): string {
196
+ return `Podcast digest ${input.digestNumber}/${input.totalDigests}`;
197
+ }
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
+ }
@@ -27,16 +27,21 @@
27
27
  * runs a real one.
28
28
  */
29
29
 
30
- import type { ToolContext, WorkflowClient, WorkflowRunSnapshot } from "@alexkroman1/aai";
30
+ import type { WorkflowClient } from "@alexkroman1/aai";
31
31
  import {
32
- createProgressStream,
33
32
  createRunSnapshot,
34
- createStubWorkflows,
35
33
  createToolContext,
36
- runTool,
34
+ parseSchemaInput,
35
+ schemaInputIssues,
36
+ toolRunner,
37
37
  withDiscoveredTools,
38
38
  } from "@alexkroman1/aai/testing";
39
- import { installStubGateway as stubGateway } from "@alexkroman1/aai/testing/vitest";
39
+ import {
40
+ installStubStepFetch,
41
+ mockWorkflows,
42
+ installStubGateway as stubGateway,
43
+ } from "@alexkroman1/aai/testing/vitest";
44
+ import type { WorkflowRunSnapshot } from "@alexkroman1/aai/workflow-api";
40
45
  import { beforeEach, describe, expect, test, vi } from "vitest";
41
46
  import { createHook, type Hook, sleep } from "workflow";
42
47
  import authoredAgent from "./agent.ts";
@@ -80,24 +85,27 @@ const agentDef = withDiscoveredTools(
80
85
  import.meta.glob("./tools/*.ts", { eager: true }),
81
86
  );
82
87
 
83
- /** Every tool here is driven through the agent's own table, by the name the model calls. */
84
- const run = (name: string, args: Record<string, unknown>, ctx: ToolContext): Promise<unknown> =>
85
- runTool(agentDef, name, args, ctx);
88
+ /**
89
+ * Every tool here is driven through the agent's own table, by the name the model
90
+ * calls.
91
+ *
92
+ * The second parameter is args-or-context, which is `runTool`'s own shape: four
93
+ * of this desk's five tools take no arguments, and the `{}` those calls were
94
+ * obliged to pass sat between the two values a reader cares about.
95
+ */
96
+ const run = toolRunner(agentDef);
86
97
 
87
- /** A `ctx.workflows` that records `start` and answers `find` from a fixture. */
98
+ /**
99
+ * A `ctx.workflows` that records `start` and answers the lookups from a fixture.
100
+ *
101
+ * `mockWorkflows` (`@alexkroman1/aai/testing/vitest`) is the whole thing — a
102
+ * `vi.fn` per method over one `runs` list, with `stream`/`streamTail` left
103
+ * rejecting because `recap_progress` reads progress through `lastLine` and
104
+ * composing those two by hand is the hazard `lastLine` exists to remove. What
105
+ * is local is only which workflow this desk declares.
106
+ */
88
107
  function stubWorkflows(runs: WorkflowRunSnapshot[] = []): WorkflowClient {
89
- return createStubWorkflows({
90
- start: vi.fn(async () => "wrun_stub"),
91
- get: vi.fn(async () => runs[0]),
92
- find: vi.fn(async () => runs),
93
- recent: vi.fn(async () => runs),
94
- cancel: vi.fn(async () => true),
95
- wakeUp: vi.fn(async () => 0),
96
- // A tail of 0 means "one line written", which is the case the tools read.
97
- streamTail: vi.fn(async () => 0),
98
- stream: vi.fn(async () => createProgressStream([])),
99
- listing: () => [{ name: "recap" }],
100
- });
108
+ return mockWorkflows({ runs, names: ["recap"] });
101
109
  }
102
110
 
103
111
  /** A finished recap, as the workflow's output reaches the tools. */
@@ -122,13 +130,19 @@ describe("the agent declares its workflow", () => {
122
130
  });
123
131
 
124
132
  test("with an input schema, so a bad URL fails at the call site", async () => {
125
- const ok = await recap.input?.["~standard"].validate({
133
+ // `parseSchemaInput` / `schemaInputIssues` rather than a reach through
134
+ // `["~standard"].validate`: that is the vendor WIRE contract, and whether it
135
+ // answers synchronously or with a promise is the vendor's business — a
136
+ // missing `await` there leaves `.issues` undefined and the refusing half
137
+ // passes for the wrong reason.
138
+ const parsed = await parseSchemaInput(recap.input, {
126
139
  url: "https://example.com/a.mp3",
127
140
  requestedBy: "s",
128
141
  });
129
- expect(ok?.issues).toBeUndefined();
130
- const bad = await recap.input?.["~standard"].validate({ url: "not a url", requestedBy: "s" });
131
- expect(bad?.issues).toBeDefined();
142
+ expect(parsed).toMatchObject({ url: "https://example.com/a.mp3" });
143
+ expect(
144
+ await schemaInputIssues(recap.input, { url: "not a url", requestedBy: "s" }),
145
+ ).toBeDefined();
132
146
  });
133
147
 
134
148
  test("and names the credential its steps read, so a deploy checks for it", () => {
@@ -156,7 +170,7 @@ describe("request_recap", () => {
156
170
  test("starts a run keyed by the session, so a later turn can find it", async () => {
157
171
  const workflows = stubWorkflows();
158
172
  const ctx = createToolContext({ workflows });
159
- const result = await run("request_recap", {}, ctx);
173
+ const result = await run("request_recap", ctx);
160
174
 
161
175
  expect(workflows.start).toHaveBeenCalledWith(
162
176
  recap,
@@ -170,7 +184,7 @@ describe("request_recap", () => {
170
184
 
171
185
  test("passes the definition rather than its name", async () => {
172
186
  const workflows = stubWorkflows();
173
- await run("request_recap", {}, createToolContext({ workflows }));
187
+ await run("request_recap", createToolContext({ workflows }));
174
188
  // The def overload is what types the input and turns a rename into a compile
175
189
  // error; a string would still work at runtime and lose both.
176
190
  expect(vi.mocked(workflows.start).mock.calls[0]?.[0]).toBe(recap);
@@ -193,7 +207,7 @@ describe("request_recap", () => {
193
207
  // failure it prevents is not tidiness: a caller who asks twice would
194
208
  // otherwise pay for the same recording being transcribed twice.
195
209
  const workflows = stubWorkflows([createRunSnapshot({ workflow: "recap", status: "running" })]);
196
- const result = await run("request_recap", {}, createToolContext({ workflows }));
210
+ const result = await run("request_recap", createToolContext({ workflows }));
197
211
  expect(result).toMatchObject({ started: false, runId: "wrun_1" });
198
212
  expect(workflows.start).not.toHaveBeenCalled();
199
213
  });
@@ -202,7 +216,7 @@ describe("request_recap", () => {
202
216
  const workflows = stubWorkflows([
203
217
  createRunSnapshot({ workflow: "recap", status: "completed", output: finishedOutput() }),
204
218
  ]);
205
- const result = await run("request_recap", {}, createToolContext({ workflows }));
219
+ const result = await run("request_recap", createToolContext({ workflows }));
206
220
  expect(result).toMatchObject({ started: true });
207
221
  expect(workflows.start).toHaveBeenCalledTimes(1);
208
222
  });
@@ -211,7 +225,7 @@ describe("request_recap", () => {
211
225
  describe("recap_status", () => {
212
226
  test("says nothing was started when the key has no runs", async () => {
213
227
  const ctx = createToolContext({ workflows: stubWorkflows([]) });
214
- const result = await run("recap_status", {}, ctx);
228
+ const result = await run("recap_status", ctx);
215
229
  expect(result).toMatchObject({ runs: [], note: "Nothing started yet." });
216
230
  });
217
231
 
@@ -221,7 +235,7 @@ describe("recap_status", () => {
221
235
  const workflows = stubWorkflows([
222
236
  createRunSnapshot({ workflow: "recap", status: "completed", output: finishedOutput() }),
223
237
  ]);
224
- const result = (await run("recap_status", {}, createToolContext({ workflows }))) as {
238
+ const result = (await run("recap_status", createToolContext({ workflows }))) as {
225
239
  runs: string[];
226
240
  };
227
241
  expect(result.runs[0]).toContain("air quality");
@@ -239,7 +253,6 @@ describe("recap_status", () => {
239
253
  ];
240
254
  const result = (await run(
241
255
  "recap_status",
242
- {},
243
256
  createToolContext({ workflows: stubWorkflows(runs) }),
244
257
  )) as { runs: string[] };
245
258
  expect(result.runs[0]).toContain("transcript deleted");
@@ -249,7 +262,7 @@ describe("recap_status", () => {
249
262
  const ctx = createToolContext({
250
263
  workflows: stubWorkflows([createRunSnapshot({ workflow: "recap", status: "running" })]),
251
264
  });
252
- const result = (await run("recap_status", {}, ctx)) as { runs: string[] };
265
+ const result = (await run("recap_status", ctx)) as { runs: string[] };
253
266
  expect(result.runs[0]).toContain("Still working");
254
267
  });
255
268
 
@@ -262,7 +275,6 @@ describe("recap_status", () => {
262
275
  ];
263
276
  const result = (await run(
264
277
  "recap_status",
265
- {},
266
278
  createToolContext({ workflows: stubWorkflows(runs) }),
267
279
  )) as { runs: string[] };
268
280
  expect(result.runs[0]).toContain("rolled back");
@@ -272,7 +284,7 @@ describe("recap_status", () => {
272
284
  test("bounds how many past runs it reads aloud", async () => {
273
285
  const workflows = stubWorkflows([]);
274
286
  const ctx = createToolContext({ workflows });
275
- await run("recap_status", {}, ctx);
287
+ await run("recap_status", ctx);
276
288
  // A voice reply cannot be a list of twenty runs.
277
289
  expect(workflows.find).toHaveBeenCalledWith(recap, ctx.sessionId, { limit: 3 });
278
290
  });
@@ -281,28 +293,30 @@ describe("recap_status", () => {
281
293
  describe("recap_progress", () => {
282
294
  test("reads the run's own progress line rather than its status", async () => {
283
295
  const workflows = stubWorkflows([createRunSnapshot({ workflow: "recap", status: "running" })]);
284
- vi.mocked(workflows.stream).mockResolvedValue(createProgressStream(["Transcript processing."]));
285
- const result = await run("recap_progress", {}, createToolContext({ workflows }));
296
+ vi.mocked(workflows.lastLine).mockResolvedValue("Transcript processing.");
297
+ const result = await run("recap_progress", createToolContext({ workflows }));
286
298
  expect(result).toMatchObject({ progress: "Transcript processing." });
287
299
  });
288
300
 
289
301
  test("asks for the LAST line, not the whole log", async () => {
290
302
  // Every poll narrates, so a twenty-minute run's whole log is eighty lines.
303
+ // `lastLine` is the whole request — the bound that keeps an empty channel
304
+ // from hanging belongs to the method, so nothing here composes
305
+ // `streamTail` and `stream`.
291
306
  const workflows = stubWorkflows([createRunSnapshot({ workflow: "recap", status: "running" })]);
292
- vi.mocked(workflows.stream).mockResolvedValue(createProgressStream(["a"]));
293
- await run("recap_progress", {}, createToolContext({ workflows }));
294
- expect(workflows.stream).toHaveBeenCalledWith("wrun_1", { startIndex: -1 });
307
+ vi.mocked(workflows.lastLine).mockResolvedValue("a");
308
+ await run("recap_progress", createToolContext({ workflows }));
309
+ expect(workflows.lastLine).toHaveBeenCalledWith("wrun_1");
295
310
  });
296
311
 
297
- test("a run that has written nothing yet says so WITHOUT opening the stream", async () => {
298
- // Not a shortcut: an empty progress channel is never closed, so reading one
299
- // waits for a line that arrives whenever the next step writesi.e. the
300
- // tool hangs instead of answering.
312
+ test("a run that has written nothing yet says so", async () => {
313
+ // `lastLine` resolves `undefined` for an empty channel, and this is the arm
314
+ // the tool branches on. That an empty channel does not HANGit is never
315
+ // closed, so a stream opened on one waits for a line that may never come —
316
+ // is `lastLine`'s own guarantee now, and `aai`'s to test.
301
317
  const workflows = stubWorkflows([createRunSnapshot({ workflow: "recap", status: "running" })]);
302
- vi.mocked(workflows.streamTail).mockResolvedValue(-1);
303
- const result = await run("recap_progress", {}, createToolContext({ workflows }));
318
+ const result = await run("recap_progress", createToolContext({ workflows }));
304
319
  expect(result).toMatchObject({ note: expect.stringContaining("nothing to report") });
305
- expect(workflows.stream).not.toHaveBeenCalled();
306
320
  });
307
321
  });
308
322
 
@@ -343,7 +357,7 @@ describe("keep_transcript — the signal", () => {
343
357
  describe("cancel_recap", () => {
344
358
  test("cancels the live run", async () => {
345
359
  const workflows = stubWorkflows([createRunSnapshot({ workflow: "recap", status: "running" })]);
346
- const result = await run("cancel_recap", {}, createToolContext({ workflows }));
360
+ const result = await run("cancel_recap", createToolContext({ workflows }));
347
361
  expect(workflows.cancel).toHaveBeenCalledWith("wrun_1");
348
362
  expect(result).toMatchObject({ cancelled: true });
349
363
  });
@@ -354,7 +368,7 @@ describe("cancel_recap", () => {
354
368
  // stops replaying the run, so the compensations never fire. A template that
355
369
  // implied otherwise would be teaching the wrong thing.
356
370
  const workflows = stubWorkflows([createRunSnapshot({ workflow: "recap", status: "running" })]);
357
- const result = (await run("cancel_recap", {}, createToolContext({ workflows }))) as {
371
+ const result = (await run("cancel_recap", createToolContext({ workflows }))) as {
358
372
  note: string;
359
373
  };
360
374
  expect(result.note).toContain("left behind");
@@ -365,13 +379,13 @@ describe("cancel_recap", () => {
365
379
  createRunSnapshot({ workflow: "recap", status: "completed", output: finishedOutput() }),
366
380
  ]);
367
381
  vi.mocked(workflows.cancel).mockResolvedValue(false);
368
- const result = await run("cancel_recap", {}, createToolContext({ workflows }));
382
+ const result = await run("cancel_recap", createToolContext({ workflows }));
369
383
  expect(result).toMatchObject({ cancelled: false, note: "That one had already finished." });
370
384
  });
371
385
 
372
386
  test("says nothing was started when the key has no runs", async () => {
373
387
  const workflows = stubWorkflows([]);
374
- const result = await run("cancel_recap", {}, createToolContext({ workflows }));
388
+ const result = await run("cancel_recap", createToolContext({ workflows }));
375
389
  expect(result).toMatchObject({ cancelled: false, note: "Nothing started yet." });
376
390
  expect(workflows.cancel).not.toHaveBeenCalled();
377
391
  });
@@ -379,20 +393,22 @@ describe("cancel_recap", () => {
379
393
 
380
394
  // ---- The steps --------------------------------------------------------------
381
395
 
382
- /** A provider answering `body` with `status`, recording what it was asked. */
396
+ /**
397
+ * A provider answering `body` with `status`, recording what it was asked.
398
+ *
399
+ * Published into `stepFetch`'s OWN slot, not over `globalThis.fetch`. Every
400
+ * request in this file goes through `stepFetch` — `request()` and
401
+ * `discardTranscript` reach it directly, `stepTranscribeSubmitClassified`
402
+ * through the SDK — and `step-fetch.ts` falls back to `globalThis.fetch` only
403
+ * when nothing is published. A global stub therefore passed while exercising a
404
+ * path production never takes; every sibling template already stubs the slot,
405
+ * and `link-digest/agent.test.ts` states the rule this one used to break.
406
+ *
407
+ * `installStubStepFetch` unpublishes on `onTestFinished`, so there is no restore
408
+ * registry here and a stub cannot reach the next file.
409
+ */
383
410
  function stubProvider(body: unknown, status = 200) {
384
- const calls: { url: string; init: RequestInit }[] = [];
385
- vi.stubGlobal(
386
- "fetch",
387
- vi.fn(async (url: string, init: RequestInit = {}) => {
388
- calls.push({ url, init });
389
- return new Response(typeof body === "string" ? body : JSON.stringify(body), {
390
- status,
391
- headers: { "Content-Type": "application/json" },
392
- });
393
- }),
394
- );
395
- return calls;
411
+ return installStubStepFetch(() => ({ status, body })).calls;
396
412
  }
397
413
 
398
414
  describe("submitRecording", () => {
@@ -407,14 +423,21 @@ describe("submitRecording", () => {
407
423
  expect(await submitRecording("https://example.com/a.mp3")).toEqual({ id: "t_1" });
408
424
 
409
425
  const call = calls[0];
410
- expect(call?.init.method).toBe("POST");
411
- expect(JSON.parse(String(call?.init.body))).toMatchObject({
426
+ expect(call?.method).toBe("POST");
427
+ // `speaker_labels` is this desk's own request, carried through the SDK's
428
+ // `params` passthrough; the model field is the SDK's and is PLURAL.
429
+ expect(JSON.parse(String(call?.body))).toMatchObject({
412
430
  audio_url: "https://example.com/a.mp3",
431
+ speaker_labels: true,
413
432
  });
414
- // AssemblyAI's `authorization` takes the key RAW — no `Bearer` prefix,
415
- // unlike the OpenAI-compatible LLM gateway `summarize` calls.
416
- const headers = call?.init.headers as Record<string, string> | undefined;
417
- expect(headers?.authorization).toBe("sk-test");
433
+ // AssemblyAI takes the key RAW — no `Bearer` prefix, unlike the
434
+ // OpenAI-compatible LLM gateway `summarize` calls. The SDK spells the
435
+ // header `Authorization`; HTTP header names are case-insensitive, so the
436
+ // lookup is too rather than pinning one casing.
437
+ const auth = Object.entries(call?.headers ?? {}).find(
438
+ ([name]) => name.toLowerCase() === "authorization",
439
+ );
440
+ expect(auth?.[1]).toBe("sk-test");
418
441
  });
419
442
 
420
443
  test("fails FATALLY on a bad key and plainly on a rate limit", async () => {
@@ -480,7 +503,7 @@ describe("discardTranscript — the compensation", () => {
480
503
  test("deletes the transcript this run created", async () => {
481
504
  const calls = stubProvider({ id: "t_1" });
482
505
  await expect(discardTranscript("t_1")).resolves.toBeUndefined();
483
- expect(calls[0]?.init.method).toBe("DELETE");
506
+ expect(calls[0]?.method).toBe("DELETE");
484
507
  expect(calls[0]?.url).toContain("/t_1");
485
508
  });
486
509
 
@@ -572,19 +595,11 @@ describe("awaitTranscript — the polling port", () => {
572
595
 
573
596
  /** A provider whose status endpoint answers `statuses` in order. */
574
597
  function stubStatuses(statuses: readonly Record<string, unknown>[]) {
575
- let call = 0;
576
- vi.stubGlobal(
577
- "fetch",
578
- vi.fn(async () => {
579
- const body = statuses[Math.min(call, statuses.length - 1)];
580
- call += 1;
581
- return new Response(JSON.stringify(body), {
582
- status: 200,
583
- headers: { "Content-Type": "application/json" },
584
- });
585
- }),
586
- );
587
- return () => call;
598
+ // Into `stepFetch`'s slot, for the reason `stubProvider` above gives.
599
+ const stub = installStubStepFetch(() => ({
600
+ body: statuses[Math.min(stub.calls.length - 1, statuses.length - 1)],
601
+ }));
602
+ return () => stub.calls.length;
588
603
  }
589
604
 
590
605
  test("keeps polling while the job is queued or processing", async () => {
@@ -657,15 +672,14 @@ describe("askWhetherToKeep — the expense port", () => {
657
672
 
658
673
  test("keeps the transcript when the caller says to, and deletes nothing", async () => {
659
674
  vi.mocked(createHook).mockReturnValue(hookAnswering({ keep: true }));
660
- const fetchSpy = vi.fn();
661
- vi.stubGlobal("fetch", fetchSpy);
675
+ const provider = installStubStepFetch();
662
676
 
663
677
  const compensations = [{ label: "transcript t_1", undo: async () => undefined }];
664
678
  expect(await askWhetherToKeep("s_1", "t_1", compensations)).toEqual({
665
679
  kept: true,
666
680
  answered: true,
667
681
  });
668
- expect(fetchSpy).not.toHaveBeenCalled();
682
+ expect(provider.calls).toEqual([]);
669
683
  // The undo stays on the stack: a later failure still has something to reverse.
670
684
  expect(compensations).toHaveLength(1);
671
685
  });
@@ -679,7 +693,7 @@ describe("askWhetherToKeep — the expense port", () => {
679
693
  kept: false,
680
694
  answered: true,
681
695
  });
682
- expect(calls[0]?.init.method).toBe("DELETE");
696
+ expect(calls[0]?.method).toBe("DELETE");
683
697
  // Leaving it would be harmless — the undo tolerates a 404 — and would still
684
698
  // narrate an unwind that reverses something already gone.
685
699
  expect(compensations).toHaveLength(0);
@@ -692,7 +706,7 @@ describe("askWhetherToKeep — the expense port", () => {
692
706
  const calls = stubProvider({ id: "t_1" });
693
707
 
694
708
  expect(await askWhetherToKeep("s_1", "t_1", [])).toEqual({ kept: false, answered: false });
695
- expect(calls[0]?.init.method).toBe("DELETE");
709
+ expect(calls[0]?.method).toBe("DELETE");
696
710
  });
697
711
 
698
712
  test("claims the token BEFORE the caller is asked to answer it", async () => {
@@ -703,13 +717,10 @@ describe("askWhetherToKeep — the expense port", () => {
703
717
  vi.mocked(createHook).mockReturnValue(
704
718
  hookAnswering({ keep: true }, () => order.push("claimed")),
705
719
  );
706
- vi.stubGlobal(
707
- "fetch",
708
- vi.fn(async () => {
709
- order.push("asked");
710
- return new Response("{}", { status: 200 });
711
- }),
712
- );
720
+ installStubStepFetch(() => {
721
+ order.push("asked");
722
+ return { body: {} };
723
+ });
713
724
 
714
725
  await askWhetherToKeep("s_1", "t_1", []);
715
726
  expect(order[0]).toBe("claimed");
@@ -11,17 +11,14 @@ export default tool({
11
11
  // otherwise nothing to say — and this run has real news in between,
12
12
  // since every poll narrates.
13
13
  //
14
- // `streamTail` FIRST, and not as an optimization: a progress channel is
15
- // never closed — no step knows it is the last one — so reading a stream
16
- // with nothing in it waits forever rather than ending. `-1` is "nothing
17
- // written yet", and it is the only safe way to learn that.
18
- if ((await ctx.workflows.streamTail(latest.runId)) < 0) {
19
- return { note: "Submitted, nothing to report yet." };
20
- }
21
- // A negative `startIndex` reads from the END, which is what a voice
22
- // reply wants — the last line, not a recital of the whole log.
23
- const stream = await ctx.workflows.stream(latest.runId, { startIndex: -1 });
24
- for await (const line of stream) return { progress: String(line) };
25
- return { note: "Submitted, nothing to report yet." };
14
+ // `lastLine` rather than `streamTail` + `stream` composed here: a progress
15
+ // channel is never closed — no step knows it is the last one — so a stream
16
+ // opened on a run that has written nothing waits forever, which down a phone
17
+ // is a turn that stops with no error and nothing in a log. The bound that
18
+ // prevents it belongs to the method now, and `undefined` is "nothing yet".
19
+ const line = await ctx.workflows.lastLine(latest.runId);
20
+ return line === undefined
21
+ ? { note: "Submitted, nothing to report yet." }
22
+ : { progress: String(line) };
26
23
  },
27
24
  });