@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.
- package/dist/{_agent-DpH2pBJd.mjs → _agent-BTwZJu4P.mjs} +2 -2
- package/dist/{_bundler-C2BDwU7r.mjs → _bundler-B4RqNF5Z.mjs} +2 -2
- package/dist/_dev-env.d.ts +22 -2
- package/dist/{_dev-server-LLLWnFBs.mjs → _dev-server-CiKFH9qw.mjs} +38 -47
- package/dist/_dev-server.d.ts +0 -17
- package/dist/{_init-C57bWAQ8.mjs → _init-CE4HKdgV.mjs} +3 -2
- package/dist/{_preflight-BtfaYtbE.mjs → _preflight-Co1G9Jww.mjs} +1 -1
- package/dist/_preflight.d.ts +1 -1
- package/dist/{_slug-api-BxrsJXVI.mjs → _slug-api-nRFaBEPJ.mjs} +2 -2
- package/dist/{_studio-U3jhKlA3.mjs → _studio-DR5yzZ1T.mjs} +2 -1
- package/dist/{_templates-Dxb_P2Wz.mjs → _templates-FI3xPEMj.mjs} +1 -1
- package/dist/_workflow-scan.d.ts +108 -0
- package/dist/{build-Dfza2pRI.mjs → build-6jfaakgc.mjs} +5 -2
- package/dist/cli.mjs +30 -25
- package/dist/{client-bundler-BEIqgOtd.mjs → client-bundler-CWnG42cU.mjs} +2 -1
- package/dist/client-bundler.mjs +1 -1
- package/dist/{delete-vbpjGzqs.mjs → delete-Dl_LSfvI.mjs} +3 -3
- package/dist/{deploy-B8lubiRT.mjs → deploy-DvYeD8ia.mjs} +5 -5
- package/dist/{dev-Uq5ujP8a.mjs → dev-B59JIfWO.mjs} +1 -1
- package/dist/{eject-31gjtaHF.mjs → eject-C9WJyyr2.mjs} +1 -1
- package/dist/{init-l6xfU4xX.mjs → init-CpJVjEg0.mjs} +3 -3
- package/dist/{login-C71-qz8F.mjs → login-BBuM1sxH.mjs} +3 -4
- package/dist/{logs-BWVGSjLR.mjs → logs-9q-psa4q.mjs} +2 -2
- package/dist/project-config.mjs +1 -1
- package/dist/scaffold/CLAUDE.md +40 -19
- package/dist/scaffold/package.json +5 -3
- package/dist/scaffold/server.mjs +1 -1
- package/dist/{secret-BuMuFR4B.mjs → secret-CVvSLIDV.mjs} +2 -2
- package/dist/{storage-DzRZ-eCw.mjs → storage-BvUrnvM3.mjs} +2 -2
- package/dist/{studio-BCNUpDgP.mjs → studio--MUV0cid.mjs} +4 -4
- package/dist/templates/call-audit/agent.test.ts +57 -204
- package/dist/templates/call-audit/agent.ts +32 -19
- package/dist/templates/call-audit/client.tsx +17 -54
- package/dist/templates/call-audit/workflows/audit.ts +11 -18
- package/dist/templates/call-audit/workflows/ingest.ts +106 -114
- package/dist/templates/call-audit/workflows/media.ts +2 -12
- package/dist/templates/call-audit/workflows/summarize.ts +47 -52
- package/dist/templates/call-audit/workflows/sync-api.ts +7 -7
- package/dist/templates/dispatch-center/agent.test.ts +149 -25
- package/dist/templates/dispatch-center/client.tsx +239 -129
- package/dist/templates/dispatch-center/shared.ts +99 -1
- package/dist/templates/dispatch-center/system-prompt.md +3 -1
- package/dist/templates/dispatch-center/tools/incident_add_note.ts +16 -13
- package/dist/templates/dispatch-center/tools/incident_create.ts +19 -1
- package/dist/templates/dispatch-center/tools/incident_escalate.ts +68 -59
- package/dist/templates/dispatch-center/tools/incident_triage.ts +57 -43
- package/dist/templates/dispatch-center/tools/incident_update_status.ts +49 -40
- package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +8 -2
- package/dist/templates/dispatch-center/tools/resources_dispatch.ts +69 -52
- package/dist/templates/dispatch-center/tools/resources_update_status.ts +45 -41
- package/dist/templates/embedded-assets/agent.test.ts +17 -14
- package/dist/templates/health-assistant/agent.test.ts +22 -4
- package/dist/templates/infocom-adventure/agent.test.ts +4 -6
- package/dist/templates/infocom-adventure/client.tsx +246 -164
- package/dist/templates/link-digest/agent.test.ts +24 -19
- package/dist/templates/link-digest/client.tsx +47 -61
- package/dist/templates/link-digest/workflows/digest.ts +19 -31
- package/dist/templates/night-owl/agent.test.ts +70 -19
- package/dist/templates/night-owl/agent.ts +5 -0
- package/dist/templates/night-owl/client.tsx +56 -56
- package/dist/templates/night-owl/shared.ts +24 -0
- package/dist/templates/night-owl/tools/recommend.ts +18 -6
- package/dist/templates/pizza-ordering/agent.test.ts +27 -27
- package/dist/templates/pizza-ordering/client.tsx +9 -26
- package/dist/templates/plan-and-execute/agent.test.ts +99 -58
- package/dist/templates/plan-and-execute/agent.ts +1 -1
- package/dist/templates/plan-and-execute/client.tsx +12 -15
- package/dist/templates/plan-and-execute/shared.ts +71 -2
- package/dist/templates/plan-and-execute/tools/plan_status.ts +18 -6
- package/dist/templates/plan-and-execute/tools/revise_plan.ts +18 -10
- package/dist/templates/plan-and-execute/tools/start_plan.ts +13 -2
- package/dist/templates/plan-and-execute/tools/work_next_step.ts +70 -22
- package/dist/templates/podcast-digest/agent.test.ts +746 -0
- package/dist/templates/podcast-digest/agent.ts +139 -0
- package/dist/templates/podcast-digest/client.tsx +154 -0
- package/dist/templates/podcast-digest/workflows/digest.ts +411 -0
- package/dist/templates/podcast-digest/workflows/feeds.ts +507 -0
- package/dist/templates/podcast-digest/workflows/slack.ts +209 -0
- package/dist/templates/recap-workflow/agent.test.ts +97 -94
- package/dist/templates/recap-workflow/tools/recap_progress.ts +9 -12
- package/dist/templates/recap-workflow/tools/recap_status.ts +8 -9
- package/dist/templates/recap-workflow/tools/request_recap.ts +2 -1
- package/dist/templates/recap-workflow/workflows/recap.ts +36 -40
- package/dist/templates/redline/agent.test.ts +17 -15
- package/dist/templates/redline/client.tsx +12 -12
- package/dist/templates/redline/workflows/redline.ts +19 -31
- package/dist/templates/research-workflow/agent.test.ts +60 -59
- package/dist/templates/research-workflow/tools/research_progress.ts +9 -12
- package/dist/templates/research-workflow/tools/research_status.ts +9 -11
- package/dist/templates/research-workflow/workflows/research.ts +44 -61
- package/dist/templates/retail/agent.test.ts +26 -23
- package/dist/templates/retail/client.tsx +226 -117
- package/dist/templates/retail/registry.test.ts +38 -6
- package/dist/templates/retail/store.test.ts +82 -15
- package/dist/templates/retail/store.ts +174 -47
- package/dist/templates/retail/system-prompt.md +11 -2
- package/dist/templates/retail/tools/cancel_pending_order.ts +2 -2
- package/dist/templates/retail/tools/exchange_delivered_order_items.ts +2 -2
- package/dist/templates/retail/tools/find_user_id_by_email.ts +8 -5
- package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +4 -5
- package/dist/templates/retail/tools/get_item_details.ts +3 -4
- package/dist/templates/retail/tools/get_order_details.ts +3 -4
- package/dist/templates/retail/tools/get_product_details.ts +3 -4
- package/dist/templates/retail/tools/get_user_details.ts +2 -2
- package/dist/templates/retail/tools/list_all_product_types.ts +5 -5
- package/dist/templates/retail/tools/modify_pending_order_address.ts +2 -2
- package/dist/templates/retail/tools/modify_pending_order_items.ts +2 -3
- package/dist/templates/retail/tools/modify_pending_order_payment.ts +2 -2
- package/dist/templates/retail/tools/modify_user_address.ts +2 -2
- package/dist/templates/retail/tools/return_delivered_order_items.ts +2 -2
- package/dist/templates/retail/tools/transfer_to_human_agents.ts +8 -4
- package/dist/templates/solo-rpg/agent.test.ts +227 -48
- package/dist/templates/solo-rpg/client.tsx +38 -37
- package/dist/templates/solo-rpg/shared.ts +145 -19
- package/dist/templates/solo-rpg/system-prompt.md +3 -2
- package/dist/templates/solo-rpg/tools/action_roll.ts +82 -63
- package/dist/templates/solo-rpg/tools/burn_momentum.ts +85 -50
- package/dist/templates/solo-rpg/tools/check_state.ts +24 -7
- package/dist/templates/solo-rpg/tools/load_game.ts +13 -1
- package/dist/templates/solo-rpg/tools/save_game.ts +16 -7
- package/dist/templates/solo-rpg/tools/setup_character.ts +22 -2
- package/dist/templates/solo-rpg/tools/update_state.ts +113 -100
- package/dist/templates/spoken-summary/agent.test.ts +68 -97
- package/dist/templates/spoken-summary/agent.ts +30 -17
- package/dist/templates/spoken-summary/client.tsx +10 -50
- package/dist/templates/spoken-summary/workflows/summarize.ts +17 -22
- package/dist/templates/spoken-summary/workflows/transcribe.ts +19 -26
- package/dist/templates/support-line/agent.test.ts +11 -16
- package/dist/templates/support-line/agent.ts +1 -1
- package/dist/templates/support-line/client.tsx +9 -9
- package/dist/templates/support-line/nodes.ts +100 -0
- package/dist/templates/support-line/procedure.ts +407 -0
- package/dist/templates/support-line/tools/answer_question.ts +17 -4
- package/dist/templates/transcription-workflow/agent.test.ts +94 -194
- package/dist/templates/transcription-workflow/agent.ts +1 -1
- package/dist/templates/transcription-workflow/client.tsx +17 -42
- package/dist/templates/transcription-workflow/workflows/batch.ts +19 -22
- package/dist/templates/transcription-workflow/workflows/normalize.ts +90 -186
- package/dist/templates/transcription-workflow/workflows/stitch.ts +0 -11
- package/dist/templates/transcription-workflow/workflows/stream.ts +4 -5
- package/dist/templates/transcription-workflow/workflows/sync-api.ts +6 -6
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +32 -26
- package/dist/templates/transcription-workflow/workflows/wav.ts +0 -31
- package/dist/templates/travel-concierge/agent.test.ts +64 -33
- package/dist/templates/travel-concierge/client.tsx +11 -23
- package/dist/templates/travel-concierge/routing.ts +34 -15
- package/dist/templates/travel-concierge/shared.ts +70 -3
- package/dist/templates/travel-concierge/tools/book_car_rental.ts +2 -2
- package/dist/templates/travel-concierge/tools/book_excursion.ts +2 -2
- package/dist/templates/travel-concierge/tools/book_hotel.ts +2 -2
- package/dist/templates/travel-concierge/tools/cancel_ticket.ts +2 -2
- package/dist/templates/travel-concierge/tools/update_ticket.ts +2 -2
- package/dist/{worker-bundler-CGD4r8Kc.mjs → worker-bundler-COxnqstQ.mjs} +221 -3
- package/dist/worker-bundler.mjs +1 -1
- package/dist/{workflow-CFpxOFfQ.mjs → workflow-D2AQf2Pl.mjs} +28 -1
- package/dist/workflow-bundler.d.ts +6 -1
- package/dist/workflow.d.ts +1 -1
- package/package.json +5 -4
- package/dist/templates/call-audit/workflows/temp-media.ts +0 -138
- package/dist/templates/support-line/graph.ts +0 -224
- package/dist/{_config-D_s09e7g.mjs → _config-CmJOFsAP.mjs} +1 -1
- /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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
209
|
+
}
|
|
@@ -27,16 +27,21 @@
|
|
|
27
27
|
* runs a real one.
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
-
import type {
|
|
30
|
+
import type { WorkflowClient } from "@alexkroman1/aai";
|
|
31
31
|
import {
|
|
32
|
-
createProgressStream,
|
|
33
32
|
createRunSnapshot,
|
|
34
|
-
createStubWorkflows,
|
|
35
33
|
createToolContext,
|
|
36
|
-
|
|
34
|
+
parseSchemaInput,
|
|
35
|
+
schemaInputIssues,
|
|
36
|
+
toolRunner,
|
|
37
37
|
withDiscoveredTools,
|
|
38
38
|
} from "@alexkroman1/aai/testing";
|
|
39
|
-
import {
|
|
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
|
-
/**
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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(
|
|
130
|
-
|
|
131
|
-
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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.
|
|
285
|
-
const result = await run("recap_progress",
|
|
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.
|
|
293
|
-
await run("recap_progress",
|
|
294
|
-
expect(workflows.
|
|
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
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
//
|
|
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 HANG — it 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
|
-
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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
|
-
/**
|
|
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
|
-
|
|
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?.
|
|
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?.
|
|
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
|
|
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]?.
|
|
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
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
|
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(
|
|
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]?.
|
|
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]?.
|
|
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
|
-
|
|
715
|
-
"
|
|
716
|
-
|
|
717
|
-
|
|
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
|
-
// `
|
|
15
|
-
// never closed — no step knows it is the last one — so
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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. `
|
|
18
|
-
*
|
|
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:
|
|
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": {
|