@alexkroman1/aai-cli 6.11.0 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/dist/{_agent-DpH2pBJd.mjs → _agent-BTwZJu4P.mjs} +2 -2
  2. package/dist/{_bundler-C2BDwU7r.mjs → _bundler-B4RqNF5Z.mjs} +2 -2
  3. package/dist/_dev-env.d.ts +22 -2
  4. package/dist/{_dev-server-LLLWnFBs.mjs → _dev-server-CiKFH9qw.mjs} +38 -47
  5. package/dist/_dev-server.d.ts +0 -17
  6. package/dist/{_init-C57bWAQ8.mjs → _init-CE4HKdgV.mjs} +3 -2
  7. package/dist/{_preflight-BtfaYtbE.mjs → _preflight-Co1G9Jww.mjs} +1 -1
  8. package/dist/_preflight.d.ts +1 -1
  9. package/dist/{_slug-api-BxrsJXVI.mjs → _slug-api-nRFaBEPJ.mjs} +2 -2
  10. package/dist/{_studio-U3jhKlA3.mjs → _studio-DR5yzZ1T.mjs} +2 -1
  11. package/dist/{_templates-Dxb_P2Wz.mjs → _templates-FI3xPEMj.mjs} +1 -1
  12. package/dist/_workflow-scan.d.ts +108 -0
  13. package/dist/{build-Dfza2pRI.mjs → build-6jfaakgc.mjs} +5 -2
  14. package/dist/cli.mjs +30 -25
  15. package/dist/{client-bundler-BEIqgOtd.mjs → client-bundler-CWnG42cU.mjs} +2 -1
  16. package/dist/client-bundler.mjs +1 -1
  17. package/dist/{delete-vbpjGzqs.mjs → delete-Dl_LSfvI.mjs} +3 -3
  18. package/dist/{deploy-B8lubiRT.mjs → deploy-DvYeD8ia.mjs} +5 -5
  19. package/dist/{dev-Uq5ujP8a.mjs → dev-B59JIfWO.mjs} +1 -1
  20. package/dist/{eject-31gjtaHF.mjs → eject-C9WJyyr2.mjs} +1 -1
  21. package/dist/{init-l6xfU4xX.mjs → init-CpJVjEg0.mjs} +3 -3
  22. package/dist/{login-C71-qz8F.mjs → login-BBuM1sxH.mjs} +3 -4
  23. package/dist/{logs-BWVGSjLR.mjs → logs-9q-psa4q.mjs} +2 -2
  24. package/dist/project-config.mjs +1 -1
  25. package/dist/scaffold/CLAUDE.md +40 -19
  26. package/dist/scaffold/package.json +5 -3
  27. package/dist/scaffold/server.mjs +1 -1
  28. package/dist/{secret-BuMuFR4B.mjs → secret-CVvSLIDV.mjs} +2 -2
  29. package/dist/{storage-DzRZ-eCw.mjs → storage-BvUrnvM3.mjs} +2 -2
  30. package/dist/{studio-BCNUpDgP.mjs → studio--MUV0cid.mjs} +4 -4
  31. package/dist/templates/call-audit/agent.test.ts +57 -204
  32. package/dist/templates/call-audit/agent.ts +32 -19
  33. package/dist/templates/call-audit/client.tsx +17 -54
  34. package/dist/templates/call-audit/workflows/audit.ts +11 -18
  35. package/dist/templates/call-audit/workflows/ingest.ts +106 -114
  36. package/dist/templates/call-audit/workflows/media.ts +2 -12
  37. package/dist/templates/call-audit/workflows/summarize.ts +47 -52
  38. package/dist/templates/call-audit/workflows/sync-api.ts +7 -7
  39. package/dist/templates/dispatch-center/agent.test.ts +149 -25
  40. package/dist/templates/dispatch-center/client.tsx +239 -129
  41. package/dist/templates/dispatch-center/shared.ts +99 -1
  42. package/dist/templates/dispatch-center/system-prompt.md +3 -1
  43. package/dist/templates/dispatch-center/tools/incident_add_note.ts +16 -13
  44. package/dist/templates/dispatch-center/tools/incident_create.ts +19 -1
  45. package/dist/templates/dispatch-center/tools/incident_escalate.ts +68 -59
  46. package/dist/templates/dispatch-center/tools/incident_triage.ts +57 -43
  47. package/dist/templates/dispatch-center/tools/incident_update_status.ts +49 -40
  48. package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +8 -2
  49. package/dist/templates/dispatch-center/tools/resources_dispatch.ts +69 -52
  50. package/dist/templates/dispatch-center/tools/resources_update_status.ts +45 -41
  51. package/dist/templates/embedded-assets/agent.test.ts +17 -14
  52. package/dist/templates/health-assistant/agent.test.ts +22 -4
  53. package/dist/templates/infocom-adventure/agent.test.ts +4 -6
  54. package/dist/templates/infocom-adventure/client.tsx +246 -164
  55. package/dist/templates/link-digest/agent.test.ts +24 -19
  56. package/dist/templates/link-digest/client.tsx +47 -61
  57. package/dist/templates/link-digest/workflows/digest.ts +19 -31
  58. package/dist/templates/night-owl/agent.test.ts +70 -19
  59. package/dist/templates/night-owl/agent.ts +5 -0
  60. package/dist/templates/night-owl/client.tsx +56 -56
  61. package/dist/templates/night-owl/shared.ts +24 -0
  62. package/dist/templates/night-owl/tools/recommend.ts +18 -6
  63. package/dist/templates/pizza-ordering/agent.test.ts +27 -27
  64. package/dist/templates/pizza-ordering/client.tsx +9 -26
  65. package/dist/templates/plan-and-execute/agent.test.ts +99 -58
  66. package/dist/templates/plan-and-execute/agent.ts +1 -1
  67. package/dist/templates/plan-and-execute/client.tsx +12 -15
  68. package/dist/templates/plan-and-execute/shared.ts +71 -2
  69. package/dist/templates/plan-and-execute/tools/plan_status.ts +18 -6
  70. package/dist/templates/plan-and-execute/tools/revise_plan.ts +18 -10
  71. package/dist/templates/plan-and-execute/tools/start_plan.ts +13 -2
  72. package/dist/templates/plan-and-execute/tools/work_next_step.ts +70 -22
  73. package/dist/templates/podcast-digest/agent.test.ts +746 -0
  74. package/dist/templates/podcast-digest/agent.ts +139 -0
  75. package/dist/templates/podcast-digest/client.tsx +154 -0
  76. package/dist/templates/podcast-digest/workflows/digest.ts +411 -0
  77. package/dist/templates/podcast-digest/workflows/feeds.ts +507 -0
  78. package/dist/templates/podcast-digest/workflows/slack.ts +209 -0
  79. package/dist/templates/recap-workflow/agent.test.ts +97 -94
  80. package/dist/templates/recap-workflow/tools/recap_progress.ts +9 -12
  81. package/dist/templates/recap-workflow/tools/recap_status.ts +8 -9
  82. package/dist/templates/recap-workflow/tools/request_recap.ts +2 -1
  83. package/dist/templates/recap-workflow/workflows/recap.ts +36 -40
  84. package/dist/templates/redline/agent.test.ts +17 -15
  85. package/dist/templates/redline/client.tsx +12 -12
  86. package/dist/templates/redline/workflows/redline.ts +19 -31
  87. package/dist/templates/research-workflow/agent.test.ts +60 -59
  88. package/dist/templates/research-workflow/tools/research_progress.ts +9 -12
  89. package/dist/templates/research-workflow/tools/research_status.ts +9 -11
  90. package/dist/templates/research-workflow/workflows/research.ts +44 -61
  91. package/dist/templates/retail/agent.test.ts +26 -23
  92. package/dist/templates/retail/client.tsx +226 -117
  93. package/dist/templates/retail/registry.test.ts +38 -6
  94. package/dist/templates/retail/store.test.ts +82 -15
  95. package/dist/templates/retail/store.ts +174 -47
  96. package/dist/templates/retail/system-prompt.md +11 -2
  97. package/dist/templates/retail/tools/cancel_pending_order.ts +2 -2
  98. package/dist/templates/retail/tools/exchange_delivered_order_items.ts +2 -2
  99. package/dist/templates/retail/tools/find_user_id_by_email.ts +8 -5
  100. package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +4 -5
  101. package/dist/templates/retail/tools/get_item_details.ts +3 -4
  102. package/dist/templates/retail/tools/get_order_details.ts +3 -4
  103. package/dist/templates/retail/tools/get_product_details.ts +3 -4
  104. package/dist/templates/retail/tools/get_user_details.ts +2 -2
  105. package/dist/templates/retail/tools/list_all_product_types.ts +5 -5
  106. package/dist/templates/retail/tools/modify_pending_order_address.ts +2 -2
  107. package/dist/templates/retail/tools/modify_pending_order_items.ts +2 -3
  108. package/dist/templates/retail/tools/modify_pending_order_payment.ts +2 -2
  109. package/dist/templates/retail/tools/modify_user_address.ts +2 -2
  110. package/dist/templates/retail/tools/return_delivered_order_items.ts +2 -2
  111. package/dist/templates/retail/tools/transfer_to_human_agents.ts +8 -4
  112. package/dist/templates/solo-rpg/agent.test.ts +227 -48
  113. package/dist/templates/solo-rpg/client.tsx +38 -37
  114. package/dist/templates/solo-rpg/shared.ts +145 -19
  115. package/dist/templates/solo-rpg/system-prompt.md +3 -2
  116. package/dist/templates/solo-rpg/tools/action_roll.ts +82 -63
  117. package/dist/templates/solo-rpg/tools/burn_momentum.ts +85 -50
  118. package/dist/templates/solo-rpg/tools/check_state.ts +24 -7
  119. package/dist/templates/solo-rpg/tools/load_game.ts +13 -1
  120. package/dist/templates/solo-rpg/tools/save_game.ts +16 -7
  121. package/dist/templates/solo-rpg/tools/setup_character.ts +22 -2
  122. package/dist/templates/solo-rpg/tools/update_state.ts +113 -100
  123. package/dist/templates/spoken-summary/agent.test.ts +68 -97
  124. package/dist/templates/spoken-summary/agent.ts +30 -17
  125. package/dist/templates/spoken-summary/client.tsx +10 -50
  126. package/dist/templates/spoken-summary/workflows/summarize.ts +17 -22
  127. package/dist/templates/spoken-summary/workflows/transcribe.ts +19 -26
  128. package/dist/templates/support-line/agent.test.ts +11 -16
  129. package/dist/templates/support-line/agent.ts +1 -1
  130. package/dist/templates/support-line/client.tsx +9 -9
  131. package/dist/templates/support-line/nodes.ts +100 -0
  132. package/dist/templates/support-line/procedure.ts +407 -0
  133. package/dist/templates/support-line/tools/answer_question.ts +17 -4
  134. package/dist/templates/transcription-workflow/agent.test.ts +94 -194
  135. package/dist/templates/transcription-workflow/agent.ts +1 -1
  136. package/dist/templates/transcription-workflow/client.tsx +17 -42
  137. package/dist/templates/transcription-workflow/workflows/batch.ts +19 -22
  138. package/dist/templates/transcription-workflow/workflows/normalize.ts +90 -186
  139. package/dist/templates/transcription-workflow/workflows/stitch.ts +0 -11
  140. package/dist/templates/transcription-workflow/workflows/stream.ts +4 -5
  141. package/dist/templates/transcription-workflow/workflows/sync-api.ts +6 -6
  142. package/dist/templates/transcription-workflow/workflows/transcribe.ts +32 -26
  143. package/dist/templates/transcription-workflow/workflows/wav.ts +0 -31
  144. package/dist/templates/travel-concierge/agent.test.ts +64 -33
  145. package/dist/templates/travel-concierge/client.tsx +11 -23
  146. package/dist/templates/travel-concierge/routing.ts +34 -15
  147. package/dist/templates/travel-concierge/shared.ts +70 -3
  148. package/dist/templates/travel-concierge/tools/book_car_rental.ts +2 -2
  149. package/dist/templates/travel-concierge/tools/book_excursion.ts +2 -2
  150. package/dist/templates/travel-concierge/tools/book_hotel.ts +2 -2
  151. package/dist/templates/travel-concierge/tools/cancel_ticket.ts +2 -2
  152. package/dist/templates/travel-concierge/tools/update_ticket.ts +2 -2
  153. package/dist/{worker-bundler-CGD4r8Kc.mjs → worker-bundler-COxnqstQ.mjs} +221 -3
  154. package/dist/worker-bundler.mjs +1 -1
  155. package/dist/{workflow-CFpxOFfQ.mjs → workflow-D2AQf2Pl.mjs} +28 -1
  156. package/dist/workflow-bundler.d.ts +6 -1
  157. package/dist/workflow.d.ts +1 -1
  158. package/package.json +5 -4
  159. package/dist/templates/call-audit/workflows/temp-media.ts +0 -138
  160. package/dist/templates/support-line/graph.ts +0 -224
  161. package/dist/{_config-D_s09e7g.mjs → _config-CmJOFsAP.mjs} +1 -1
  162. /package/dist/templates/plan-and-execute/{graph.ts → procedure.ts} +0 -0
@@ -1,4 +1,5 @@
1
- import { isTerminal, tool } from "@alexkroman1/aai";
1
+ import { tool } from "@alexkroman1/aai";
2
+ import { isTerminal } from "@alexkroman1/aai/workflow-api";
2
3
  import { z } from "zod";
3
4
  import { recap, SAMPLE_RECORDING } from "../shared.ts";
4
5
 
@@ -55,7 +55,7 @@
55
55
  * Everything the desk claims to do. `submitRecording`, `checkTranscript` and
56
56
  * `discardTranscript` are AssemblyAI's pre-recorded API (`POST`, `GET` and
57
57
  * `DELETE` on `/v2/transcript`), and `summarize` is a real model call through
58
- * `stepGenerate`. The BATCH API is what makes the polling port honest: it
58
+ * `stepGenerateJsonClassified`. The BATCH API is what makes the polling port honest: it
59
59
  * answers with a job id in milliseconds and finishes minutes later, so the wait
60
60
  * is the provider's, not a `setTimeout` this template chose. (Its sibling
61
61
  * `transcription-workflow` takes the other endpoint — the sync one, which answers in
@@ -67,17 +67,14 @@
67
67
  * just your shell.
68
68
  */
69
69
 
70
- import { throwStepError, toStepError } from "@alexkroman1/aai/step-errors";
70
+ import { report, requireStepEnv, stepFetch } from "@alexkroman1/aai/step";
71
71
  import {
72
- errorMessage,
73
- isRecord,
74
- omitUndefined,
75
- report,
76
- requireStepEnv,
77
- stepFetch,
78
- stepGenerateJson,
79
- stepTranscribeSubmit,
80
- } from "@alexkroman1/aai/utils";
72
+ stepFetchOk,
73
+ stepGenerateJsonClassified,
74
+ stepTranscribeSubmitClassified,
75
+ toStepError,
76
+ } from "@alexkroman1/aai/step-errors";
77
+ import { errorMessage, isRecord, omitUndefined } from "@alexkroman1/aai/utils";
81
78
  import { createHook, FatalError, sleep } from "workflow";
82
79
  import { z } from "zod";
83
80
  import { retentionToken } from "./tokens.ts";
@@ -151,7 +148,7 @@ const POINTS = 3;
151
148
  /**
152
149
  * The shape the model must answer in.
153
150
  *
154
- * `stepGenerateJson` validates against this and throws PLAINLY when the reply
151
+ * `stepGenerateJsonClassified` validates against this and throws PLAINLY when the reply
155
152
  * misses, which is the retry policy in one distinction: a model that answered in
156
153
  * prose may answer correctly next time, where a 401 will not. `spoken` is the
157
154
  * field this template exists for — without it the announced turn has nothing to
@@ -257,7 +254,7 @@ export async function recapFlow(input: { url: string; requestedBy: string }) {
257
254
  * to completion in a worker, where the body is what may suspend. Splitting it
258
255
  * out keeps `recapFlow` readable and costs nothing, since the WDK transform
259
256
  * rewrites a step's DECLARATION rather than its call sites, so a step called
260
- * from a helper is still a real step (`mapInBatches` rests on the same
257
+ * from a helper is still a real step (`mapConcurrent` rests on the same
261
258
  * property).
262
259
  *
263
260
  * The loop is deterministic despite looking like it is not: every branch turns
@@ -359,13 +356,14 @@ export async function submitRecording(url: string): Promise<{ id: string }> {
359
356
 
360
357
  await report(`Submitting ${new URL(url).hostname} for transcription…`);
361
358
 
362
- // `stepTranscribeSubmit` owns the endpoint, the raw-key auth, the PLURAL
363
- // `speech_models` field and the failure classification. `speaker_labels` is
364
- // this desk's own request, which is what `params` is for — the async API's
359
+ // `stepTranscribeSubmitClassified` owns the endpoint, the raw-key auth, the
360
+ // PLURAL `speech_models` field and the failure classification the
361
+ // `Classified` suffix being that last part: it is `stepTranscribeSubmit` with
362
+ // `throwStepError` already applied, so a provider refusal stays terminal and a
363
+ // rate limit waits out the delay the provider itself named. `speaker_labels`
364
+ // is this desk's own request, which is what `params` is for — the async API's
365
365
  // surface is large and the SDK deliberately does not mirror it.
366
- return await stepTranscribeSubmit(url, { params: { speaker_labels: true } }).catch(
367
- throwStepError,
368
- );
366
+ return await stepTranscribeSubmitClassified(url, { params: { speaker_labels: true } });
369
367
  }
370
368
 
371
369
  /**
@@ -467,19 +465,20 @@ export async function summarize(url: string, transcript: TranscriptState): Promi
467
465
  throw new FatalError("That recording came back with no speech in it.");
468
466
  }
469
467
 
470
- // `stepGenerateJson` unwraps the fence a model puts around JSON however firmly
471
- // it is told not to, parses it, and validates it — all four things this step
472
- // used to re-derive. `throwStepError` is what makes a terminal gateway failure
473
- // (a bad key, a rejected request) stop rather than burn the remaining
474
- // attempts, where a reply that missed the SHAPE throws plainly and retries.
475
- const parsed = await stepGenerateJson(text, {
468
+ // `stepGenerateJsonClassified` unwraps the fence a model puts around JSON
469
+ // however firmly it is told not to, parses it, and validates it — all four
470
+ // things this step used to re-derive. The `Classified` half is what makes a
471
+ // terminal gateway failure (a bad key, a rejected request) stop rather than
472
+ // burn the remaining attempts, where a reply that missed the SHAPE throws
473
+ // plainly and retries.
474
+ const parsed = await stepGenerateJsonClassified(text, {
476
475
  schema: RecapReply,
477
476
  system:
478
477
  "You write up recordings for someone who will hear the result on a phone call. " +
479
478
  `Reply with JSON only: {"headline": string, "points": string[], "spoken": string}. ` +
480
479
  `Give exactly ${POINTS} points. "spoken" is ONE sentence, under 30 words, ` +
481
480
  "written to be read aloud. No markdown fence, no preamble.",
482
- }).catch(throwStepError);
481
+ });
483
482
 
484
483
  return {
485
484
  url,
@@ -519,27 +518,24 @@ async function request(
519
518
  url: string,
520
519
  init: { method?: string; body?: string } = {},
521
520
  ): Promise<Response> {
522
- // `stepFetch`, not `fetch`: it pins HTTP/1.1, so several concurrent runs (and
523
- // this workflow POLLS, so one run is many requests) get a socket each rather
524
- // than N streams on one connection — and a connection failure arrives as a
525
- // `StepTransportError` naming its cause instead of a bare
521
+ // Through `stepFetch`, not `fetch`: it pins HTTP/1.1, so several concurrent
522
+ // runs (and this workflow POLLS, so one run is many requests) get a socket
523
+ // each rather than N streams on one connection — and a connection failure
524
+ // arrives as a `StepTransportError` naming its cause instead of a bare
526
525
  // `TypeError: fetch failed`, which for a template whose whole subject is
527
526
  // durability is the difference between a diagnosable resume and a mystery.
528
527
  // `sdk/step-fetch.ts` carries the measurements.
529
- const response = await stepFetch(url, {
528
+ // `stepFetchOk` makes the three-way retry decision: a 401 or a 400 answers the
529
+ // same way on the fourth attempt and burns the step, a 429 or a 5xx is what
530
+ // retries are for, and a `Retry-After` the provider named is waited out rather
531
+ // than replaced by the DevKit's one-second default — which matters here more
532
+ // than usual, because a fan-out of segments hits a rate limit together. The
533
+ // DELETE below stays on plain `stepFetch`, because there a 404 is a SUCCESS.
534
+ return await stepFetchOk(url, {
530
535
  ...init,
531
536
  headers: { authorization: requireStepEnv(API_KEY_ENV), "content-type": "application/json" },
532
537
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
533
538
  });
534
- // The three-way retry decision, made by the SDK: a 401 or a 400 answers the
535
- // same way on the fourth attempt and burns the step, a 429 or a 5xx is what
536
- // retries are for, and a `Retry-After` the provider named is waited out rather
537
- // than replaced by the DevKit's one-second default — which matters here more
538
- // than usual, because a fan-out of segments hits a rate limit together.
539
- if (!response.ok) {
540
- throw toStepError(response, `${init.method ?? "GET"} ${url} failed: HTTP ${response.status}`);
541
- }
542
- return response;
543
539
  }
544
540
 
545
541
  /** A string field of a JSON body, when it really is one. */
@@ -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;
@@ -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
  });