@alexkroman1/aai-cli 6.11.0 → 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 +40 -19
  26. package/dist/scaffold/package.json +5 -3
  27. package/dist/scaffold/server.mjs +1 -1
  28. package/dist/{secret-BuMuFR4B.mjs → secret-CVvSLIDV.mjs} +2 -2
  29. package/dist/{storage-DzRZ-eCw.mjs → storage-BvUrnvM3.mjs} +2 -2
  30. package/dist/{studio-BCNUpDgP.mjs → studio--MUV0cid.mjs} +4 -4
  31. package/dist/templates/call-audit/agent.test.ts +57 -204
  32. package/dist/templates/call-audit/agent.ts +32 -19
  33. package/dist/templates/call-audit/client.tsx +17 -54
  34. package/dist/templates/call-audit/workflows/audit.ts +11 -18
  35. package/dist/templates/call-audit/workflows/ingest.ts +106 -114
  36. package/dist/templates/call-audit/workflows/media.ts +2 -12
  37. package/dist/templates/call-audit/workflows/summarize.ts +47 -52
  38. package/dist/templates/call-audit/workflows/sync-api.ts +7 -7
  39. package/dist/templates/dispatch-center/agent.test.ts +149 -25
  40. package/dist/templates/dispatch-center/client.tsx +239 -129
  41. package/dist/templates/dispatch-center/shared.ts +99 -1
  42. package/dist/templates/dispatch-center/system-prompt.md +3 -1
  43. package/dist/templates/dispatch-center/tools/incident_add_note.ts +16 -13
  44. package/dist/templates/dispatch-center/tools/incident_create.ts +19 -1
  45. package/dist/templates/dispatch-center/tools/incident_escalate.ts +68 -59
  46. package/dist/templates/dispatch-center/tools/incident_triage.ts +57 -43
  47. package/dist/templates/dispatch-center/tools/incident_update_status.ts +49 -40
  48. package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +8 -2
  49. package/dist/templates/dispatch-center/tools/resources_dispatch.ts +69 -52
  50. package/dist/templates/dispatch-center/tools/resources_update_status.ts +45 -41
  51. package/dist/templates/embedded-assets/agent.test.ts +17 -14
  52. package/dist/templates/health-assistant/agent.test.ts +22 -4
  53. package/dist/templates/infocom-adventure/agent.test.ts +4 -6
  54. package/dist/templates/infocom-adventure/client.tsx +246 -164
  55. package/dist/templates/link-digest/agent.test.ts +24 -19
  56. package/dist/templates/link-digest/client.tsx +47 -61
  57. package/dist/templates/link-digest/workflows/digest.ts +19 -31
  58. package/dist/templates/night-owl/agent.test.ts +70 -19
  59. package/dist/templates/night-owl/agent.ts +5 -0
  60. package/dist/templates/night-owl/client.tsx +56 -56
  61. package/dist/templates/night-owl/shared.ts +24 -0
  62. package/dist/templates/night-owl/tools/recommend.ts +18 -6
  63. package/dist/templates/pizza-ordering/agent.test.ts +27 -27
  64. package/dist/templates/pizza-ordering/client.tsx +9 -26
  65. package/dist/templates/plan-and-execute/agent.test.ts +99 -58
  66. package/dist/templates/plan-and-execute/agent.ts +1 -1
  67. package/dist/templates/plan-and-execute/client.tsx +12 -15
  68. package/dist/templates/plan-and-execute/shared.ts +71 -2
  69. package/dist/templates/plan-and-execute/tools/plan_status.ts +18 -6
  70. package/dist/templates/plan-and-execute/tools/revise_plan.ts +18 -10
  71. package/dist/templates/plan-and-execute/tools/start_plan.ts +13 -2
  72. package/dist/templates/plan-and-execute/tools/work_next_step.ts +70 -22
  73. package/dist/templates/podcast-digest/agent.test.ts +746 -0
  74. package/dist/templates/podcast-digest/agent.ts +139 -0
  75. package/dist/templates/podcast-digest/client.tsx +154 -0
  76. package/dist/templates/podcast-digest/workflows/digest.ts +411 -0
  77. package/dist/templates/podcast-digest/workflows/feeds.ts +507 -0
  78. package/dist/templates/podcast-digest/workflows/slack.ts +209 -0
  79. package/dist/templates/recap-workflow/agent.test.ts +97 -94
  80. package/dist/templates/recap-workflow/tools/recap_progress.ts +9 -12
  81. package/dist/templates/recap-workflow/tools/recap_status.ts +8 -9
  82. package/dist/templates/recap-workflow/tools/request_recap.ts +2 -1
  83. package/dist/templates/recap-workflow/workflows/recap.ts +36 -40
  84. package/dist/templates/redline/agent.test.ts +17 -15
  85. package/dist/templates/redline/client.tsx +12 -12
  86. package/dist/templates/redline/workflows/redline.ts +19 -31
  87. package/dist/templates/research-workflow/agent.test.ts +60 -59
  88. package/dist/templates/research-workflow/tools/research_progress.ts +9 -12
  89. package/dist/templates/research-workflow/tools/research_status.ts +9 -11
  90. package/dist/templates/research-workflow/workflows/research.ts +44 -61
  91. package/dist/templates/retail/agent.test.ts +26 -23
  92. package/dist/templates/retail/client.tsx +226 -117
  93. package/dist/templates/retail/registry.test.ts +38 -6
  94. package/dist/templates/retail/store.test.ts +82 -15
  95. package/dist/templates/retail/store.ts +174 -47
  96. package/dist/templates/retail/system-prompt.md +11 -2
  97. package/dist/templates/retail/tools/cancel_pending_order.ts +2 -2
  98. package/dist/templates/retail/tools/exchange_delivered_order_items.ts +2 -2
  99. package/dist/templates/retail/tools/find_user_id_by_email.ts +8 -5
  100. package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +4 -5
  101. package/dist/templates/retail/tools/get_item_details.ts +3 -4
  102. package/dist/templates/retail/tools/get_order_details.ts +3 -4
  103. package/dist/templates/retail/tools/get_product_details.ts +3 -4
  104. package/dist/templates/retail/tools/get_user_details.ts +2 -2
  105. package/dist/templates/retail/tools/list_all_product_types.ts +5 -5
  106. package/dist/templates/retail/tools/modify_pending_order_address.ts +2 -2
  107. package/dist/templates/retail/tools/modify_pending_order_items.ts +2 -3
  108. package/dist/templates/retail/tools/modify_pending_order_payment.ts +2 -2
  109. package/dist/templates/retail/tools/modify_user_address.ts +2 -2
  110. package/dist/templates/retail/tools/return_delivered_order_items.ts +2 -2
  111. package/dist/templates/retail/tools/transfer_to_human_agents.ts +8 -4
  112. package/dist/templates/solo-rpg/agent.test.ts +227 -48
  113. package/dist/templates/solo-rpg/client.tsx +38 -37
  114. package/dist/templates/solo-rpg/shared.ts +145 -19
  115. package/dist/templates/solo-rpg/system-prompt.md +3 -2
  116. package/dist/templates/solo-rpg/tools/action_roll.ts +82 -63
  117. package/dist/templates/solo-rpg/tools/burn_momentum.ts +85 -50
  118. package/dist/templates/solo-rpg/tools/check_state.ts +24 -7
  119. package/dist/templates/solo-rpg/tools/load_game.ts +13 -1
  120. package/dist/templates/solo-rpg/tools/save_game.ts +16 -7
  121. package/dist/templates/solo-rpg/tools/setup_character.ts +22 -2
  122. package/dist/templates/solo-rpg/tools/update_state.ts +113 -100
  123. package/dist/templates/spoken-summary/agent.test.ts +68 -97
  124. package/dist/templates/spoken-summary/agent.ts +30 -17
  125. package/dist/templates/spoken-summary/client.tsx +10 -50
  126. package/dist/templates/spoken-summary/workflows/summarize.ts +17 -22
  127. package/dist/templates/spoken-summary/workflows/transcribe.ts +19 -26
  128. package/dist/templates/support-line/agent.test.ts +11 -16
  129. package/dist/templates/support-line/agent.ts +1 -1
  130. package/dist/templates/support-line/client.tsx +9 -9
  131. package/dist/templates/support-line/nodes.ts +100 -0
  132. package/dist/templates/support-line/procedure.ts +407 -0
  133. package/dist/templates/support-line/tools/answer_question.ts +17 -4
  134. package/dist/templates/transcription-workflow/agent.test.ts +94 -194
  135. package/dist/templates/transcription-workflow/agent.ts +1 -1
  136. package/dist/templates/transcription-workflow/client.tsx +17 -42
  137. package/dist/templates/transcription-workflow/workflows/batch.ts +19 -22
  138. package/dist/templates/transcription-workflow/workflows/normalize.ts +90 -186
  139. package/dist/templates/transcription-workflow/workflows/stitch.ts +0 -11
  140. package/dist/templates/transcription-workflow/workflows/stream.ts +4 -5
  141. package/dist/templates/transcription-workflow/workflows/sync-api.ts +6 -6
  142. package/dist/templates/transcription-workflow/workflows/transcribe.ts +32 -26
  143. package/dist/templates/transcription-workflow/workflows/wav.ts +0 -31
  144. package/dist/templates/travel-concierge/agent.test.ts +64 -33
  145. package/dist/templates/travel-concierge/client.tsx +11 -23
  146. package/dist/templates/travel-concierge/routing.ts +34 -15
  147. package/dist/templates/travel-concierge/shared.ts +70 -3
  148. package/dist/templates/travel-concierge/tools/book_car_rental.ts +2 -2
  149. package/dist/templates/travel-concierge/tools/book_excursion.ts +2 -2
  150. package/dist/templates/travel-concierge/tools/book_hotel.ts +2 -2
  151. package/dist/templates/travel-concierge/tools/cancel_ticket.ts +2 -2
  152. package/dist/templates/travel-concierge/tools/update_ticket.ts +2 -2
  153. package/dist/{worker-bundler-CGD4r8Kc.mjs → worker-bundler-COxnqstQ.mjs} +221 -3
  154. package/dist/worker-bundler.mjs +1 -1
  155. package/dist/{workflow-CFpxOFfQ.mjs → workflow-D2AQf2Pl.mjs} +28 -1
  156. package/dist/workflow-bundler.d.ts +6 -1
  157. package/dist/workflow.d.ts +1 -1
  158. package/package.json +5 -4
  159. package/dist/templates/call-audit/workflows/temp-media.ts +0 -138
  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,10 +423,10 @@ 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");
426
+ expect(call?.method).toBe("POST");
411
427
  // `speaker_labels` is this desk's own request, carried through the SDK's
412
428
  // `params` passthrough; the model field is the SDK's and is PLURAL.
413
- expect(JSON.parse(String(call?.init.body))).toMatchObject({
429
+ expect(JSON.parse(String(call?.body))).toMatchObject({
414
430
  audio_url: "https://example.com/a.mp3",
415
431
  speaker_labels: true,
416
432
  });
@@ -418,8 +434,7 @@ describe("submitRecording", () => {
418
434
  // OpenAI-compatible LLM gateway `summarize` calls. The SDK spells the
419
435
  // header `Authorization`; HTTP header names are case-insensitive, so the
420
436
  // lookup is too rather than pinning one casing.
421
- const headers = call?.init.headers as Record<string, string> | undefined;
422
- const auth = Object.entries(headers ?? {}).find(
437
+ const auth = Object.entries(call?.headers ?? {}).find(
423
438
  ([name]) => name.toLowerCase() === "authorization",
424
439
  );
425
440
  expect(auth?.[1]).toBe("sk-test");
@@ -488,7 +503,7 @@ describe("discardTranscript — the compensation", () => {
488
503
  test("deletes the transcript this run created", async () => {
489
504
  const calls = stubProvider({ id: "t_1" });
490
505
  await expect(discardTranscript("t_1")).resolves.toBeUndefined();
491
- expect(calls[0]?.init.method).toBe("DELETE");
506
+ expect(calls[0]?.method).toBe("DELETE");
492
507
  expect(calls[0]?.url).toContain("/t_1");
493
508
  });
494
509
 
@@ -580,19 +595,11 @@ describe("awaitTranscript — the polling port", () => {
580
595
 
581
596
  /** A provider whose status endpoint answers `statuses` in order. */
582
597
  function stubStatuses(statuses: readonly Record<string, unknown>[]) {
583
- let call = 0;
584
- vi.stubGlobal(
585
- "fetch",
586
- vi.fn(async () => {
587
- const body = statuses[Math.min(call, statuses.length - 1)];
588
- call += 1;
589
- return new Response(JSON.stringify(body), {
590
- status: 200,
591
- headers: { "Content-Type": "application/json" },
592
- });
593
- }),
594
- );
595
- 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;
596
603
  }
597
604
 
598
605
  test("keeps polling while the job is queued or processing", async () => {
@@ -665,15 +672,14 @@ describe("askWhetherToKeep — the expense port", () => {
665
672
 
666
673
  test("keeps the transcript when the caller says to, and deletes nothing", async () => {
667
674
  vi.mocked(createHook).mockReturnValue(hookAnswering({ keep: true }));
668
- const fetchSpy = vi.fn();
669
- vi.stubGlobal("fetch", fetchSpy);
675
+ const provider = installStubStepFetch();
670
676
 
671
677
  const compensations = [{ label: "transcript t_1", undo: async () => undefined }];
672
678
  expect(await askWhetherToKeep("s_1", "t_1", compensations)).toEqual({
673
679
  kept: true,
674
680
  answered: true,
675
681
  });
676
- expect(fetchSpy).not.toHaveBeenCalled();
682
+ expect(provider.calls).toEqual([]);
677
683
  // The undo stays on the stack: a later failure still has something to reverse.
678
684
  expect(compensations).toHaveLength(1);
679
685
  });
@@ -687,7 +693,7 @@ describe("askWhetherToKeep — the expense port", () => {
687
693
  kept: false,
688
694
  answered: true,
689
695
  });
690
- expect(calls[0]?.init.method).toBe("DELETE");
696
+ expect(calls[0]?.method).toBe("DELETE");
691
697
  // Leaving it would be harmless — the undo tolerates a 404 — and would still
692
698
  // narrate an unwind that reverses something already gone.
693
699
  expect(compensations).toHaveLength(0);
@@ -700,7 +706,7 @@ describe("askWhetherToKeep — the expense port", () => {
700
706
  const calls = stubProvider({ id: "t_1" });
701
707
 
702
708
  expect(await askWhetherToKeep("s_1", "t_1", [])).toEqual({ kept: false, answered: false });
703
- expect(calls[0]?.init.method).toBe("DELETE");
709
+ expect(calls[0]?.method).toBe("DELETE");
704
710
  });
705
711
 
706
712
  test("claims the token BEFORE the caller is asked to answer it", async () => {
@@ -711,13 +717,10 @@ describe("askWhetherToKeep — the expense port", () => {
711
717
  vi.mocked(createHook).mockReturnValue(
712
718
  hookAnswering({ keep: true }, () => order.push("claimed")),
713
719
  );
714
- vi.stubGlobal(
715
- "fetch",
716
- vi.fn(async () => {
717
- order.push("asked");
718
- return new Response("{}", { status: 200 });
719
- }),
720
- );
720
+ installStubStepFetch(() => {
721
+ order.push("asked");
722
+ return { body: {} };
723
+ });
721
724
 
722
725
  await askWhetherToKeep("s_1", "t_1", []);
723
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
  });
@@ -1,9 +1,5 @@
1
- import {
2
- isTerminal,
3
- tool,
4
- type WorkflowOutputOf,
5
- type WorkflowRunSnapshot,
6
- } from "@alexkroman1/aai";
1
+ import { tool } from "@alexkroman1/aai";
2
+ import { isTerminal, type WorkflowRunOf } from "@alexkroman1/aai/workflow-api";
7
3
  import { recap } from "../shared.ts";
8
4
 
9
5
  /** How many past runs the status tool will look at. Newest first. */
@@ -14,10 +10,13 @@ const RECENT_RUNS = 3;
14
10
  *
15
11
  * This is the QUERY, and `isTerminal` is what makes it typed: it narrows to the
16
12
  * three finished statuses, which is what puts `run.output` and `run.error`
17
- * within reach without a cast. `WorkflowOutputOf` names the output type from the
18
- * declaration, so this signature never reaches past it into the body.
13
+ * within reach without a cast. `WorkflowRunOf` is the snapshot with that output
14
+ * already named from the declaration the
15
+ * `WorkflowRunSnapshot<WorkflowOutputOf<typeof recap>>` this file used to
16
+ * compose by hand, for a three-name import — so the signature never reaches past
17
+ * the declaration into the body, and it is still the discriminated union.
19
18
  */
20
- function describeRun(run: WorkflowRunSnapshot<WorkflowOutputOf<typeof recap>>): string {
19
+ function describeRun(run: WorkflowRunOf<typeof recap>): string {
21
20
  if (!isTerminal(run)) return "Still working on that one.";
22
21
  switch (run.status) {
23
22
  case "completed": {