@alexkroman1/aai-cli 13.2.0 → 14.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 (152) hide show
  1. package/bin.mjs +1 -1
  2. package/dist/{_agent-BTwZJu4P.mjs → _agent-BzUeqOdj.mjs} +33 -10
  3. package/dist/_agent.d.ts +23 -0
  4. package/dist/{_bundler-C31rlcYz.mjs → _bundler-DolUCMxu.mjs} +9 -8
  5. package/dist/_cli-common.d.ts +45 -6
  6. package/dist/{_config-CmJOFsAP.mjs → _config-DYzC6WMD.mjs} +7 -4
  7. package/dist/_config.d.ts +25 -1
  8. package/dist/_dev-env.d.ts +1 -1
  9. package/dist/{_dev-server-DWvkNoIS.mjs → _dev-server-CSMqF8PN.mjs} +28 -30
  10. package/dist/_dev-vite-config.d.ts +3 -3
  11. package/dist/_e2e-test-utils.d.ts +15 -7
  12. package/dist/{_init-BURbdHNM.mjs → _init-CQ8idAwo.mjs} +64 -30
  13. package/dist/{_output-CKkmWs7i.mjs → _output-DBP9Op_d.mjs} +43 -3
  14. package/dist/_output.d.ts +24 -7
  15. package/dist/{_preflight-Co1G9Jww.mjs → _preflight-CcAR577V.mjs} +3 -3
  16. package/dist/_resource-commands.d.ts +9 -9
  17. package/dist/{_server-common-6e0QI4mq.mjs → _server-common-vILJp3it.mjs} +3 -3
  18. package/dist/{_slug-api-CfGQq1Tt.mjs → _slug-api-CGvc2YSQ.mjs} +19 -10
  19. package/dist/_slug-api.d.ts +19 -0
  20. package/dist/{_studio-B1waXMuR.mjs → _studio-DnR_BqFp.mjs} +9 -5
  21. package/dist/_studio-commands.d.ts +13 -13
  22. package/dist/{_templates-0Zr2z6yA.mjs → _templates-CK4oKoeX.mjs} +3 -3
  23. package/dist/{_typecheck-gate-BXvUNnfs.mjs → _typecheck-gate-U3jcil8L.mjs} +5 -4
  24. package/dist/_typecheck-gate.d.ts +3 -1
  25. package/dist/{_ui-u7T4YooX.mjs → _ui-DCt4qZrk.mjs} +1 -1
  26. package/dist/{_utils-B8QmtFhK.mjs → _utils-D5JGcjiW.mjs} +44 -2
  27. package/dist/_utils.d.ts +35 -0
  28. package/dist/{_vite-env-D9Qj6KPX.mjs → _vite-env-kejF0p2L.mjs} +1 -1
  29. package/dist/{_workflow-determinism-f9J-5Zlj.mjs → _workflow-determinism-CvpqMpdh.mjs} +19 -27
  30. package/dist/_workflow-determinism.d.ts +3 -3
  31. package/dist/build-Mxk8gWvX.mjs +108 -0
  32. package/dist/build.d.ts +5 -0
  33. package/dist/cli.mjs +172 -116
  34. package/dist/{client-bundler-j5c46x0G.mjs → client-bundler-BJgREAh6.mjs} +4 -4
  35. package/dist/client-bundler.mjs +1 -1
  36. package/dist/{delete-X_CRgCf6.mjs → delete-BdFnbnVR.mjs} +6 -6
  37. package/dist/{deploy-BzZ-r4iM.mjs → deploy-uAJ4NukN.mjs} +14 -14
  38. package/dist/{dev-BDykFvcc.mjs → dev-DApPSaE_.mjs} +5 -5
  39. package/dist/{eval-0qEOdm0l.mjs → eval-BK47A_K5.mjs} +27 -11
  40. package/dist/eval.d.ts +2 -0
  41. package/dist/{init-BtJI306C.mjs → init-DukDxECd.mjs} +13 -21
  42. package/dist/{login-DeUETobb.mjs → login-DaLrPOFg.mjs} +6 -8
  43. package/dist/{logs-hWnJ66Bl.mjs → logs-7BRwq0cG.mjs} +22 -15
  44. package/dist/project-config.mjs +1 -1
  45. package/dist/scaffold/.env.example +17 -1
  46. package/dist/scaffold/CLAUDE.md +84 -56
  47. package/dist/scaffold/package.json +6 -5
  48. package/dist/scaffold/server.mjs +129 -25
  49. package/dist/secret-Cjp912yF.mjs +162 -0
  50. package/dist/secret.d.ts +48 -13
  51. package/dist/{studio-BxAS_FQQ.mjs → studio-CpHlNHUZ.mjs} +15 -17
  52. package/dist/templates/briefing-desk/agent.test.ts +7 -3
  53. package/dist/templates/call-audit/agent.test.ts +25 -9
  54. package/dist/templates/call-audit/agent.ts +2 -2
  55. package/dist/templates/call-audit/client.tsx +40 -58
  56. package/dist/templates/call-audit/workflows/audit.ts +7 -5
  57. package/dist/templates/call-audit/workflows/ingest.ts +7 -7
  58. package/dist/templates/call-audit/workflows/media.ts +12 -37
  59. package/dist/templates/call-audit/workflows/summarize.ts +8 -8
  60. package/dist/templates/call-audit/workflows/sync-api.ts +3 -3
  61. package/dist/templates/dispatch-center/agent.test.ts +7 -7
  62. package/dist/templates/dispatch-center/client.tsx +53 -61
  63. package/dist/templates/dispatch-center/shared.ts +0 -8
  64. package/dist/templates/dispatch-center/tools/ops_dashboard.ts +4 -1
  65. package/dist/templates/embedded-assets/agent.eval.test.ts +4 -2
  66. package/dist/templates/embedded-assets/agent.test.ts +7 -1
  67. package/dist/templates/health-assistant/agent.eval.test.ts +4 -2
  68. package/dist/templates/health-assistant/agent.test.ts +8 -4
  69. package/dist/templates/infocom-adventure/client.tsx +40 -36
  70. package/dist/templates/link-digest/agent.test.ts +10 -4
  71. package/dist/templates/link-digest/agent.ts +2 -2
  72. package/dist/templates/link-digest/client.tsx +42 -36
  73. package/dist/templates/link-digest/workflows/digest.ts +13 -16
  74. package/dist/templates/night-owl/agent.eval.test.ts +4 -2
  75. package/dist/templates/night-owl/agent.test.ts +6 -1
  76. package/dist/templates/night-owl/client.tsx +8 -2
  77. package/dist/templates/pipeline-simple/agent.test.ts +33 -16
  78. package/dist/templates/pizza-ordering/client.tsx +5 -4
  79. package/dist/templates/pizza-ordering/shared.ts +3 -8
  80. package/dist/templates/pizza-ordering/tools/add_pizza.ts +3 -2
  81. package/dist/templates/pizza-ordering/tools/place_order.ts +3 -2
  82. package/dist/templates/pizza-ordering/tools/remove_pizza.ts +3 -2
  83. package/dist/templates/pizza-ordering/tools/update_pizza.ts +3 -2
  84. package/dist/templates/pizza-ordering/tools/view_order.ts +3 -2
  85. package/dist/templates/plan-and-execute/agent.test.ts +10 -5
  86. package/dist/templates/plan-and-execute/client.tsx +2 -2
  87. package/dist/templates/podcast-digest/agent.test.ts +33 -14
  88. package/dist/templates/podcast-digest/client.tsx +27 -36
  89. package/dist/templates/podcast-digest/workflows/digest.ts +21 -19
  90. package/dist/templates/podcast-digest/workflows/feeds.ts +61 -33
  91. package/dist/templates/podcast-digest/workflows/slack.ts +7 -7
  92. package/dist/templates/recap-workflow/agent.eval.test.ts +21 -13
  93. package/dist/templates/recap-workflow/agent.test.ts +49 -32
  94. package/dist/templates/recap-workflow/workflows/recap.ts +24 -28
  95. package/dist/templates/redline/agent.eval.test.ts +5 -8
  96. package/dist/templates/redline/agent.test.ts +8 -2
  97. package/dist/templates/redline/client.tsx +47 -48
  98. package/dist/templates/redline/workflows/redline.ts +17 -17
  99. package/dist/templates/research-workflow/agent.eval.test.ts +5 -5
  100. package/dist/templates/research-workflow/agent.test.ts +17 -8
  101. package/dist/templates/research-workflow/workflows/research.ts +28 -30
  102. package/dist/templates/retail/agent.test.ts +17 -15
  103. package/dist/templates/retail/cancel.ts +7 -6
  104. package/dist/templates/retail/client.tsx +39 -58
  105. package/dist/templates/retail/payment.ts +4 -3
  106. package/dist/templates/retail/pending.test.ts +19 -19
  107. package/dist/templates/retail/registry.test.ts +99 -58
  108. package/dist/templates/retail/store.test.ts +5 -5
  109. package/dist/templates/retail/swap.ts +8 -11
  110. package/dist/templates/simple/agent.test.ts +39 -6
  111. package/dist/templates/solo-rpg/agent.eval.test.ts +2 -16
  112. package/dist/templates/solo-rpg/agent.test.ts +12 -12
  113. package/dist/templates/solo-rpg/client.tsx +3 -3
  114. package/dist/templates/spoken-summary/agent.test.ts +21 -12
  115. package/dist/templates/spoken-summary/agent.ts +3 -3
  116. package/dist/templates/spoken-summary/client.tsx +36 -47
  117. package/dist/templates/spoken-summary/workflows/summarize.ts +11 -11
  118. package/dist/templates/spoken-summary/workflows/transcribe.ts +14 -14
  119. package/dist/templates/support-line/client.tsx +4 -3
  120. package/dist/templates/support-line/prompts.ts +0 -2
  121. package/dist/templates/transcription-workflow/agent.test.ts +36 -20
  122. package/dist/templates/transcription-workflow/agent.ts +2 -2
  123. package/dist/templates/transcription-workflow/client.tsx +34 -40
  124. package/dist/templates/transcription-workflow/recover.ts +1 -1
  125. package/dist/templates/transcription-workflow/run-panel.tsx +34 -14
  126. package/dist/templates/transcription-workflow/total-latency.tsx +46 -20
  127. package/dist/templates/transcription-workflow/workflows/batch.ts +15 -15
  128. package/dist/templates/transcription-workflow/workflows/downsample.ts +1 -1
  129. package/dist/templates/transcription-workflow/workflows/normalize.ts +15 -19
  130. package/dist/templates/transcription-workflow/workflows/stream.ts +12 -12
  131. package/dist/templates/transcription-workflow/workflows/sync-api.ts +3 -3
  132. package/dist/templates/transcription-workflow/workflows/transcribe.ts +23 -34
  133. package/dist/templates/travel-concierge/agent.eval.test.ts +2 -2
  134. package/dist/templates/travel-concierge/agent.test.ts +15 -7
  135. package/dist/templates/travel-concierge/client.tsx +2 -2
  136. package/dist/templates/travel-concierge/shared.ts +10 -14
  137. package/dist/templates/travel-concierge/tools/lookup_booking.ts +4 -3
  138. package/dist/templates/travel-concierge/tools/search_car_rentals.ts +3 -2
  139. package/dist/templates/travel-concierge/tools/search_excursions.ts +3 -2
  140. package/dist/templates/travel-concierge/tools/search_flights.ts +3 -2
  141. package/dist/templates/travel-concierge/tools/search_hotels.ts +3 -2
  142. package/dist/test-D032V2cA.mjs +242 -0
  143. package/dist/test.d.ts +97 -31
  144. package/dist/typecheck.mjs +2 -2
  145. package/dist/worker-bundler.mjs +3 -3
  146. package/dist/{workflow-BBu5oEDw.mjs → workflow-CVioyO3V.mjs} +8 -7
  147. package/package.json +10 -10
  148. package/dist/bin.d.mts +0 -2
  149. package/dist/build-BLlLOrng.mjs +0 -66
  150. package/dist/secret-B-3T5tvr.mjs +0 -61
  151. package/dist/test-CiLab-AA.mjs +0 -182
  152. package/dist/tsdown.config.d.ts +0 -2
@@ -99,7 +99,7 @@
99
99
  * Everything the desk claims to do. `submitRecording`, `checkTranscript` and
100
100
  * `discardTranscript` are AssemblyAI's pre-recorded API (`POST`, `GET` and
101
101
  * `DELETE` on `/v2/transcript`), and `summarize` is a real model call through
102
- * `stepGenerateJsonClassified`. The BATCH API is what makes the polling port honest: it
102
+ * `stepGenerateJsonOrFail`. The BATCH API is what makes the polling port honest: it
103
103
  * answers with a job id in milliseconds and finishes minutes later, so the wait
104
104
  * is the provider's, not a `setTimeout` this template chose. (Its sibling
105
105
  * `transcription-workflow` takes the other endpoint — the sync one, which answers in
@@ -111,13 +111,13 @@
111
111
  * just your shell.
112
112
  */
113
113
 
114
- import type { WorkflowCtx } from "@alexkroman1/aai";
115
- import { report, requireStepEnv, stepFetch, stepWebhookUrl } from "@alexkroman1/aai/step";
114
+ import type { WorkflowContext } from "@alexkroman1/aai";
115
+ import { requireStepEnv, stepFetch, stepReport, stepWebhookUrl } from "@alexkroman1/aai/step";
116
116
  import {
117
117
  FatalError,
118
- stepFetchOk,
119
- stepGenerateJsonClassified,
120
- stepTranscribeSubmitClassified,
118
+ stepFetchOrFail,
119
+ stepGenerateJsonOrFail,
120
+ stepTranscribeSubmitOrFail,
121
121
  toStepError,
122
122
  } from "@alexkroman1/aai/step-errors";
123
123
  import { errorMessage, omitUndefined } from "@alexkroman1/aai/utils";
@@ -239,7 +239,7 @@ const POINTS = 3;
239
239
  /**
240
240
  * The shape the model must answer in.
241
241
  *
242
- * `stepGenerateJsonClassified` validates against this and throws PLAINLY when the reply
242
+ * `stepGenerateJsonOrFail` validates against this and throws PLAINLY when the reply
243
243
  * misses, which is the retry policy in one distinction: a model that answered in
244
244
  * prose may answer correctly next time, where a 401 will not. `spoken` is the
245
245
  * field this template exists for — without it the announced turn has nothing to
@@ -347,7 +347,7 @@ export type Compensation = { label: string; undo: () => Promise<void> };
347
347
  * `agent.ts` reads back down the phone, so it is shaped for an ear rather than
348
348
  * a page.
349
349
  */
350
- export async function recapFlow(input: { url: string; requestedBy: string }, ctx: WorkflowCtx) {
350
+ export async function recapFlow(input: { url: string; requestedBy: string }, ctx: WorkflowContext) {
351
351
  // The compensation stack, newest first — `unshift` after each successful
352
352
  // acquisition, exactly as Temporal's `openAccount` does. Registering the undo
353
353
  // AFTER the step it undoes is the whole discipline: a step that never
@@ -462,7 +462,7 @@ export async function recapFlow(input: { url: string; requestedBy: string }, ctx
462
462
  */
463
463
  export async function awaitTranscript(
464
464
  id: string,
465
- ctx: WorkflowCtx,
465
+ ctx: WorkflowContext,
466
466
  nudge?: string,
467
467
  ): Promise<TranscriptState> {
468
468
  // Which turn says "still going". A pure function of `nudge`, which the body
@@ -518,7 +518,7 @@ export async function askWhetherToKeep(
518
518
  requestedBy: string,
519
519
  transcriptId: string,
520
520
  compensations: Compensation[],
521
- ctx: WorkflowCtx,
521
+ ctx: WorkflowContext,
522
522
  ): Promise<Retention> {
523
523
  await ctx.step("noteGate", () =>
524
524
  note(
@@ -560,7 +560,7 @@ export async function askWhetherToKeep(
560
560
  export async function compensate(
561
561
  compensations: Compensation[],
562
562
  because: string,
563
- ctx: WorkflowCtx,
563
+ ctx: WorkflowContext,
564
564
  ): Promise<void> {
565
565
  if (compensations.length === 0) return;
566
566
  // The narration is a STEP like every other, so an unwind interrupted by a
@@ -607,9 +607,9 @@ export async function submitRecording(
607
607
  url: string,
608
608
  webhookUrl?: string,
609
609
  ): Promise<{ id: string; callback: boolean }> {
610
- await report(`Submitting ${new URL(url).hostname} for transcription…`);
610
+ await stepReport(`Submitting ${new URL(url).hostname} for transcription…`);
611
611
 
612
- // `stepTranscribeSubmitClassified` owns the endpoint, the raw-key auth, the
612
+ // `stepTranscribeSubmitOrFail` owns the endpoint, the raw-key auth, the
613
613
  // PLURAL `speech_models` field and the failure classification — the
614
614
  // `Classified` suffix being that last part: it is `stepTranscribeSubmit` with
615
615
  // `throwStepError` already applied, so a provider refusal stays terminal and a
@@ -625,7 +625,7 @@ export async function submitRecording(
625
625
  // What must not creep in is a `?? null` or a `?? ""` to "be explicit": either
626
626
  // one puts the key back, and a provider handed a null for a URL is entitled to
627
627
  // refuse the whole submission.
628
- const job = await stepTranscribeSubmitClassified(url, {
628
+ const job = await stepTranscribeSubmitOrFail(url, {
629
629
  params: { speaker_labels: true, webhook_url: webhookUrl },
630
630
  });
631
631
  return { id: job.id, callback: webhookUrl !== undefined };
@@ -698,7 +698,7 @@ export async function checkTranscript(id: string): Promise<TranscriptState> {
698
698
  );
699
699
  }
700
700
 
701
- await report(`Transcript ${parsed.data.status}.`);
701
+ await stepReport(`Transcript ${parsed.data.status}.`);
702
702
  return {
703
703
  status: parsed.data.status,
704
704
  // `omitUndefined` rather than a spread-ternary per field: under
@@ -730,7 +730,7 @@ export async function checkTranscript(id: string): Promise<TranscriptState> {
730
730
  * a replay is exactly that world.
731
731
  */
732
732
  export async function discardTranscript(id: string): Promise<void> {
733
- await report(`Discarding transcript ${id}.`);
733
+ await stepReport(`Discarding transcript ${id}.`);
734
734
  // Not through `request` above, because a 404 is a SUCCESS here — see below.
735
735
  // `stepFetch` for the same reason it does; only the status handling differs.
736
736
  const response = await stepFetch(`${TRANSCRIPT_ENDPOINT}/${id}`, {
@@ -756,7 +756,7 @@ export async function discardTranscript(id: string): Promise<void> {
756
756
  * journal instead of submitting the recording again.
757
757
  */
758
758
  export async function summarize(url: string, transcript: TranscriptState): Promise<Recap> {
759
- await report("Writing the recap.");
759
+ await stepReport("Writing the recap.");
760
760
 
761
761
  const text = (transcript.text ?? "").slice(0, MAX_TRANSCRIPT_CHARS);
762
762
  if (text.trim() === "") {
@@ -765,13 +765,13 @@ export async function summarize(url: string, transcript: TranscriptState): Promi
765
765
  throw new FatalError("That recording came back with no speech in it.");
766
766
  }
767
767
 
768
- // `stepGenerateJsonClassified` unwraps the fence a model puts around JSON
768
+ // `stepGenerateJsonOrFail` unwraps the fence a model puts around JSON
769
769
  // however firmly it is told not to, parses it, and validates it — all four
770
770
  // things this step used to re-derive. The `Classified` half is what makes a
771
771
  // terminal gateway failure (a bad key, a rejected request) stop rather than
772
772
  // burn the remaining attempts, where a reply that missed the SHAPE throws
773
773
  // plainly and retries.
774
- const parsed = await stepGenerateJsonClassified(text, {
774
+ const parsed = await stepGenerateJsonOrFail(text, {
775
775
  schema: RecapReply,
776
776
  system:
777
777
  "You write up recordings for someone who will hear the result on a phone call. " +
@@ -792,13 +792,13 @@ export async function summarize(url: string, transcript: TranscriptState): Promi
792
792
  /**
793
793
  * Say one line into the run's progress channel.
794
794
  *
795
- * A step for one reason: the body REPLAYS, so a `report()` written there is
795
+ * A step for one reason: the body REPLAYS, so a `stepReport()` written there is
796
796
  * re-emitted on every resume. Everything the body itself wants to narrate —
797
797
  * the slow-recording note, the unwind — comes through here, and `agent.ts`'s
798
798
  * `recap_progress` is what reads it back down the phone.
799
799
  */
800
800
  export async function note(line: string): Promise<void> {
801
- await report(line);
801
+ await stepReport(line);
802
802
  }
803
803
 
804
804
  // ---- HTTP -------------------------------------------------------------------
@@ -810,10 +810,7 @@ export async function note(line: string): Promise<void> {
810
810
  * Note the header is a bare key: AssemblyAI's `authorization` takes the key
811
811
  * itself, with no `Bearer` prefix.
812
812
  */
813
- async function request(
814
- url: string,
815
- init: { method?: string; body?: string } = {},
816
- ): Promise<Response> {
813
+ async function request(url: string): Promise<Response> {
817
814
  // Through `stepFetch`, not `fetch`: it pins HTTP/1.1, so several concurrent
818
815
  // runs (and this workflow POLLS, so one run is many requests) get a socket
819
816
  // each rather than N streams on one connection — and a connection failure
@@ -821,14 +818,13 @@ async function request(
821
818
  // `TypeError: fetch failed`, which for a template whose whole subject is
822
819
  // durability is the difference between a diagnosable resume and a mystery.
823
820
  // `sdk/step-fetch.ts` carries the measurements.
824
- // `stepFetchOk` makes the three-way retry decision: a 401 or a 400 answers the
821
+ // `stepFetchOrFail` makes the three-way retry decision: a 401 or a 400 answers the
825
822
  // same way on the fourth attempt and burns the step, a 429 or a 5xx is what
826
823
  // retries are for, and a `Retry-After` the provider named is waited out rather
827
824
  // than replaced by the DevKit's one-second default — which matters here more
828
825
  // than usual, because a fan-out of segments hits a rate limit together. The
829
826
  // DELETE below stays on plain `stepFetch`, because there a 404 is a SUCCESS.
830
- return await stepFetchOk(url, {
831
- ...init,
827
+ return await stepFetchOrFail(url, {
832
828
  headers: { authorization: requireStepEnv(API_KEY_ENV), "content-type": "application/json" },
833
829
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
834
830
  });
@@ -29,7 +29,7 @@
29
29
  // journal, no replay, and no per-step retry, so a rate-limited live run FAILS
30
30
  // where a deployed one would have ridden it out. The tier that really resumes a
31
31
  // run is `aai-cli`'s `dev-workflow.scenario.test.ts`.
32
- import { stubGatewayRoute } from "@alexkroman1/aai/testing";
32
+ import { routeStepFetch, stubGatewayRoute } from "@alexkroman1/aai/testing";
33
33
  import { installStubStepFetch } from "@alexkroman1/aai/testing/vitest";
34
34
  import { describeWorkflowEval } from "@alexkroman1/aai-runtime/eval/vitest";
35
35
  import { expect } from "vitest";
@@ -75,13 +75,10 @@ const critique = (verdict: "ship" | "revise", score = 8): string =>
75
75
  */
76
76
  function scriptGateway(contents: readonly string[]) {
77
77
  const model = stubGatewayRoute(contents);
78
- installStubStepFetch((request) => {
79
- const answered = model.route(request);
80
- if (answered === undefined) {
81
- throw new Error(`unexpected step request in an eval: ${request.method} ${request.url}`);
82
- }
83
- return answered;
84
- });
78
+ // `routeStepFetch` defaults to throwing on an unrecognised request, which is
79
+ // what this file wants and what it used to spell out: every step in this body
80
+ // is a model call, so anything the route does not recognise is a finding.
81
+ installStubStepFetch(routeStepFetch([model.route]));
85
82
  return model;
86
83
  }
87
84
 
@@ -46,7 +46,13 @@ describe("the agent declares itself a workflow app", () => {
46
46
  test("under the name the page starts a run by", () => {
47
47
  // `useWorkflowSubmit("redline")` in client.tsx names this key. Nothing else
48
48
  // records it, so a rename here is a 400 there rather than a compile error.
49
- expect(Object.keys(agentDef.workflows ?? {})).toEqual(["redline"]);
49
+ // `toContain` rather than an exact key list: adding a second workflow is an
50
+ // invited edit and must not redden a test the author did not write. The
51
+ // NAME is still pinned, deliberately — the page starts a run by this
52
+ // string, so renaming the key is a runtime 400 rather than a compile
53
+ // error, and this pin is the only thing that says so. Rename it here and
54
+ // in `client.tsx` together.
55
+ expect(Object.keys(agentDef.workflows ?? {})).toContain("redline");
50
56
  expect(agentDef.workflows?.redline).toBe(redline);
51
57
  });
52
58
 
@@ -222,7 +228,7 @@ describe("the steps", () => {
222
228
  * to take the same branch on every walk.
223
229
  *
224
230
  * The model is the whole world here (`writeDraft`, `critiqueDraft` and
225
- * `reviseDraft` are all `stepGenerate*Classified`), so `stubGateway`'s scripted
231
+ * `reviseDraft` are all `stepGenerate*OrFail`), so `stubGateway`'s scripted
226
232
  * replies ARE the run, and its call log is what proves a replay did not pay for
227
233
  * a round twice. Scripted in body order, with the last reply repeating.
228
234
  */
@@ -31,11 +31,13 @@
31
31
  * in React state — so a refresh lost it while the loop carried on writing,
32
32
  * grading and revising without anywhere to report to. On a desk whose whole
33
33
  * subject is a loop that runs several long-form model calls, that is the one
34
- * failure worth two lines of wiring: `key` is the handle that survives a reload
35
- * and `recover` is what reads it back, so the draft, the critique trail and the
36
- * Clear button are all there again.
34
+ * failure the hook now covers on its own: `useWorkflowSubmit` records every run
35
+ * under a correlation KEY it mints for this page and asks for that key's newest
36
+ * run as it mounts, so the draft, the critique trail and the Clear button are
37
+ * all there again with nothing written here.
37
38
  *
38
- * **The key is opaque and lives in `sessionStorage`, and the brief is why.** A
39
+ * **The key it mints is opaque and lives in `sessionStorage`, and the brief is
40
+ * why this page wants exactly that one.** A
39
41
  * `?key=` parameter in the page's own URL would survive more — a new tab, a
40
42
  * bookmark, a link sent to the person who asked for the piece — and that is
41
43
  * exactly what it must not do here. There is no per-user filtering behind
@@ -48,26 +50,28 @@
48
50
  *
49
51
  * Deriving the key from the brief is worse again: two people briefing the same
50
52
  * thing would recover each other's runs, and the key would then carry what they
51
- * typed into a lookup token the platform deliberately stopped logging.
53
+ * typed into a lookup token the platform deliberately stopped logging. Both are
54
+ * things a page could still ask for by passing its own `key`, and this one has
55
+ * no reason to.
52
56
  */
53
57
 
54
58
  import "@alexkroman1/aai-ui/styles.css";
55
59
  import { plural } from "@alexkroman1/aai/utils";
56
60
  import type { WorkflowInputOf, WorkflowOutputOf } from "@alexkroman1/aai/workflow-api";
57
61
  import {
62
+ BulletList,
63
+ Facts,
58
64
  Form,
59
65
  type FormValues,
60
- page,
66
+ mountPage,
61
67
  SubmitButton,
62
68
  TextAreaField,
63
- useRunKey,
64
69
  useWorkflowSubmit,
65
70
  WORKFLOW_STATUS_LABELS,
66
71
  WorkflowFields,
67
72
  WorkflowProgress,
68
73
  type WorkflowRun,
69
74
  } from "@alexkroman1/aai-ui";
70
- import { useState } from "react";
71
75
  import type { redline } from "./agent.ts";
72
76
 
73
77
  /**
@@ -121,19 +125,10 @@ export function toInput(values: FormValues): WorkflowInputOf<typeof redline> {
121
125
  }
122
126
 
123
127
  function RedlineDesk() {
124
- // This tab's handle on its own draftsopaque, short, and remembered for the
125
- // next load, which is what `recover` produces to find the run again.
126
- const key = useRunKey();
127
- // Did THIS load start the run? A reload cannot have, and that is the only way
128
- // the page can tell "writing what you just briefed" from "picking up where
129
- // you left off" — the hook reports the run, not who asked for it.
130
- const [startedHere, setStartedHere] = useState(false);
131
- const { submit, run, pending, error, reset } = useWorkflowSubmit<typeof redline>(WORKFLOW, {
132
- // Neither half is useful alone: without the key there is nothing to find
133
- // the run by, and without `recover` the key is only ever written.
134
- key,
135
- recover: true,
136
- });
128
+ // The reload is covered by the hook's own keysee the module doc for why
129
+ // this desk wants the tab-scoped one it mints rather than a key of its own.
130
+ const { submit, run, pending, error, reset, startedHere } =
131
+ useWorkflowSubmit<typeof redline>(WORKFLOW);
137
132
 
138
133
  return (
139
134
  <main className="mx-auto flex max-w-2xl flex-col gap-8 p-8">
@@ -145,13 +140,7 @@ function RedlineDesk() {
145
140
  </p>
146
141
  </header>
147
142
 
148
- <Form
149
- onSubmit={(values) => {
150
- setStartedHere(true);
151
- return submit(toInput(values));
152
- }}
153
- error={error}
154
- >
143
+ <Form onSubmit={(values) => submit(toInput(values))} error={error}>
155
144
  {/* The scalars: brief, audience, rounds. Declared, not written. */}
156
145
  <WorkflowFields workflow={WORKFLOW} />
157
146
  {/* The array the schema declares and no generic control can render. */}
@@ -180,7 +169,6 @@ function RedlineDesk() {
180
169
  // The recovered run is dismissed as deliberately as one this load
181
170
  // started: `reset()` is not undone by a second lookup (the lookup
182
171
  // is a mount-time act), so Clear really does clear.
183
- setStartedHere(false);
184
172
  reset();
185
173
  }}
186
174
  />
@@ -196,15 +184,16 @@ function Rounds({ rounds }: { rounds: Redline["rounds"] }) {
196
184
  <ol className="flex flex-col gap-3">
197
185
  {rounds.map((entry) => (
198
186
  <li key={entry.round} className="flex flex-col gap-1 border-l pl-4">
199
- <p className="text-xs uppercase tracking-[1.2px] opacity-60">
200
- Round {entry.round} · {entry.critique.score}/10 ·{" "}
201
- {entry.critique.verdict === "ship" ? "ship it" : "revise"}
202
- </p>
203
- <ul className="flex list-disc flex-col gap-1 pl-5 text-sm">
204
- {entry.critique.notes.map((note) => (
205
- <li key={note}>{note}</li>
206
- ))}
207
- </ul>
187
+ <Facts
188
+ size="xs"
189
+ className="uppercase tracking-[1.2px]"
190
+ items={[
191
+ `Round ${entry.round}`,
192
+ `${entry.critique.score}/10`,
193
+ entry.critique.verdict === "ship" ? "ship it" : "revise",
194
+ ]}
195
+ />
196
+ <BulletList items={entry.critique.notes} size="sm" />
208
197
  </li>
209
198
  ))}
210
199
  </ol>
@@ -227,7 +216,7 @@ function RunPanel({ run, onClear }: { run: WorkflowRun<Redline>; onClear: () =>
227
216
  {/* The run's own narration — the complement of the status line, which is
228
217
  `running` for a run's whole life, so a one-round redline and a
229
218
  three-round one look identical while they happen. These lines come from
230
- the run itself (`report()` in `workflows/redline.ts`), and they REPLAY,
219
+ the run itself (`stepReport()` in `workflows/redline.ts`), and they REPLAY,
231
220
  so a reload mid-run catches up rather than starting from whatever
232
221
  arrives next. */}
233
222
  <WorkflowProgress runId={run.runId} />
@@ -236,20 +225,30 @@ function RunPanel({ run, onClear }: { run: WorkflowRun<Redline>; onClear: () =>
236
225
  without a cast. */}
237
226
  {run.status === "completed" && (
238
227
  <>
239
- <p className="text-xs opacity-60">
240
- {run.output.words} words · {run.output.roundsRun}{" "}
241
- {plural(run.output.roundsRun, "round")} ·{" "}
242
- {/* Which of the two stop conditions ended the loop is the one thing
243
- a reader cannot infer from the round count alone. */}
244
- {run.output.shipped ? "the critic stopped it" : "the round budget stopped it"}
245
- </p>
228
+ {/* Which of the two stop conditions ended the loop is the one thing a
229
+ reader cannot infer from the round count alone, so it is a fact of
230
+ its own rather than something left to the round count. */}
231
+ <Facts
232
+ size="xs"
233
+ items={[
234
+ `${run.output.words} words`,
235
+ `${run.output.roundsRun} ${plural(run.output.roundsRun, "round")}`,
236
+ run.output.shipped ? "the critic stopped it" : "the round budget stopped it",
237
+ ]}
238
+ />
246
239
  <Rounds rounds={run.output.rounds} />
247
240
  <article className="whitespace-pre-wrap text-sm leading-relaxed">
248
241
  {run.output.draft}
249
242
  </article>
250
243
  </>
251
244
  )}
252
- {run.status === "failed" && <p className="text-red-600">{run.error}</p>}
245
+ {/* `role="alert"`, the same contract `<Form>` gives the submit error: this
246
+ is the outcome the reader waited minutes for. */}
247
+ {run.status === "failed" && (
248
+ <p role="alert" className="text-red-600">
249
+ {run.error}
250
+ </p>
251
+ )}
253
252
  </section>
254
253
  );
255
254
  }
@@ -266,4 +265,4 @@ function RunPanel({ run, onClear }: { run: WorkflowRun<Redline>; onClear: () =>
266
265
  */
267
266
  const STATUS_LINE = { ...WORKFLOW_STATUS_LABELS, running: "Writing…" };
268
267
 
269
- page({ name: "Redline", component: RedlineDesk });
268
+ mountPage({ name: "Redline", component: RedlineDesk });
@@ -32,12 +32,12 @@
32
32
  * three functions read more tidily than one.
33
33
  */
34
34
 
35
- import type { WorkflowCtx } from "@alexkroman1/aai";
36
- import { report } from "@alexkroman1/aai/step";
35
+ import type { WorkflowContext } from "@alexkroman1/aai";
36
+ import { stepReport } from "@alexkroman1/aai/step";
37
37
  import {
38
38
  FatalError,
39
- stepGenerateClassified,
40
- stepGenerateJsonClassified,
39
+ stepGenerateJsonOrFail,
40
+ stepGenerateOrFail,
41
41
  } from "@alexkroman1/aai/step-errors";
42
42
  import { countWords } from "@alexkroman1/aai/utils";
43
43
  import { z } from "zod";
@@ -98,7 +98,7 @@ export interface Round {
98
98
  * the page's render model — and `WorkflowOutputOf<typeof redline>` in
99
99
  * `client.tsx` is that type, derived rather than restated.
100
100
  */
101
- export async function redlineFlow(input: RedlineInput, ctx: WorkflowCtx) {
101
+ export async function redlineFlow(input: RedlineInput, ctx: WorkflowContext) {
102
102
  // The three `maxAttempts` below were `maxRetries` properties on the functions
103
103
  // (3, 5, 3 — retries AFTER the first attempt, so 4, 6, 4 in all). The policy
104
104
  // is an argument to the CALL now, which is where it belongs: the same function
@@ -153,12 +153,12 @@ export async function writeDraft(input: RedlineInput): Promise<string> {
153
153
  );
154
154
  }
155
155
 
156
- await report(`Writing the first draft for ${input.audience}.`);
156
+ await stepReport(`Writing the first draft for ${input.audience}.`);
157
157
  // No empty-reply guard here or in `reviseDraft`, and that is not an omission:
158
158
  // `stepGenerate` already refuses an empty completion, as a RETRYABLE
159
159
  // `StepGenerateError` — which is the right answer, and one a hand-written
160
160
  // check would have to re-derive.
161
- const draft = await stepGenerateClassified(briefBlock(input), { system: WRITER_SYSTEM });
161
+ const draft = await stepGenerateOrFail(briefBlock(input), { system: WRITER_SYSTEM });
162
162
  return draft.trim();
163
163
  }
164
164
 
@@ -175,14 +175,14 @@ export async function critiqueDraft(
175
175
  input: RedlineInput,
176
176
  round: number,
177
177
  ): Promise<Critique> {
178
- await report(`Round ${round}: reading it back critically.`);
178
+ await stepReport(`Round ${round}: reading it back critically.`);
179
179
  // `stepGenerateJson` owns the fence, the parse, the non-object case and the
180
180
  // shape — and throws PLAINLY when any of them misses, unlike the fatal one
181
181
  // above: a model that answered with prose may well obey on the next attempt.
182
- const parsed = await stepGenerateJsonClassified(
183
- `${briefBlock(input)}\n\nThe submission:\n${draft}`,
184
- { schema: CritiqueReply, system: CRITIC_SYSTEM },
185
- );
182
+ const parsed = await stepGenerateJsonOrFail(`${briefBlock(input)}\n\nThe submission:\n${draft}`, {
183
+ schema: CritiqueReply,
184
+ system: CRITIC_SYSTEM,
185
+ });
186
186
 
187
187
  const critique: Critique = {
188
188
  verdict: parsed.verdict,
@@ -192,7 +192,7 @@ export async function critiqueDraft(
192
192
  score: clampScore(parsed.score),
193
193
  notes: parsed.notes.slice(0, MAX_NOTES),
194
194
  };
195
- await report(
195
+ await stepReport(
196
196
  critique.verdict === "ship"
197
197
  ? `Round ${round}: the critic would ship it (${critique.score}/10).`
198
198
  : `Round ${round}: ${critique.notes.length} note(s) to address (${critique.score}/10).`,
@@ -207,8 +207,8 @@ export async function reviseDraft(
207
207
  input: RedlineInput,
208
208
  round: number,
209
209
  ): Promise<string> {
210
- await report(`Round ${round}: revising.`);
211
- const revised = await stepGenerateClassified(
210
+ await stepReport(`Round ${round}: revising.`);
211
+ const revised = await stepGenerateOrFail(
212
212
  [
213
213
  briefBlock(input),
214
214
  `Your current draft:\n${draft}`,
@@ -241,8 +241,8 @@ export function clampScore(score: number): number {
241
241
  // There is no local `ask()` any more, and its absence is the point. The SDK
242
242
  // classifies the gateway's failure (`StepGenerateError.retryable`) and stops
243
243
  // there — whether a terminal failure should burn the step's remaining attempts
244
- // is the caller's call — so `stepGenerateClassified` and
245
- // `stepGenerateJsonClassified` (`@alexkroman1/aai/step-errors`) are that call
244
+ // is the caller's call — so `stepGenerateOrFail` and
245
+ // `stepGenerateJsonOrFail` (`@alexkroman1/aai/step-errors`) are that call
246
246
  // made one way: terminal stays terminal, and a rate limit becomes a
247
247
  // `RetryableError` carrying the delay the gateway itself named, which beats
248
248
  // `RetryableError`'s own one-second default. Three templates each wrapped the
@@ -29,7 +29,7 @@
29
29
  // endpointing, barge-in, whether two sentences merged into one turn.
30
30
 
31
31
  import agentDef from "virtual:aai/agent";
32
- import { stubGatewayRoute } from "@alexkroman1/aai/testing";
32
+ import { routeStepFetch, stubGatewayRoute } from "@alexkroman1/aai/testing";
33
33
  import { installStubStepFetch } from "@alexkroman1/aai/testing/vitest";
34
34
  import { type EvalToolCall, type EvalWorkflows, toolResultIn } from "@alexkroman1/aai-runtime/eval";
35
35
  import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
@@ -132,11 +132,11 @@ type ScriptedSteps = {
132
132
  function scriptSteps(options: { hold?: boolean } = {}): ScriptedSteps {
133
133
  const gate = Promise.withResolvers<void>();
134
134
  const model = stubGatewayRoute(MODEL_SCRIPT);
135
+ // Throwing on an unrecognised request is `routeStepFetch`'s default and is
136
+ // what this file wants: every step here is a model call.
137
+ const route = routeStepFetch([model.route]);
135
138
  const stub = installStubStepFetch(async (request) => {
136
- const answered = model.route(request);
137
- if (answered === undefined) {
138
- throw new Error(`unexpected step request in an eval: ${request.method} ${request.url}`);
139
- }
139
+ const answered = route(request);
140
140
  // `model.calls` has already recorded this one, so a length of 1 IS the first
141
141
  // answer — and holding after the route rather than before it keeps the reply
142
142
  // this returns the one the script owed that call.
@@ -16,7 +16,7 @@
16
16
  * exported async function, so its prompt handling, its parsing and its
17
17
  * `FatalError` guards are all testable without an engine.
18
18
  *
19
- * The BODY is driven here only through `createWorkflowCtx`, which records what
19
+ * The BODY is driven here only through `createWorkflowContext`, which records what
20
20
  * it asked for and replays nothing. That is a choice rather than a limit now:
21
21
  * `runWorkflow` from `@alexkroman1/aai-runtime/testing` will run this body on
22
22
  * the real engine, and `link-digest` is the template that shows it — three
@@ -31,13 +31,16 @@ import { FatalError, RetryableError } from "@alexkroman1/aai/step-errors";
31
31
  import {
32
32
  createRunSnapshot,
33
33
  createToolContext,
34
- createWorkflowCtx,
34
+ createWorkflowContext,
35
35
  parseSchemaInput,
36
36
  type StubGatewayCall,
37
37
  schemaInputIssues,
38
38
  toolRunner,
39
39
  } from "@alexkroman1/aai/testing";
40
- import { mockWorkflows, installStubGateway as stubGateway } from "@alexkroman1/aai/testing/vitest";
40
+ import {
41
+ installStubWorkflows,
42
+ installStubGateway as stubGateway,
43
+ } from "@alexkroman1/aai/testing/vitest";
41
44
  import { visitWebpage, webSearch } from "@alexkroman1/aai/tools";
42
45
  import type { WorkflowRunSnapshot } from "@alexkroman1/aai/workflow-api";
43
46
  import { runWorkflow } from "@alexkroman1/aai-runtime/testing";
@@ -89,7 +92,7 @@ const run = toolRunner(agentDef);
89
92
  *
90
93
  * Returned WITHOUT a cast, which is the property worth keeping: a cast would
91
94
  * also stop reporting the day `WorkflowClient` grows a method, and this stub is
92
- * how the template's tools reach the client at all. `mockWorkflows`
95
+ * how the template's tools reach the client at all. `installStubWorkflows`
93
96
  * (`@alexkroman1/aai/testing/vitest`) is what keeps that affordable — a `vi.fn`
94
97
  * per method over one `runs` list, filling in what this desk does not drive, so
95
98
  * the day the client does grow a method only the tests using it change.
@@ -101,12 +104,18 @@ function stubWorkflows(runs: WorkflowRunSnapshot[] = []): WorkflowClient {
101
104
  // Name only: `WorkflowSummary.description` is optional, so passing this
102
105
  // desk's through would mean handing `description: undefined` to a field that
103
106
  // does not accept it. Nothing here reads the description anyway.
104
- return mockWorkflows({ runs, names: ["research"] });
107
+ return installStubWorkflows({ runs, names: ["research"] });
105
108
  }
106
109
 
107
110
  describe("the agent declares its workflow", () => {
108
111
  test("under the name ctx.workflows.start resolves it by", () => {
109
- expect(Object.keys(agentDef.workflows ?? {})).toEqual(["research"]);
112
+ // `toContain` rather than an exact key list: a second workflow is an
113
+ // invited edit and must not redden a test the author did not write. The
114
+ // NAME is still pinned, deliberately — this key is a STRING to everything
115
+ // outside this file (the REST route, `ctx.workflows.get`, a schedule), so
116
+ // renaming it is a runtime 404 rather than a compile error, and nothing
117
+ // else says so.
118
+ expect(Object.keys(agentDef.workflows ?? {})).toContain("research");
110
119
  expect(agentDef.workflows?.research).toBe(research);
111
120
  });
112
121
 
@@ -439,7 +448,7 @@ describe("the steps that research", () => {
439
448
  // could not have said differently.
440
449
  // `planAngles`' result is what the fan-out iterates, so it is supplied
441
450
  // rather than run — the rest of the body needs no page and no model.
442
- const ctx = createWorkflowCtx({
451
+ const ctx = createWorkflowContext({
443
452
  runSteps: false,
444
453
  // Every step the body READS needs a value: with `runSteps: false` nothing
445
454
  // runs, so this is the skeleton of a run rather than a run. That is the
@@ -550,7 +559,7 @@ describe("the steps that research", () => {
550
559
  /**
551
560
  * `researchFlow` itself, on the real replay engine.
552
561
  *
553
- * The block above drives this body through `createWorkflowCtx`, which records
562
+ * The block above drives this body through `createWorkflowContext`, which records
554
563
  * what it ASKED for and replays nothing — right for the retry policy and the
555
564
  * step order, and silent about the desk's actual promise: **answer the caller
556
565
  * now, finish the work later**. `runWorkflow`