@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,209 @@
1
+ // Copyright 2026 the AAI authors. MIT license.
2
+ // An EVAL for a WORKFLOW APP: does the run actually do the work? Run it with
3
+ // `aai eval`.
4
+ //
5
+ // `agent.test.ts` asserts about the declaration and drives the three steps one
6
+ // at a time. This drives the WHOLE BODY — `redlineFlow` from the top — and the
7
+ // thing it is here to check is the LOOP: the critic returns a verdict, the body
8
+ // breaks on it, and `shipped` says which of the two stopped it. That decision is
9
+ // the whole point of this template, and it is the one thing a per-step spec
10
+ // structurally cannot see.
11
+ //
12
+ // `describeWorkflowEval` picks the providers for you and says which it picked:
13
+ //
14
+ // * with `ASSEMBLYAI_API_KEY` — a LIVE run: a real model writes, a real model
15
+ // critiques, and a real model revises if it is asked to. That spends tokens,
16
+ // and a model is a NOISY instrument — one failure is a question, not a
17
+ // verdict. Re-run before believing either answer.
18
+ // * without one — a SCRIPTED run. The body, the loop and the three steps all
19
+ // really execute; only the gateway is answered in memory.
20
+ //
21
+ // Two of the three cases below are SCRIPTED IN BOTH MODES on purpose, and say so
22
+ // where they are: their claims are about the loop's arithmetic and about what a
23
+ // stage was SHOWN, and a live model cannot be asked to make either of those
24
+ // true — it can only be asked and then have its answer accepted, which is not
25
+ // evidence.
26
+ //
27
+ // WHAT NO EVAL HERE COVERS: durability. Imported through vitest with no bundler
28
+ // in the path, a `"use workflow"` body is an ordinary async function — no
29
+ // journal, no replay, and no per-step retry, so a rate-limited live run FAILS
30
+ // where a deployed one would have ridden it out. The tier that really resumes a
31
+ // run is `aai-cli`'s `dev-workflow.scenario.test.ts`.
32
+ import { installStubStepFetch } from "@alexkroman1/aai/testing/vitest";
33
+ import { describeWorkflowEval } from "@alexkroman1/aai-runtime/eval/vitest";
34
+ import { expect } from "vitest";
35
+ import agentDef, { MAX_ROUNDS, redline } from "./agent.ts";
36
+
37
+ /** A brief with a word in it nothing else would produce, so the draft is checkable. */
38
+ const BRIEF = "Explain why our on-call rotation is moving to a two-week quokka cycle";
39
+ /** The one point every stage must be shown — `briefBlock` is what carries it. */
40
+ const MUST_COVER = ["Nobody carries the pager two weeks running"];
41
+
42
+ /** What the writer and the reviser are scripted to hand back. */
43
+ const DRAFT = [
44
+ "The on-call rotation is moving to a two-week quokka cycle.",
45
+ "Nobody carries the pager two weeks running: the second week is review and follow-up.",
46
+ ].join(" ");
47
+
48
+ const critique = (verdict: "ship" | "revise", score = 8): string =>
49
+ JSON.stringify({
50
+ verdict,
51
+ score,
52
+ notes: verdict === "ship" ? [] : ["Say what happens to the handover", "Name the start date"],
53
+ });
54
+
55
+ /** One gateway reply, in the envelope `stepGenerate` reads. */
56
+ const reply = (content: string) => ({ body: { choices: [{ message: { content } }] } });
57
+
58
+ /**
59
+ * Answer the gateway with `contents`, in order, and record what each stage asked.
60
+ *
61
+ * The last reply repeats, matching `stubGateway`'s convention — a loop cannot
62
+ * know how many calls it will make, and a script that ran out mid-loop would
63
+ * fail on the script rather than on the code. `installStubStepFetch` rather than
64
+ * `installStubGateway`: `stepGenerate` goes through the published `stepFetch`
65
+ * slot, and a published slot BEATS a stubbed global, so stubbing the global here
66
+ * would test a path production does not take.
67
+ */
68
+ function scriptGateway(contents: readonly string[]) {
69
+ let next = 0;
70
+ const fetched = installStubStepFetch(() => {
71
+ const content = contents.at(Math.min(next, contents.length - 1)) ?? "";
72
+ next += 1;
73
+ return reply(content);
74
+ });
75
+ return fetched;
76
+ }
77
+
78
+ /** Every prompt the gateway was sent, in call order. */
79
+ function promptsOf(fetched: ReturnType<typeof scriptGateway>): string[] {
80
+ return fetched.calls.map((call) => String(call.body ?? ""));
81
+ }
82
+
83
+ describeWorkflowEval(agentDef, (test) => {
84
+ test("runs a round and lets the CRITIC decide whether there is another", async ({
85
+ app,
86
+ mode,
87
+ }) => {
88
+ // One round in live mode, deliberately: the claim is about the loop's exit,
89
+ // and three long-form model calls are enough to make it.
90
+ if (mode === "stub") scriptGateway([DRAFT, critique("ship")]);
91
+
92
+ const run = await app.run(redline, {
93
+ brief: BRIEF,
94
+ audience: "engineers",
95
+ rounds: 1,
96
+ mustCover: MUST_COVER,
97
+ });
98
+
99
+ // The error first, so a failed run names its own reason.
100
+ expect(run.error).toBeUndefined();
101
+ expect(run.status).toBe("completed");
102
+
103
+ const output = run.output;
104
+ if (output === undefined) expect.fail("a completed run must carry an output");
105
+ expect(output.roundsRun).toBe(1);
106
+ expect(output.rounds).toHaveLength(1);
107
+
108
+ const last = output.rounds.at(-1);
109
+ if (last === undefined) expect.fail("a run of one round must record it");
110
+ // THE INVARIANT the template exists for: `shipped` is true exactly when the
111
+ // critic said so, and a shipped round revised nothing after it. Get either
112
+ // half wrong and the loop spends a model call it did not need, or stops one
113
+ // short — both of which read as a working run.
114
+ expect(["ship", "revise"]).toContain(last.critique.verdict);
115
+ expect(output.shipped).toBe(last.critique.verdict === "ship");
116
+ if (output.shipped) expect(last.revisedWords).toBeUndefined();
117
+ else expect(last.revisedWords).toBeTypeOf("number");
118
+
119
+ // The score is CLAMPED at the call site rather than by the schema, so a
120
+ // model answering 11 still lands in range.
121
+ expect(last.critique.score).toBeGreaterThanOrEqual(1);
122
+ expect(last.critique.score).toBeLessThanOrEqual(10);
123
+ expect(last.critique.notes.length).toBeLessThanOrEqual(3);
124
+
125
+ // It wrote about the brief it was given, not about writing in general.
126
+ expect(output.draft).toMatch(/quokka/i);
127
+ expect(output.words).toBeGreaterThan(5);
128
+
129
+ // Both stages narrated, and the round is numbered — which is what a page
130
+ // watching the run renders.
131
+ expect(run.reported[0]).toBe("Writing the first draft for engineers.");
132
+ expect(run.reported).toContain("Round 1: reading it back critically.");
133
+ // Nothing durable was asked for, so nothing was skipped: this body's only
134
+ // waits are its model calls.
135
+ expect(run.slept).toEqual([]);
136
+ });
137
+
138
+ test("stops on the ROUND BUDGET when the critic never ships", async ({ app }) => {
139
+ // Scripted in both modes: a live critic cannot be made to refuse three times
140
+ // running, and asking it and then accepting whatever it says is not evidence
141
+ // about the budget. What this pins is the loop's arithmetic — the half a
142
+ // live case cannot reach.
143
+ const fetched = scriptGateway([
144
+ DRAFT,
145
+ critique("revise", 4),
146
+ `${DRAFT} It starts on the first Monday of the month.`,
147
+ critique("revise", 5),
148
+ `${DRAFT} It starts on the first Monday, and the handover is a written note.`,
149
+ critique("revise", 6),
150
+ ]);
151
+
152
+ const run = await app.run(redline, {
153
+ brief: BRIEF,
154
+ audience: "executives",
155
+ rounds: MAX_ROUNDS,
156
+ mustCover: MUST_COVER,
157
+ });
158
+
159
+ expect(run.error).toBeUndefined();
160
+ const output = run.output;
161
+ if (output === undefined) expect.fail("a completed run must carry an output");
162
+
163
+ // The BUDGET stopped it, not the critic — which is the field a page reads to
164
+ // say "this is as good as it got" rather than "this is finished".
165
+ expect(output.shipped).toBe(false);
166
+ expect(output.roundsRun).toBe(MAX_ROUNDS);
167
+ // Every round revised, because none of them shipped.
168
+ expect(
169
+ output.rounds.map((round) => round.revisedWords).every((n) => typeof n === "number"),
170
+ ).toBe(true);
171
+ // One draft plus a critique-and-revise pair per round. A loop that critiqued
172
+ // twice, or revised the round it shipped, changes this number.
173
+ expect(fetched.calls).toHaveLength(1 + 2 * MAX_ROUNDS);
174
+
175
+ // `briefBlock` is what keeps the three stages from drifting apart, and this is
176
+ // the assertion behind that claim: the writer, the critic AND the reviser were
177
+ // all shown the same brief and the same must-cover point.
178
+ const prompts = promptsOf(fetched);
179
+ expect(prompts).toHaveLength(1 + 2 * MAX_ROUNDS);
180
+ for (const prompt of prompts) {
181
+ expect(prompt).toContain("quokka");
182
+ expect(prompt).toContain(MUST_COVER[0]);
183
+ }
184
+ // The critic and the reviser were also shown the DRAFT, which the writer
185
+ // could not have been.
186
+ expect(prompts[1]).toContain("The on-call rotation is moving");
187
+ expect(prompts[2]).toContain("The critique");
188
+ });
189
+
190
+ test("refuses a brief that is only whitespace, terminally", async ({ app }) => {
191
+ // No model is reached on this path in either mode, so it costs nothing live.
192
+ // The case exists because the schema's `.min(20)` counts CHARACTERS: twenty
193
+ // spaces validate at `start()` and arrive at the writer as nothing to write
194
+ // from, which is why `writeDraft` carries its own `FatalError`.
195
+ const run = await app.run(redline, {
196
+ brief: " ".repeat(40),
197
+ audience: "general readers",
198
+ rounds: 1,
199
+ mustCover: [],
200
+ });
201
+
202
+ expect(run.status).toBe("failed");
203
+ expect(run.error).toMatch(/too short to write from/i);
204
+ expect(run.output).toBeUndefined();
205
+ // It failed before narrating anything, which is the ordering the guard
206
+ // implies: the check is the first thing in the step.
207
+ expect(run.reported).toEqual([]);
208
+ });
209
+ });
@@ -17,6 +17,7 @@
17
17
  * of that lives.
18
18
  */
19
19
 
20
+ import { parseSchemaInput, schemaInputIssues } from "@alexkroman1/aai/testing";
20
21
  import { installStubGateway as stubGateway } from "@alexkroman1/aai/testing/vitest";
21
22
  import { beforeEach, describe, expect, test, vi } from "vitest";
22
23
  import { FatalError } from "workflow";
@@ -24,7 +25,6 @@ import agentDef, { MAX_ROUNDS, redline } from "./agent.ts";
24
25
  import {
25
26
  briefBlock,
26
27
  clampScore,
27
- countWords,
28
28
  critiqueDraft,
29
29
  MAX_NOTES,
30
30
  type RedlineInput,
@@ -70,30 +70,37 @@ describe("the agent declares itself a workflow app", () => {
70
70
  });
71
71
 
72
72
  describe("the input schema", () => {
73
- const validate = (value: unknown) => redline.input?.["~standard"].validate(value);
73
+ // `schemaInputIssues` / `parseSchemaInput` rather than a local reach through
74
+ // `["~standard"].validate`: that is the vendor WIRE contract, and whether it
75
+ // answers synchronously or with a promise is the vendor's business — a missing
76
+ // `await` there leaves `.issues` undefined and every refusing test below
77
+ // passes for the wrong reason.
78
+ const issues = (value: unknown) => schemaInputIssues(redline.input, value, "redline");
74
79
 
75
80
  test("caps the rounds at the CALL SITE rather than on the bill", async () => {
76
- const tooMany = await validate({ ...INPUT, rounds: MAX_ROUNDS + 1 });
77
- expect(tooMany?.issues).toBeDefined();
81
+ expect(await issues({ ...INPUT, rounds: MAX_ROUNDS + 1 })).toBeDefined();
78
82
  });
79
83
 
80
84
  test("defaults the rounds and the required points, so the form need not", async () => {
81
- const result = await validate({ brief: INPUT.brief, audience: "engineers" });
82
- if (!result || result.issues) throw new Error("expected valid input");
83
- expect(result.value).toMatchObject({ rounds: 2, mustCover: [] });
85
+ const parsed = await parseSchemaInput(
86
+ redline.input,
87
+ { brief: INPUT.brief, audience: "engineers" },
88
+ "redline",
89
+ );
90
+ expect(parsed).toMatchObject({ rounds: 2, mustCover: [] });
84
91
  });
85
92
 
86
93
  test("rejects an audience outside the enum — which is also what makes it a select", async () => {
87
94
  // `<WorkflowFields>` renders a `z.enum` as a `<SelectField>`; the same
88
95
  // declaration is what stops an API caller inventing a fifth audience.
89
- expect((await validate({ ...INPUT, audience: "cats" }))?.issues).toBeDefined();
96
+ expect(await issues({ ...INPUT, audience: "cats" })).toBeDefined();
90
97
  });
91
98
 
92
99
  test("declares mustCover as an array, which is what the page renders by hand", async () => {
93
100
  // The mixed-form case: `<WorkflowFields>` renders scalars only, so client.tsx
94
101
  // writes this field itself and maps a textarea into it.
95
- expect((await validate({ ...INPUT, mustCover: "one point" }))?.issues).toBeDefined();
96
- expect((await validate({ ...INPUT, mustCover: ["one point"] }))?.issues).toBeUndefined();
102
+ expect(await issues({ ...INPUT, mustCover: "one point" })).toBeDefined();
103
+ expect(await issues({ ...INPUT, mustCover: ["one point"] })).toBeUndefined();
97
104
  });
98
105
  });
99
106
 
@@ -111,11 +118,6 @@ describe("pure helpers", () => {
111
118
  expect(briefBlock({ ...INPUT, mustCover: [] })).toContain("nothing specific");
112
119
  });
113
120
 
114
- test("countWords ignores surrounding and repeated whitespace", () => {
115
- expect(countWords(" one two\nthree ")).toBe(3);
116
- expect(countWords(" ")).toBe(0);
117
- });
118
-
119
121
  test("clampScore holds a model's number inside the range it was given", () => {
120
122
  expect(clampScore(12)).toBe(10);
121
123
  expect(clampScore(0)).toBe(1);
@@ -27,7 +27,8 @@
27
27
  */
28
28
 
29
29
  import "@alexkroman1/aai-ui/styles.css";
30
- import type { WorkflowOutputOf } from "@alexkroman1/aai";
30
+ import { plural } from "@alexkroman1/aai/utils";
31
+ import type { WorkflowOutputOf } from "@alexkroman1/aai/workflow-api";
31
32
  import {
32
33
  Form,
33
34
  type FormValues,
@@ -35,6 +36,7 @@ import {
35
36
  SubmitButton,
36
37
  TextAreaField,
37
38
  useWorkflowSubmit,
39
+ WORKFLOW_STATUS_LABELS,
38
40
  WorkflowFields,
39
41
  WorkflowProgress,
40
42
  type WorkflowRun,
@@ -150,8 +152,8 @@ function RunPanel({ run, onClear }: { run: WorkflowRun<Redline>; onClear: () =>
150
152
  {run.status === "completed" && (
151
153
  <>
152
154
  <p className="text-xs opacity-60">
153
- {run.output.words} words · {run.output.roundsRun} round
154
- {run.output.roundsRun === 1 ? "" : "s"} ·{" "}
155
+ {run.output.words} words · {run.output.roundsRun}{" "}
156
+ {plural(run.output.roundsRun, "round")} ·{" "}
155
157
  {/* Which of the two stop conditions ended the loop is the one thing
156
158
  a reader cannot infer from the round count alone. */}
157
159
  {run.output.shipped ? "the critic stopped it" : "the round budget stopped it"}
@@ -170,15 +172,13 @@ function RunPanel({ run, onClear }: { run: WorkflowRun<Redline>; onClear: () =>
170
172
  /**
171
173
  * One line describing where a run has got to.
172
174
  *
173
- * A `Record` keyed by the status union rather than a switch, so a status added
174
- * to the SDK is a compile error here instead of falling through a `default:`.
175
+ * The SDK's map with the one label this desk wants differently: `running` is
176
+ * "Writing…" here because that is what the run is doing. Spreading a COMPLETE
177
+ * `Record<WorkflowRunStatus, string>` cannot drop a key, so the exhaustiveness
178
+ * the hand-written copy was written for survives — and now lives at the SDK
179
+ * boundary, where a status added upstream is one compile error rather than one
180
+ * per page.
175
181
  */
176
- const STATUS_LINE: Record<WorkflowRun["status"], string> = {
177
- pending: "Queued",
178
- running: "Writing…",
179
- completed: "Done",
180
- failed: "Failed",
181
- cancelled: "Cancelled",
182
- };
182
+ const STATUS_LINE = { ...WORKFLOW_STATUS_LABELS, running: "Writing…" };
183
183
 
184
184
  page({ name: "Redline", component: RedlineDesk });
@@ -31,8 +31,9 @@
31
31
  * three functions read more tidily than one.
32
32
  */
33
33
 
34
- import { throwStepError } from "@alexkroman1/aai/step-errors";
35
- import { report, stepGenerate, stepGenerateJson } from "@alexkroman1/aai/utils";
34
+ import { report } from "@alexkroman1/aai/step";
35
+ import { stepGenerateClassified, stepGenerateJsonClassified } from "@alexkroman1/aai/step-errors";
36
+ import { countWords } from "@alexkroman1/aai/utils";
36
37
  import { FatalError } from "workflow";
37
38
  import { z } from "zod";
38
39
  import { CRITIC_SYSTEM, REVISER_SYSTEM, WRITER_SYSTEM } from "./prompts.ts";
@@ -145,7 +146,7 @@ export async function writeDraft(input: RedlineInput): Promise<string> {
145
146
  // `stepGenerate` already refuses an empty completion, as a RETRYABLE
146
147
  // `StepGenerateError` — which is the right answer, and one a hand-written
147
148
  // check would have to re-derive.
148
- const draft = await ask(briefBlock(input), { system: WRITER_SYSTEM });
149
+ const draft = await stepGenerateClassified(briefBlock(input), { system: WRITER_SYSTEM });
149
150
  return draft.trim();
150
151
  }
151
152
 
@@ -168,10 +169,10 @@ export async function critiqueDraft(
168
169
  // `stepGenerateJson` owns the fence, the parse, the non-object case and the
169
170
  // shape — and throws PLAINLY when any of them misses, unlike the fatal one
170
171
  // above: a model that answered with prose may well obey on the next attempt.
171
- const parsed = await stepGenerateJson(`${briefBlock(input)}\n\nThe submission:\n${draft}`, {
172
- schema: CritiqueReply,
173
- system: CRITIC_SYSTEM,
174
- }).catch(throwStepError);
172
+ const parsed = await stepGenerateJsonClassified(
173
+ `${briefBlock(input)}\n\nThe submission:\n${draft}`,
174
+ { schema: CritiqueReply, system: CRITIC_SYSTEM },
175
+ );
175
176
 
176
177
  const critique: Critique = {
177
178
  verdict: parsed.verdict,
@@ -199,7 +200,7 @@ export async function reviseDraft(
199
200
  "use step";
200
201
 
201
202
  await report(`Round ${round}: revising.`);
202
- const revised = await ask(
203
+ const revised = await stepGenerateClassified(
203
204
  [
204
205
  briefBlock(input),
205
206
  `Your current draft:\n${draft}`,
@@ -221,11 +222,6 @@ export function briefBlock(input: RedlineInput): string {
221
222
  return [`Brief: ${input.brief}`, `Audience: ${input.audience}`, must].join("\n\n");
222
223
  }
223
224
 
224
- export function countWords(text: string): number {
225
- const trimmed = text.trim();
226
- return trimmed.length === 0 ? 0 : trimmed.split(/\s+/).length;
227
- }
228
-
229
225
  /** Scores arrive from a model, so they arrive out of range often enough. */
230
226
  export function clampScore(score: number): number {
231
227
  if (!Number.isFinite(score)) return 0;
@@ -233,24 +229,16 @@ export function clampScore(score: number): number {
233
229
  }
234
230
 
235
231
  // ---- The model call ---------------------------------------------------------
236
-
237
- /**
238
- * `stepGenerate`, with this desk's retry POLICY on top.
239
- *
240
- * The SDK classifies the gateway's failure (`StepGenerateError.retryable`) and
241
- * stops there: whether a terminal failure should burn the step's remaining
242
- * attempts is the caller's call. `throwStepError`
243
- * (`@alexkroman1/aai/step-errors`) is that call made one way terminal stays
244
- * terminal, and a rate limit becomes a `RetryableError` carrying the delay the
245
- * gateway itself named, which beats `RetryableError`'s own one-second default.
246
- *
247
- * This desk used to carry that mapping itself, as did `research-workflow` and
248
- * `link-digest`; it is one import now, and the delay is no longer the one line
249
- * only this template remembered.
250
- */
251
- async function ask(prompt: string, opts: { system: string }): Promise<string> {
252
- return await stepGenerate(prompt, opts).catch(throwStepError);
253
- }
232
+ //
233
+ // There is no local `ask()` any more, and its absence is the point. The SDK
234
+ // classifies the gateway's failure (`StepGenerateError.retryable`) and stops
235
+ // there — whether a terminal failure should burn the step's remaining attempts
236
+ // is the caller's call — so `stepGenerateClassified` and
237
+ // `stepGenerateJsonClassified` (`@alexkroman1/aai/step-errors`) are that call
238
+ // made one way: terminal stays terminal, and a rate limit becomes a
239
+ // `RetryableError` carrying the delay the gateway itself named, which beats
240
+ // `RetryableError`'s own one-second default. Three templates each wrapped the
241
+ // raw `/step` call to say that; the wrapper is a suffix on the import now.
254
242
 
255
243
  /** A rate limit — and a model that ignored the format — are both expected. */
256
244
  critiqueDraft.maxRetries = 5;