@alexkroman1/aai-cli 6.11.0 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. package/README.md +1 -0
  2. package/dist/{_agent-DpH2pBJd.mjs → _agent-BTwZJu4P.mjs} +2 -2
  3. package/dist/{_bundler-C2BDwU7r.mjs → _bundler-CDuYl5Gb.mjs} +2 -2
  4. package/dist/_dev-env.d.ts +22 -2
  5. package/dist/{_dev-server-LLLWnFBs.mjs → _dev-server-Me06wgeF.mjs} +38 -47
  6. package/dist/_dev-server.d.ts +0 -17
  7. package/dist/{_init-C57bWAQ8.mjs → _init-BhEfQ6Yi.mjs} +3 -2
  8. package/dist/{_preflight-BtfaYtbE.mjs → _preflight-Co1G9Jww.mjs} +1 -1
  9. package/dist/_preflight.d.ts +1 -1
  10. package/dist/_resource-commands.d.ts +23 -0
  11. package/dist/{_slug-api-BxrsJXVI.mjs → _slug-api-z-USxPtL.mjs} +16 -12
  12. package/dist/_slug-api.d.ts +5 -2
  13. package/dist/{_studio-U3jhKlA3.mjs → _studio-DR5yzZ1T.mjs} +2 -1
  14. package/dist/{_templates-Dxb_P2Wz.mjs → _templates-4WcKOjS5.mjs} +5 -6
  15. package/dist/_templates.d.ts +3 -4
  16. package/dist/_workflow-scan.d.ts +108 -0
  17. package/dist/{build-Dfza2pRI.mjs → build-BBxf8vom.mjs} +6 -3
  18. package/dist/cli.mjs +346 -349
  19. package/dist/{client-bundler-BEIqgOtd.mjs → client-bundler-CWnG42cU.mjs} +2 -1
  20. package/dist/client-bundler.mjs +1 -1
  21. package/dist/{delete-vbpjGzqs.mjs → delete-Dl_LSfvI.mjs} +3 -3
  22. package/dist/{deploy-B8lubiRT.mjs → deploy-DRC8jWr8.mjs} +5 -5
  23. package/dist/{dev-Uq5ujP8a.mjs → dev-BvcctCn2.mjs} +1 -1
  24. package/dist/eval-DfQ8cm5p.mjs +56 -0
  25. package/dist/eval.d.ts +43 -0
  26. package/dist/{init-l6xfU4xX.mjs → init-CUo87gcI.mjs} +3 -3
  27. package/dist/{login-C71-qz8F.mjs → login-BBuM1sxH.mjs} +3 -4
  28. package/dist/{logs-BWVGSjLR.mjs → logs-CQ2T46dr.mjs} +2 -2
  29. package/dist/project-config.mjs +1 -1
  30. package/dist/scaffold/CLAUDE.md +442 -80
  31. package/dist/scaffold/package.json +6 -3
  32. package/dist/scaffold/server.mjs +11 -4
  33. package/dist/scaffold/tsconfig.json +0 -1
  34. package/dist/{secret-BuMuFR4B.mjs → secret-Ct34kdgU.mjs} +2 -2
  35. package/dist/{storage-DzRZ-eCw.mjs → storage-i_79ZwAR.mjs} +2 -2
  36. package/dist/{studio-BCNUpDgP.mjs → studio-CzTzPqe1.mjs} +4 -4
  37. package/dist/templates/briefing-desk/agent.test.ts +350 -0
  38. package/dist/templates/briefing-desk/agent.ts +30 -0
  39. package/dist/templates/briefing-desk/shared.ts +200 -0
  40. package/dist/templates/briefing-desk/system-prompt.md +39 -0
  41. package/dist/templates/briefing-desk/tools/briefing_so_far.ts +35 -0
  42. package/dist/templates/briefing-desk/tools/research_topic.ts +88 -0
  43. package/dist/templates/briefing-desk/tools/verify_claim.ts +77 -0
  44. package/dist/templates/call-audit/agent.eval.test.ts +474 -0
  45. package/dist/templates/call-audit/agent.test.ts +57 -204
  46. package/dist/templates/call-audit/agent.ts +32 -19
  47. package/dist/templates/call-audit/client.tsx +17 -54
  48. package/dist/templates/call-audit/workflows/audit.ts +11 -18
  49. package/dist/templates/call-audit/workflows/ingest.ts +106 -114
  50. package/dist/templates/call-audit/workflows/media.ts +2 -12
  51. package/dist/templates/call-audit/workflows/summarize.ts +47 -52
  52. package/dist/templates/call-audit/workflows/sync-api.ts +7 -7
  53. package/dist/templates/code-interpreter/agent.eval.test.ts +165 -0
  54. package/dist/templates/dispatch-center/agent.eval.test.ts +349 -0
  55. package/dist/templates/dispatch-center/agent.test.ts +149 -25
  56. package/dist/templates/dispatch-center/client.tsx +239 -129
  57. package/dist/templates/dispatch-center/shared.ts +99 -1
  58. package/dist/templates/dispatch-center/system-prompt.md +3 -1
  59. package/dist/templates/dispatch-center/tools/incident_add_note.ts +16 -13
  60. package/dist/templates/dispatch-center/tools/incident_create.ts +19 -1
  61. package/dist/templates/dispatch-center/tools/incident_escalate.ts +68 -59
  62. package/dist/templates/dispatch-center/tools/incident_triage.ts +57 -43
  63. package/dist/templates/dispatch-center/tools/incident_update_status.ts +49 -40
  64. package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +8 -2
  65. package/dist/templates/dispatch-center/tools/resources_dispatch.ts +69 -52
  66. package/dist/templates/dispatch-center/tools/resources_update_status.ts +45 -41
  67. package/dist/templates/embedded-assets/agent.eval.test.ts +138 -0
  68. package/dist/templates/embedded-assets/agent.test.ts +17 -14
  69. package/dist/templates/health-assistant/agent.eval.test.ts +167 -0
  70. package/dist/templates/health-assistant/agent.test.ts +22 -4
  71. package/dist/templates/infocom-adventure/agent.eval.test.ts +158 -0
  72. package/dist/templates/infocom-adventure/agent.test.ts +53 -24
  73. package/dist/templates/infocom-adventure/agent.ts +21 -0
  74. package/dist/templates/infocom-adventure/client.tsx +246 -164
  75. package/dist/templates/infocom-adventure/shared.ts +20 -9
  76. package/dist/templates/infocom-adventure/system-prompt.md +1 -1
  77. package/dist/templates/infocom-adventure/tools/game_state_move.ts +3 -2
  78. package/dist/templates/link-digest/agent.eval.test.ts +195 -0
  79. package/dist/templates/link-digest/agent.test.ts +24 -19
  80. package/dist/templates/link-digest/client.tsx +47 -61
  81. package/dist/templates/link-digest/workflows/digest.ts +19 -31
  82. package/dist/templates/math-buddy/agent.eval.test.ts +146 -0
  83. package/dist/templates/night-owl/agent.eval.test.ts +272 -0
  84. package/dist/templates/night-owl/agent.test.ts +70 -19
  85. package/dist/templates/night-owl/agent.ts +5 -0
  86. package/dist/templates/night-owl/client.tsx +56 -56
  87. package/dist/templates/night-owl/shared.ts +24 -0
  88. package/dist/templates/night-owl/tools/recommend.ts +18 -6
  89. package/dist/templates/personal-finance/agent.eval.test.ts +168 -0
  90. package/dist/templates/personal-finance/system-prompt.md +4 -0
  91. package/dist/templates/pipeline-simple/agent.eval.test.ts +78 -0
  92. package/dist/templates/pipeline-simple/agent.ts +2 -2
  93. package/dist/templates/pizza-ordering/agent.eval.test.ts +171 -0
  94. package/dist/templates/pizza-ordering/agent.test.ts +42 -27
  95. package/dist/templates/pizza-ordering/client.tsx +9 -26
  96. package/dist/templates/pizza-ordering/shared.ts +20 -1
  97. package/dist/templates/pizza-ordering/system-prompt.md +6 -0
  98. package/dist/templates/plan-and-execute/agent.eval.test.ts +209 -0
  99. package/dist/templates/plan-and-execute/agent.test.ts +99 -58
  100. package/dist/templates/plan-and-execute/agent.ts +1 -1
  101. package/dist/templates/plan-and-execute/client.tsx +12 -15
  102. package/dist/templates/plan-and-execute/shared.ts +71 -2
  103. package/dist/templates/plan-and-execute/tools/plan_status.ts +18 -6
  104. package/dist/templates/plan-and-execute/tools/revise_plan.ts +18 -10
  105. package/dist/templates/plan-and-execute/tools/start_plan.ts +13 -2
  106. package/dist/templates/plan-and-execute/tools/work_next_step.ts +70 -22
  107. package/dist/templates/podcast-digest/agent.eval.test.ts +411 -0
  108. package/dist/templates/podcast-digest/agent.test.ts +717 -0
  109. package/dist/templates/podcast-digest/agent.ts +139 -0
  110. package/dist/templates/podcast-digest/client.tsx +154 -0
  111. package/dist/templates/podcast-digest/workflows/digest.ts +444 -0
  112. package/dist/templates/podcast-digest/workflows/feeds.ts +507 -0
  113. package/dist/templates/podcast-digest/workflows/slack.ts +88 -0
  114. package/dist/templates/recap-workflow/agent.eval.test.ts +366 -0
  115. package/dist/templates/recap-workflow/agent.test.ts +97 -94
  116. package/dist/templates/recap-workflow/tools/recap_progress.ts +9 -12
  117. package/dist/templates/recap-workflow/tools/recap_status.ts +8 -9
  118. package/dist/templates/recap-workflow/tools/request_recap.ts +2 -1
  119. package/dist/templates/recap-workflow/workflows/recap.ts +36 -40
  120. package/dist/templates/redline/agent.eval.test.ts +209 -0
  121. package/dist/templates/redline/agent.test.ts +17 -15
  122. package/dist/templates/redline/client.tsx +12 -12
  123. package/dist/templates/redline/workflows/redline.ts +19 -31
  124. package/dist/templates/research-workflow/agent.eval.test.ts +329 -0
  125. package/dist/templates/research-workflow/agent.test.ts +60 -59
  126. package/dist/templates/research-workflow/tools/research_progress.ts +9 -12
  127. package/dist/templates/research-workflow/tools/research_status.ts +9 -11
  128. package/dist/templates/research-workflow/workflows/research.ts +44 -61
  129. package/dist/templates/retail/address.ts +87 -1
  130. package/dist/templates/retail/agent.eval.test.ts +314 -0
  131. package/dist/templates/retail/agent.test.ts +195 -112
  132. package/dist/templates/retail/agent.ts +35 -1
  133. package/dist/templates/retail/cancel.ts +116 -0
  134. package/dist/templates/retail/client.tsx +265 -117
  135. package/dist/templates/retail/payment.ts +107 -0
  136. package/dist/templates/retail/pending.test.ts +196 -0
  137. package/dist/templates/retail/pending.ts +137 -0
  138. package/dist/templates/retail/registry.test.ts +150 -7
  139. package/dist/templates/retail/returns.ts +102 -0
  140. package/dist/templates/retail/shared.test.ts +42 -0
  141. package/dist/templates/retail/shared.ts +35 -0
  142. package/dist/templates/retail/store.test.ts +84 -15
  143. package/dist/templates/retail/store.ts +231 -48
  144. package/dist/templates/retail/swap.test.ts +4 -4
  145. package/dist/templates/retail/swap.ts +248 -15
  146. package/dist/templates/retail/system-prompt.md +69 -28
  147. package/dist/templates/retail/tools/cancel_change.ts +33 -0
  148. package/dist/templates/retail/tools/cancel_pending_order.ts +16 -60
  149. package/dist/templates/retail/tools/confirm_change.ts +42 -0
  150. package/dist/templates/retail/tools/exchange_delivered_order_items.ts +22 -67
  151. package/dist/templates/retail/tools/find_user_id_by_email.ts +8 -5
  152. package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +4 -5
  153. package/dist/templates/retail/tools/get_item_details.ts +3 -4
  154. package/dist/templates/retail/tools/get_order_details.ts +3 -4
  155. package/dist/templates/retail/tools/get_product_details.ts +3 -4
  156. package/dist/templates/retail/tools/get_user_details.ts +2 -2
  157. package/dist/templates/retail/tools/list_all_product_types.ts +5 -5
  158. package/dist/templates/retail/tools/modify_pending_order_address.ts +13 -25
  159. package/dist/templates/retail/tools/modify_pending_order_items.ts +23 -62
  160. package/dist/templates/retail/tools/modify_pending_order_payment.ts +16 -78
  161. package/dist/templates/retail/tools/modify_user_address.ts +13 -16
  162. package/dist/templates/retail/tools/return_delivered_order_items.ts +17 -72
  163. package/dist/templates/retail/tools/transfer_to_human_agents.ts +12 -6
  164. package/dist/templates/simple/agent.eval.test.ts +54 -0
  165. package/dist/templates/solo-rpg/agent.eval.test.ts +320 -0
  166. package/dist/templates/solo-rpg/agent.test.ts +227 -48
  167. package/dist/templates/solo-rpg/client.tsx +38 -37
  168. package/dist/templates/solo-rpg/shared.ts +145 -19
  169. package/dist/templates/solo-rpg/system-prompt.md +3 -2
  170. package/dist/templates/solo-rpg/tools/action_roll.ts +82 -63
  171. package/dist/templates/solo-rpg/tools/burn_momentum.ts +85 -50
  172. package/dist/templates/solo-rpg/tools/check_state.ts +24 -7
  173. package/dist/templates/solo-rpg/tools/load_game.ts +13 -1
  174. package/dist/templates/solo-rpg/tools/save_game.ts +16 -7
  175. package/dist/templates/solo-rpg/tools/setup_character.ts +22 -2
  176. package/dist/templates/solo-rpg/tools/update_state.ts +113 -100
  177. package/dist/templates/spoken-summary/agent.eval.test.ts +342 -0
  178. package/dist/templates/spoken-summary/agent.test.ts +68 -97
  179. package/dist/templates/spoken-summary/agent.ts +30 -17
  180. package/dist/templates/spoken-summary/client.tsx +10 -50
  181. package/dist/templates/spoken-summary/workflows/summarize.ts +17 -22
  182. package/dist/templates/spoken-summary/workflows/transcribe.ts +19 -26
  183. package/dist/templates/support-line/agent.eval.test.ts +273 -0
  184. package/dist/templates/support-line/agent.test.ts +11 -16
  185. package/dist/templates/support-line/agent.ts +1 -1
  186. package/dist/templates/support-line/client.tsx +9 -9
  187. package/dist/templates/support-line/nodes.ts +100 -0
  188. package/dist/templates/support-line/procedure.ts +407 -0
  189. package/dist/templates/support-line/system-prompt.md +2 -1
  190. package/dist/templates/support-line/tools/answer_question.ts +66 -4
  191. package/dist/templates/transcription-workflow/agent.eval.test.ts +234 -0
  192. package/dist/templates/transcription-workflow/agent.test.ts +94 -194
  193. package/dist/templates/transcription-workflow/agent.ts +1 -1
  194. package/dist/templates/transcription-workflow/client.tsx +17 -42
  195. package/dist/templates/transcription-workflow/workflows/batch.ts +19 -22
  196. package/dist/templates/transcription-workflow/workflows/normalize.ts +90 -186
  197. package/dist/templates/transcription-workflow/workflows/stitch.ts +0 -11
  198. package/dist/templates/transcription-workflow/workflows/stream.ts +4 -5
  199. package/dist/templates/transcription-workflow/workflows/sync-api.ts +6 -6
  200. package/dist/templates/transcription-workflow/workflows/transcribe.ts +32 -26
  201. package/dist/templates/transcription-workflow/workflows/wav.ts +0 -31
  202. package/dist/templates/travel-concierge/agent.eval.test.ts +323 -0
  203. package/dist/templates/travel-concierge/agent.test.ts +174 -43
  204. package/dist/templates/travel-concierge/agent.ts +18 -9
  205. package/dist/templates/travel-concierge/client.tsx +11 -23
  206. package/dist/templates/travel-concierge/routing.ts +34 -15
  207. package/dist/templates/travel-concierge/shared.ts +180 -12
  208. package/dist/templates/travel-concierge/system-prompt.md +7 -1
  209. package/dist/templates/travel-concierge/tools/book_car_rental.ts +8 -5
  210. package/dist/templates/travel-concierge/tools/book_excursion.ts +8 -5
  211. package/dist/templates/travel-concierge/tools/book_hotel.ts +8 -5
  212. package/dist/templates/travel-concierge/tools/cancel_ticket.ts +8 -5
  213. package/dist/templates/travel-concierge/tools/search_car_rentals.ts +9 -5
  214. package/dist/templates/travel-concierge/tools/search_excursions.ts +9 -5
  215. package/dist/templates/travel-concierge/tools/search_flights.ts +9 -6
  216. package/dist/templates/travel-concierge/tools/search_hotels.ts +9 -5
  217. package/dist/templates/travel-concierge/tools/update_ticket.ts +8 -5
  218. package/dist/templates/web-researcher/agent.eval.test.ts +121 -0
  219. package/dist/templates/web-researcher/system-prompt.md +15 -4
  220. package/dist/{test-9kPKJI-w.mjs → test-BvyT6sAc.mjs} +24 -10
  221. package/dist/test.d.ts +29 -5
  222. package/dist/{worker-bundler-CGD4r8Kc.mjs → worker-bundler-CCVEDjm1.mjs} +241 -3
  223. package/dist/worker-bundler.mjs +1 -1
  224. package/dist/{workflow-CFpxOFfQ.mjs → workflow-BflATMmU.mjs} +47 -12
  225. package/dist/workflow-bundler.d.ts +6 -1
  226. package/dist/workflow.d.ts +1 -1
  227. package/package.json +5 -4
  228. package/dist/eject-31gjtaHF.mjs +0 -116
  229. package/dist/eject.d.ts +0 -60
  230. package/dist/templates/call-audit/workflows/temp-media.ts +0 -138
  231. package/dist/templates/infocom-adventure/tools/game_state_history.ts +0 -14
  232. package/dist/templates/support-line/graph.ts +0 -224
  233. package/dist/{_config-D_s09e7g.mjs → _config-CmJOFsAP.mjs} +1 -1
  234. /package/dist/templates/plan-and-execute/{graph.ts → procedure.ts} +0 -0
@@ -0,0 +1,329 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ // An EVAL: does this desk actually behave? Run it with `aai eval`.
4
+ //
5
+ // `agent.test.ts` drives the four tools against a STUBBED `ctx.workflows` and
6
+ // the five steps against a stubbed `fetch`. Neither can say whether the AGENT —
7
+ // a model, reading this system prompt, holding these four tools — hands the
8
+ // work off at all, or whether the run it starts is findable afterwards. That is
9
+ // what this file is for, and it is only possible because `describeEval` opens a
10
+ // real workflow engine per case and hands its client to the session: before
11
+ // that, a tool calling `ctx.workflows.start` was a tool an eval could not
12
+ // execute.
13
+ //
14
+ // Two boundaries this file is careful about, because a case that implied either
15
+ // would be the worse failure:
16
+ //
17
+ // * **The engine is not durable.** No journal, no replay, no retry, and a
18
+ // `sleep()` is RECORDED rather than taken (which is why the review wait
19
+ // below is an assertion about what the body ASKED for). Nothing here says
20
+ // anything about resume, and `aai-cli`'s `dev-workflow.scenario.test.ts` is
21
+ // the tier that does.
22
+ // * **A step's HTTP is scripted, in BOTH modes.** The live half of an eval is
23
+ // the SESSION's model — which tool the desk reaches for, and when. The
24
+ // run's own five-to-twelve model calls and its web searches are answered
25
+ // from `MODEL_SCRIPT` through the published `stepFetch` slot, so a case is
26
+ // deterministic, free, and cannot fail on a DuckDuckGo 403 or a rate limit
27
+ // the engine's inert `maxRetries` could not ride out. What the researcher's
28
+ // search loop does with what it finds is `agent.test.ts`'s subject.
29
+ //
30
+ // And what no eval here can see at all: anything below the audio boundary —
31
+ // endpointing, barge-in, whether two sentences merged into one turn.
32
+ import { withDiscoveredTools } from "@alexkroman1/aai/testing";
33
+ import { installStubStepFetch } from "@alexkroman1/aai/testing/vitest";
34
+ import type { EvalToolCall, EvalWorkflows } from "@alexkroman1/aai-runtime/eval";
35
+ import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
36
+ import { expect } from "vitest";
37
+ import { z } from "zod";
38
+ import authoredAgent from "./agent.ts";
39
+ import { research } from "./shared.ts";
40
+
41
+ /**
42
+ * The def a DEPLOYED agent runs: authored, plus what `tools/` declares.
43
+ *
44
+ * Load-bearing, and not applied by `agent()` — the BUILD is what enumerates
45
+ * `tools/`, so an eval driving the raw default export would measure a desk with
46
+ * no tools at all and every assertion below would mean nothing. This template
47
+ * declares its prompt inline in `agent.ts`, so there is no `system-prompt.md`
48
+ * to layer on with `withSystemPrompt`.
49
+ *
50
+ * The glob is written here rather than reached for from a shared helper because
51
+ * this file SHIPS — see `agent.test.ts`.
52
+ */
53
+ const agentDef = withDiscoveredTools(
54
+ authoredAgent,
55
+ import.meta.glob("./tools/*.ts", { eager: true }),
56
+ );
57
+
58
+ /**
59
+ * The key the run's steps read with `requireStepEnv`.
60
+ *
61
+ * Passed as the agent env so the eval's workflow engine publishes it: the
62
+ * gateway call below is answered by a fake, but `stepGenerate` asks for the key
63
+ * BEFORE it makes the request, so a run with no key fails on the missing
64
+ * credential rather than reaching the script. The ENVIRONMENT and nothing else —
65
+ * a template may not read a developer's CLI config.
66
+ */
67
+ const EVAL_ENV = { ASSEMBLYAI_API_KEY: process.env.ASSEMBLYAI_API_KEY ?? "eval-scripted-key" };
68
+
69
+ /** The one angle the scripted planner comes back with. */
70
+ const ANGLE = "What second-hand cargo bikes actually sell for";
71
+
72
+ /** The written report the scripted `writeReport` produces. */
73
+ const REPORT_BODY =
74
+ "## Second-hand cargo bikes in Amsterdam\n\nAsking prices cluster between 1,200 and 2,400 EUR.";
75
+
76
+ /** The two sentences the desk reads down the phone. */
77
+ const SPOKEN_SUMMARY =
78
+ "Second-hand cargo bikes in Amsterdam mostly go for between one and two and a half thousand euros.";
79
+
80
+ /**
81
+ * What the run's model calls are answered with, in the order the body asks.
82
+ *
83
+ * Six calls, and the ORDER is the assertion: brief, angles, the researcher's
84
+ * one action, the gap pass, the report, the spoken summary. A stage that
85
+ * disappeared or moved would hand a later stage an earlier reply, which is
86
+ * exactly what the two content assertions in "writes the report" catch.
87
+ *
88
+ * The researcher's action is `stop` on purpose: `search` and `read` go through
89
+ * `webSearch`/`visitWebpage`, whose fetch is the global one and not the step
90
+ * slot, so scripting them is not available here — a case that let them run
91
+ * would really search the web.
92
+ */
93
+ const MODEL_SCRIPT: readonly string[] = [
94
+ JSON.stringify({
95
+ brief: "What a used cargo bike costs in Amsterdam, and where they are sold.",
96
+ criteria: ["typical asking prices", "where people buy them"],
97
+ }),
98
+ JSON.stringify({ angles: [ANGLE] }),
99
+ JSON.stringify({ action: "stop", why: "the budget is better spent elsewhere" }),
100
+ JSON.stringify({ angles: [] }),
101
+ REPORT_BODY,
102
+ SPOKEN_SUMMARY,
103
+ ];
104
+
105
+ /** A scripted step HTTP surface, and the gate that holds its first answer. */
106
+ type ScriptedSteps = {
107
+ /** Every step request, in order — the gateway calls the run really made. */
108
+ readonly calls: readonly { url: string; method: string }[];
109
+ /** Let a held first answer through. Idempotent. */
110
+ readonly release: () => void;
111
+ };
112
+
113
+ /**
114
+ * Answer the run's model calls from {@link MODEL_SCRIPT}.
115
+ *
116
+ * Through `stepFetch`'s published slot rather than `vi.stubGlobal("fetch")`,
117
+ * which is what a step really dials — and which leaves the SESSION's own model
118
+ * on the live path, so a live case still measures the agent. Anything that is
119
+ * not the gateway THROWS rather than answering 200: an unexpected request from
120
+ * a step is a finding, and a silent empty body would be read as a model that
121
+ * said nothing.
122
+ *
123
+ * `hold` keeps the FIRST answer pending, which is the only way to observe a run
124
+ * that is still going: a durable `sleep` is skipped here, so an unheld run
125
+ * finishes in milliseconds.
126
+ */
127
+ function scriptSteps(options: { hold?: boolean } = {}): ScriptedSteps {
128
+ const gate = Promise.withResolvers<void>();
129
+ let served = 0;
130
+ const stub = installStubStepFetch(async (request) => {
131
+ if (!request.url.includes("/chat/completions")) {
132
+ throw new Error(`unexpected step request in an eval: ${request.method} ${request.url}`);
133
+ }
134
+ const at = served++;
135
+ if (options.hold === true && at === 0) await gate.promise;
136
+ const content = MODEL_SCRIPT[Math.min(at, MODEL_SCRIPT.length - 1)];
137
+ return { body: { choices: [{ message: { content } }] } };
138
+ });
139
+ return { calls: stub.calls, release: () => gate.resolve() };
140
+ }
141
+
142
+ /** `request_research`'s answer when it really started something. */
143
+ const Started = z.object({
144
+ started: z.literal(true),
145
+ runId: z.string().min(1),
146
+ topic: z.string().min(1),
147
+ });
148
+
149
+ /**
150
+ * The run id a `request_research` call reported.
151
+ *
152
+ * Parsed rather than regexed: a tool result reaches the event stream as a
153
+ * serialized string, and a shape that stopped matching should fail HERE naming
154
+ * the field instead of handing the next assertion `undefined`.
155
+ */
156
+ function startedRunId(calls: readonly EvalToolCall[]): string {
157
+ const call = calls.find((one) => one.name === "request_research");
158
+ if (call?.result === undefined) {
159
+ throw new Error(
160
+ `the desk called no request_research: ${calls.map((one) => one.name).join(", ") || "(no tools)"}`,
161
+ );
162
+ }
163
+ return Started.parse(JSON.parse(call.result)).runId;
164
+ }
165
+
166
+ /** Every tool call in this turn that READS a run, whichever the model picked. */
167
+ function readbacks(calls: readonly EvalToolCall[]): readonly EvalToolCall[] {
168
+ return calls.filter((one) => one.name === "research_status" || one.name === "research_progress");
169
+ }
170
+
171
+ /** The one utterance every case that starts work opens with. */
172
+ const ASK = "Please research the second-hand cargo bike market in Amsterdam for me.";
173
+
174
+ /** The scripted session turn that answers {@link ASK}. */
175
+ const START_TURN = [
176
+ { tool: "request_research", args: { topic: "the second-hand cargo bike market in Amsterdam" } },
177
+ "I've started looking into that — I'll let you know when it lands.",
178
+ ] as const;
179
+
180
+ /**
181
+ * Let the run finish before the case ends.
182
+ *
183
+ * Not tidiness: the scripted `stepFetch` is unpublished when the test that
184
+ * installed it finishes, so a body still mid-flight would make its next model
185
+ * call against whatever the next case publishes — or against the real gateway.
186
+ */
187
+ async function drain(workflows: EvalWorkflows | undefined, steps: ScriptedSteps): Promise<void> {
188
+ steps.release();
189
+ for (const run of await (workflows?.runs() ?? [])) {
190
+ await workflows?.settle(run.runId, research);
191
+ }
192
+ }
193
+
194
+ describeEval(
195
+ agentDef,
196
+ (test) => {
197
+ test(
198
+ "hands the topic to a run and answers the turn without waiting for it",
199
+ async ({ session, workflows }) => {
200
+ // Held, so the run cannot possibly have finished by the time the desk
201
+ // replies — which is the whole claim of the handoff shape.
202
+ const steps = scriptSteps({ hold: true });
203
+
204
+ const turn = await session.say(ASK);
205
+
206
+ const runId = startedRunId(turn.toolCalls);
207
+ expect(turn.completed).toBe(true);
208
+ // The topic it passed is the caller's, not a paraphrase of the prompt.
209
+ const asked = turn.toolCalls.find((one) => one.name === "request_research");
210
+ expect(String(asked?.args.topic)).toMatch(/cargo bike/i);
211
+
212
+ // The run is REAL: the engine started it, under the name the agent
213
+ // declares, and it is still going now that the turn has ended.
214
+ const runs = await (workflows?.runs() ?? []);
215
+ const started = runs.find((one) => one.runId === runId);
216
+ expect(started?.workflow).toBe("research");
217
+ expect(started?.status).toBe("running");
218
+ // And it really began work — the brief stage narrated before its model
219
+ // call, which is the request this case is holding.
220
+ expect(started?.reported.join("\n")).toMatch(/really asking/);
221
+
222
+ await drain(workflows, steps);
223
+ },
224
+ { stubReply: [...START_TURN] },
225
+ );
226
+
227
+ test(
228
+ "the run really writes the report, and asks for the review wait",
229
+ async ({ session, workflows }) => {
230
+ const steps = scriptSteps();
231
+
232
+ const turn = await session.say(ASK);
233
+ const runId = startedRunId(turn.toolCalls);
234
+ const run = await workflows?.settle(runId, research);
235
+
236
+ // What a completed run reports as `output` is what the agent reads back
237
+ // and what the announcement is built from, so every field is asserted.
238
+ expect(run?.status).toBe("completed");
239
+ expect(run?.output?.report).toBe(REPORT_BODY);
240
+ expect(run?.output?.summary).toBe(SPOKEN_SUMMARY);
241
+ expect(run?.output?.angles).toEqual([ANGLE]);
242
+ expect(run?.output?.filedAt).toBe("filed");
243
+
244
+ // The five stages, in order, off the run's own narration — which is
245
+ // also what `research_progress` reads back down the phone. A stage that
246
+ // stopped reporting is a caller who is told nothing for minutes.
247
+ const narration = run?.reported.join("\n") ?? "";
248
+ expect(narration).toMatch(/really asking/);
249
+ expect(narration).toMatch(/Researching 1 angle/);
250
+ expect(narration).toMatch(new RegExp(`Looking into: ${ANGLE}`));
251
+ expect(narration).toMatch(/writing it up/);
252
+ expect(narration).toMatch(/Writing up 1 angle/);
253
+ expect(run?.reported.at(-1)).toBe("Filing the findings.");
254
+
255
+ // The review wait, ASKED FOR and not taken: this engine records a
256
+ // durable `sleep` rather than suspending, so what a case can honestly
257
+ // claim is that the body asked — and that is the assertion that fails
258
+ // if the suspension is ever deleted.
259
+ expect(run?.slept).toEqual([{ duration: "30 seconds" }]);
260
+
261
+ // Six model calls, all through the step slot: the fan-out's width came
262
+ // from a journaled stage rather than from anything the body recomputed.
263
+ expect(steps.calls).toHaveLength(MODEL_SCRIPT.length);
264
+ },
265
+ { stubReply: [...START_TURN] },
266
+ );
267
+
268
+ test(
269
+ "reads the live run back rather than guessing at it",
270
+ async ({ session, workflows }) => {
271
+ const steps = scriptSteps({ hold: true });
272
+
273
+ const started = await session.say(ASK);
274
+ const runId = startedRunId(started.toolCalls);
275
+ const turn = await session.say("What's it doing right now?");
276
+
277
+ // WHICH of the two readback tools the model picks is its business —
278
+ // the prompt offers both — so the claim is about what it was told:
279
+ // either the run's own latest progress line or its status, and never
280
+ // an answer the desk invented.
281
+ const read = readbacks(turn.toolCalls);
282
+ expect(read.length).toBeGreaterThan(0);
283
+ const answered = read.map((one) => one.result ?? "").join("\n");
284
+ expect(answered).toMatch(/really asking|Still working on it/);
285
+ expect(answered).not.toMatch(/Nothing started yet/);
286
+
287
+ // The load-bearing half: that readback happened while the run was
288
+ // genuinely in flight, which is the only state these two tools exist
289
+ // for.
290
+ const runs = await (workflows?.runs() ?? []);
291
+ expect(runs.find((one) => one.runId === runId)?.status).toBe("running");
292
+
293
+ await drain(workflows, steps);
294
+ },
295
+ {
296
+ stubReply: [
297
+ ...START_TURN,
298
+ { tool: "research_progress", args: {} },
299
+ "It's still working out what the question really is.",
300
+ ],
301
+ },
302
+ );
303
+
304
+ test(
305
+ "says nothing is running when nothing is, and starts nothing to find out",
306
+ async ({ session, workflows }) => {
307
+ const steps = scriptSteps();
308
+
309
+ const turn = await session.say("Any news on that research I asked for?");
310
+
311
+ const read = readbacks(turn.toolCalls);
312
+ expect(read.length).toBeGreaterThan(0);
313
+ expect(read.map((one) => one.result ?? "").join("\n")).toMatch(/Nothing started yet/);
314
+ // A question is not a request: asking after work nobody asked for must
315
+ // not put a run — and a research pass's worth of model calls — on the
316
+ // account.
317
+ expect(await (workflows?.runs() ?? [])).toEqual([]);
318
+ expect(steps.calls).toEqual([]);
319
+ },
320
+ {
321
+ stubReply: [
322
+ { tool: "research_status", args: {} },
323
+ "Nothing has been started yet — want me to look into something?",
324
+ ],
325
+ },
326
+ );
327
+ },
328
+ { env: EVAL_ENV },
329
+ );
@@ -20,18 +20,19 @@
20
20
  * builds a project and runs one.
21
21
  */
22
22
 
23
- import type { ToolContext, WorkflowClient, WorkflowRunSnapshot } from "@alexkroman1/aai";
23
+ import type { WorkflowClient } from "@alexkroman1/aai";
24
24
  import {
25
- createProgressStream,
26
25
  createRunSnapshot,
27
- createStubWorkflows,
28
26
  createToolContext,
29
- runTool,
27
+ parseSchemaInput,
30
28
  type StubGatewayCall,
29
+ schemaInputIssues,
30
+ toolRunner,
31
31
  withDiscoveredTools,
32
32
  } from "@alexkroman1/aai/testing";
33
- import { installStubGateway as stubGateway } from "@alexkroman1/aai/testing/vitest";
33
+ import { mockWorkflows, installStubGateway as stubGateway } from "@alexkroman1/aai/testing/vitest";
34
34
  import { visitWebpage, webSearch } from "@alexkroman1/aai/tools";
35
+ import type { WorkflowRunSnapshot } from "@alexkroman1/aai/workflow-api";
35
36
  import { beforeEach, describe, expect, test, vi } from "vitest";
36
37
  import { FatalError, RetryableError } from "workflow";
37
38
  import authoredAgent from "./agent.ts";
@@ -75,37 +76,34 @@ const agentDef = withDiscoveredTools(
75
76
  import.meta.glob("./tools/*.ts", { eager: true }),
76
77
  );
77
78
 
78
- /** Every tool here is driven through the agent's own table, by the name the model calls. */
79
- const run = (name: string, args: Record<string, unknown>, ctx: ToolContext): Promise<unknown> =>
80
- runTool(agentDef, name, args, ctx);
79
+ /**
80
+ * Every tool here is driven through the agent's own table, by the name the model
81
+ * calls.
82
+ *
83
+ * The second parameter is args-or-context, which is `runTool`'s own shape: three
84
+ * of this desk's four tools take no arguments, and the `{}` those calls were
85
+ * obliged to pass sat between the two values a reader cares about.
86
+ */
87
+ const run = toolRunner(agentDef);
81
88
 
82
89
  /**
83
90
  * A `ctx.workflows` that records `start` and answers `find` from a fixture.
84
91
  *
85
92
  * Returned WITHOUT a cast, which is the property worth keeping: a cast would
86
93
  * also stop reporting the day `WorkflowClient` grows a method, and this stub is
87
- * how the template's tools reach the client at all. `createStubWorkflows` is
88
- * what keeps that affordable — it fills in the methods this desk does not
89
- * drive, so the day the client does grow one, only the tests that use it change.
94
+ * how the template's tools reach the client at all. `mockWorkflows`
95
+ * (`@alexkroman1/aai/testing/vitest`) is what keeps that affordable — a `vi.fn`
96
+ * per method over one `runs` list, filling in what this desk does not drive, so
97
+ * the day the client does grow a method only the tests using it change.
98
+ * `stream`/`streamTail` are left rejecting on purpose: `research_progress` reads
99
+ * progress through `lastLine`, and composing those two by hand is the hazard
100
+ * `lastLine` exists to remove.
90
101
  */
91
102
  function stubWorkflows(runs: WorkflowRunSnapshot[] = []): WorkflowClient {
92
- return createStubWorkflows({
93
- start: vi.fn(async () => "wrun_stub"),
94
- get: vi.fn(async () => runs[0]),
95
- find: vi.fn(async () => runs),
96
- recent: vi.fn(async () => runs),
97
- cancel: vi.fn(async () => true),
98
- wakeUp: vi.fn(async () => 0),
99
- // A tail of 0 means "one line written", which is the case the tools read.
100
- // The `-1` case is overridden per test, because it is the one that decides
101
- // whether the stream is opened at all.
102
- streamTail: vi.fn(async () => 0),
103
- stream: vi.fn(async () => createProgressStream([])),
104
- // Name only: `WorkflowDef.description` is optional, so passing it through
105
- // would mean handing `description: undefined` to a field that does not
106
- // accept it. Nothing here reads the description anyway.
107
- listing: () => [{ name: "research" }],
108
- });
103
+ // Name only: `WorkflowSummary.description` is optional, so passing this
104
+ // desk's through would mean handing `description: undefined` to a field that
105
+ // does not accept it. Nothing here reads the description anyway.
106
+ return mockWorkflows({ runs, names: ["research"] });
109
107
  }
110
108
 
111
109
  describe("the agent declares its workflow", () => {
@@ -115,13 +113,16 @@ describe("the agent declares its workflow", () => {
115
113
  });
116
114
 
117
115
  test("with an input schema, so a bad topic fails at the call site", async () => {
118
- const result = await research.input?.["~standard"].validate({
119
- topic: "otters",
120
- requestedBy: "s",
121
- });
122
- expect(result?.issues).toBeUndefined();
123
- const bad = await research.input?.["~standard"].validate({ topic: "no", requestedBy: "s" });
124
- expect(bad?.issues).toBeDefined();
116
+ // `parseSchemaInput` / `schemaInputIssues` rather than a reach through
117
+ // `["~standard"].validate`: that is the vendor WIRE contract, and whether it
118
+ // answers synchronously or with a promise is the vendor's business — a
119
+ // missing `await` there leaves `.issues` undefined and the refusing half
120
+ // passes for the wrong reason.
121
+ const parsed = await parseSchemaInput(research.input, { topic: "otters", requestedBy: "s" });
122
+ expect(parsed).toMatchObject({ topic: "otters" });
123
+ expect(
124
+ await schemaInputIssues(research.input, { topic: "no", requestedBy: "s" }),
125
+ ).toBeDefined();
125
126
  });
126
127
  });
127
128
 
@@ -184,7 +185,7 @@ describe("request_research", () => {
184
185
  describe("research_status", () => {
185
186
  test("says nothing was started when the key has no runs", async () => {
186
187
  const ctx = createToolContext({ workflows: stubWorkflows([]) });
187
- const result = await run("research_status", {}, ctx);
188
+ const result = await run("research_status", ctx);
188
189
  expect(result).toMatchObject({ runs: [], note: "Nothing started yet." });
189
190
  });
190
191
 
@@ -197,7 +198,7 @@ describe("research_status", () => {
197
198
  }),
198
199
  ];
199
200
  const ctx = createToolContext({ workflows: stubWorkflows(runs) });
200
- const result = (await run("research_status", {}, ctx)) as { runs: string[] };
201
+ const result = (await run("research_status", ctx)) as { runs: string[] };
201
202
  expect(result.runs[0]).toContain("Otters use tools.");
202
203
  expect(result.runs[0]).toContain("3 sources");
203
204
  });
@@ -206,7 +207,7 @@ describe("research_status", () => {
206
207
  const ctx = createToolContext({
207
208
  workflows: stubWorkflows([createRunSnapshot({ workflow: "research", status: "running" })]),
208
209
  });
209
- const result = (await run("research_status", {}, ctx)) as { runs: string[] };
210
+ const result = (await run("research_status", ctx)) as { runs: string[] };
210
211
  expect(result.runs[0]).toContain("Still working on it.");
211
212
  });
212
213
 
@@ -215,14 +216,14 @@ describe("research_status", () => {
215
216
  createRunSnapshot({ workflow: "research", status: "failed", error: "model unavailable" }),
216
217
  ];
217
218
  const ctx = createToolContext({ workflows: stubWorkflows(runs) });
218
- const result = (await run("research_status", {}, ctx)) as { runs: string[] };
219
+ const result = (await run("research_status", ctx)) as { runs: string[] };
219
220
  expect(result.runs[0]).toContain("model unavailable");
220
221
  });
221
222
 
222
223
  test("bounds how many past runs it reads aloud", async () => {
223
224
  const workflows = stubWorkflows([]);
224
225
  const ctx = createToolContext({ workflows });
225
- await run("research_status", {}, ctx);
226
+ await run("research_status", ctx);
226
227
  // A voice reply cannot be a list of twenty runs.
227
228
  expect(workflows.find).toHaveBeenCalledWith(research, ctx.sessionId, { limit: 3 });
228
229
  });
@@ -233,40 +234,40 @@ describe("research_progress", () => {
233
234
  const workflows = stubWorkflows([
234
235
  createRunSnapshot({ workflow: "research", status: "running" }),
235
236
  ]);
236
- vi.mocked(workflows.stream).mockResolvedValue(createProgressStream(["Found 3 sources."]));
237
- const ctx = createToolContext({ workflows });
238
- const result = await run("research_progress", {}, ctx);
237
+ vi.mocked(workflows.lastLine).mockResolvedValue("Found 3 sources.");
238
+ const result = await run("research_progress", createToolContext({ workflows }));
239
239
  expect(result).toMatchObject({ progress: "Found 3 sources." });
240
240
  });
241
241
 
242
242
  test("asks for the LAST line, not the whole log", async () => {
243
- // A voice reply cannot recite every line the run has written.
243
+ // A voice reply cannot recite every line the run has written. `lastLine` is
244
+ // the whole request — the bound that keeps an empty channel from hanging
245
+ // belongs to the method, so nothing here composes `streamTail` and `stream`.
244
246
  const workflows = stubWorkflows([
245
247
  createRunSnapshot({ workflow: "research", status: "running" }),
246
248
  ]);
247
- vi.mocked(workflows.stream).mockResolvedValue(createProgressStream(["a"]));
248
- await run("research_progress", {}, createToolContext({ workflows }));
249
- expect(workflows.stream).toHaveBeenCalledWith("wrun_1", { startIndex: -1 });
249
+ vi.mocked(workflows.lastLine).mockResolvedValue("a");
250
+ await run("research_progress", createToolContext({ workflows }));
251
+ expect(workflows.lastLine).toHaveBeenCalledWith("wrun_1");
250
252
  });
251
253
 
252
- test("a run that has written nothing yet says so WITHOUT opening the stream", async () => {
253
- // Not a shortcut: an empty progress channel is never closed, so reading one
254
- // would wait for a line that arrives whenever the next step writes i.e.
255
- // the tool hangs instead of answering. The tail is how that is known.
254
+ test("a run that has written nothing yet says so", async () => {
255
+ // `lastLine` resolves `undefined` for an empty channel, and this is the arm
256
+ // the tool branches on. That an empty channel does not HANGit is never
257
+ // closed, so a stream opened on one waits for a line that may never come —
258
+ // is `lastLine`'s own guarantee now, and `aai`'s to test.
256
259
  const workflows = stubWorkflows([
257
260
  createRunSnapshot({ workflow: "research", status: "running" }),
258
261
  ]);
259
- vi.mocked(workflows.streamTail).mockResolvedValue(-1);
260
- const result = await run("research_progress", {}, createToolContext({ workflows }));
262
+ const result = await run("research_progress", createToolContext({ workflows }));
261
263
  expect(result).toMatchObject({ note: "Started, nothing to report yet." });
262
- expect(workflows.stream).not.toHaveBeenCalled();
263
264
  });
264
265
 
265
266
  test("says nothing was started when the key has no runs", async () => {
266
267
  const workflows = stubWorkflows([]);
267
- const result = await run("research_progress", {}, createToolContext({ workflows }));
268
+ const result = await run("research_progress", createToolContext({ workflows }));
268
269
  expect(result).toMatchObject({ note: "Nothing started yet." });
269
- expect(workflows.stream).not.toHaveBeenCalled();
270
+ expect(workflows.lastLine).not.toHaveBeenCalled();
270
271
  });
271
272
  });
272
273
 
@@ -276,7 +277,7 @@ describe("file_it_now", () => {
276
277
  createRunSnapshot({ workflow: "research", status: "running" }),
277
278
  ]);
278
279
  vi.mocked(workflows.wakeUp).mockResolvedValue(1);
279
- const result = await run("file_it_now", {}, createToolContext({ workflows }));
280
+ const result = await run("file_it_now", createToolContext({ workflows }));
280
281
  expect(workflows.wakeUp).toHaveBeenCalledWith("wrun_1");
281
282
  expect(result).toMatchObject({ filed: true });
282
283
  });
@@ -288,13 +289,13 @@ describe("file_it_now", () => {
288
289
  createRunSnapshot({ workflow: "research", status: "running" }),
289
290
  ]);
290
291
  vi.mocked(workflows.wakeUp).mockResolvedValue(0);
291
- const result = await run("file_it_now", {}, createToolContext({ workflows }));
292
+ const result = await run("file_it_now", createToolContext({ workflows }));
292
293
  expect(result).toMatchObject({ filed: false });
293
294
  });
294
295
 
295
296
  test("says nothing was started when the key has no runs", async () => {
296
297
  const workflows = stubWorkflows([]);
297
- const result = await run("file_it_now", {}, createToolContext({ workflows }));
298
+ const result = await run("file_it_now", createToolContext({ workflows }));
298
299
  expect(result).toMatchObject({ note: "Nothing started yet." });
299
300
  expect(workflows.wakeUp).not.toHaveBeenCalled();
300
301
  });
@@ -10,17 +10,14 @@ export default tool({
10
10
  // has WRITTEN (`getWritable()` in `workflows/research.ts`). Between "still
11
11
  // working on it" and a finished summary there is otherwise nothing to say.
12
12
  //
13
- // `streamTail` FIRST, and not as an optimization: a progress channel is
14
- // never closed — no step knows it is the last one — so reading a stream
15
- // with nothing in it waits forever rather than ending. `-1` is "nothing
16
- // written yet", and it is the only safe way to learn that.
17
- if ((await ctx.workflows.streamTail(latest.runId)) < 0) {
18
- return { note: "Started, nothing to report yet." };
19
- }
20
- // A negative `startIndex` reads from the END, which is what a voice reply
21
- // wants the last line, not a recital of the whole log.
22
- const stream = await ctx.workflows.stream(latest.runId, { startIndex: -1 });
23
- for await (const line of stream) return { progress: String(line) };
24
- return { note: "Started, nothing to report yet." };
13
+ // `lastLine` rather than `streamTail` + `stream` composed here: a progress
14
+ // channel is never closed — no step knows it is the last one — so a stream
15
+ // opened on a run that has written nothing waits forever, which down a phone
16
+ // is a turn that stops with no error and nothing in a log. The bound that
17
+ // prevents it belongs to the method now, and `undefined` is "nothing yet".
18
+ const line = await ctx.workflows.lastLine(latest.runId);
19
+ return line === undefined
20
+ ? { note: "Started, nothing to report yet." }
21
+ : { progress: String(line) };
25
22
  },
26
23
  });
@@ -1,9 +1,6 @@
1
- import {
2
- isTerminal,
3
- tool,
4
- type WorkflowOutputOf,
5
- type WorkflowRunSnapshot,
6
- } from "@alexkroman1/aai";
1
+ import { tool } from "@alexkroman1/aai";
2
+ import { plural } from "@alexkroman1/aai/utils";
3
+ import { isTerminal, type WorkflowRunOf } from "@alexkroman1/aai/workflow-api";
7
4
  import { research } from "../shared.ts";
8
5
 
9
6
  /** How many past runs the status tool will look at. Newest first. */
@@ -12,17 +9,18 @@ const RECENT_RUNS = 3;
12
9
  /**
13
10
  * One line a voice agent can read aloud about a run.
14
11
  *
15
- * `WorkflowOutputOf` is what names the output type — the same helper a page uses
16
- * to type `run.output`, and the reason this signature does not have to reach
17
- * past the declaration into the body's own return type.
12
+ * `WorkflowRunOf` is the snapshot with its output already typed — the
13
+ * `WorkflowRunSnapshot<WorkflowOutputOf<typeof research>>` this file used to
14
+ * compose by hand, which cost a three-name import for one type. Still the
15
+ * discriminated union, so `isTerminal` below narrows exactly as it did.
18
16
  */
19
- function describeRun(run: WorkflowRunSnapshot<WorkflowOutputOf<typeof research>>): string {
17
+ function describeRun(run: WorkflowRunOf<typeof research>): string {
20
18
  // `isTerminal` narrows to the three finished statuses, which is what makes
21
19
  // `run.output` and `run.error` reachable without a cast.
22
20
  if (!isTerminal(run)) return "Still working on it.";
23
21
  switch (run.status) {
24
22
  case "completed":
25
- return `Done: ${run.output.summary} (${run.output.sources} sources)`;
23
+ return `Done: ${run.output.summary} (${run.output.sources} ${plural(run.output.sources, "source")})`;
26
24
  case "failed":
27
25
  return `That one failed: ${run.error}`;
28
26
  default: