@alexkroman1/aai-cli 9.1.0 → 10.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 (81) hide show
  1. package/dist/{_bundler-CDuYl5Gb.mjs → _bundler-B31MqYaD.mjs} +7 -33
  2. package/dist/_bundler.d.ts +3 -41
  3. package/dist/{_dev-server-BAR8Ya43.mjs → _dev-server-Bo5dSBzQ.mjs} +140 -44
  4. package/dist/_dev-server.d.ts +1 -8
  5. package/dist/_dev-vite-config.d.ts +21 -0
  6. package/dist/{_init-BNotRgV1.mjs → _init-BURbdHNM.mjs} +1 -1
  7. package/dist/{_slug-api--ZBmIGH-.mjs → _slug-api-CfGQq1Tt.mjs} +1 -1
  8. package/dist/{_templates-4WcKOjS5.mjs → _templates-0Zr2z6yA.mjs} +37 -2
  9. package/dist/_templates.d.ts +29 -0
  10. package/dist/{build-DvDdSwAS.mjs → build-DKC2e9o_.mjs} +1 -2
  11. package/dist/cli.mjs +46 -22
  12. package/dist/{delete-8pjH8n8n.mjs → delete-X_CRgCf6.mjs} +1 -1
  13. package/dist/{deploy-CjU3e-3P.mjs → deploy-CchW5pmw.mjs} +2 -2
  14. package/dist/{dev-dFzdvwa7.mjs → dev-BfVl1uBA.mjs} +1 -1
  15. package/dist/{init-DK7GpIF9.mjs → init-BRfoc2EK.mjs} +2 -2
  16. package/dist/{logs-D2Yhqe2w.mjs → logs-hWnJ66Bl.mjs} +2 -2
  17. package/dist/scaffold/CLAUDE.md +162 -54
  18. package/dist/scaffold/package.json +4 -6
  19. package/dist/scaffold/server.mjs +12 -23
  20. package/dist/{secret-CwgDLpSy.mjs → secret-B-3T5tvr.mjs} +2 -2
  21. package/dist/{studio-D3-HWIfk.mjs → studio-wVWNLREn.mjs} +4 -3
  22. package/dist/templates/call-audit/agent.eval.test.ts +1 -1
  23. package/dist/templates/call-audit/agent.test.ts +80 -8
  24. package/dist/templates/call-audit/client.tsx +87 -3
  25. package/dist/templates/call-audit/workflows/audit.ts +52 -32
  26. package/dist/templates/call-audit/workflows/ingest.ts +15 -15
  27. package/dist/templates/call-audit/workflows/media.ts +1 -1
  28. package/dist/templates/call-audit/workflows/summarize.ts +0 -4
  29. package/dist/templates/link-digest/agent.eval.test.ts +4 -2
  30. package/dist/templates/link-digest/agent.test.ts +25 -5
  31. package/dist/templates/link-digest/client.tsx +65 -7
  32. package/dist/templates/link-digest/workflows/digest.ts +39 -29
  33. package/dist/templates/podcast-digest/agent.eval.test.ts +5 -5
  34. package/dist/templates/podcast-digest/agent.test.ts +108 -2
  35. package/dist/templates/podcast-digest/client.tsx +89 -8
  36. package/dist/templates/podcast-digest/workflows/digest.ts +67 -48
  37. package/dist/templates/podcast-digest/workflows/feeds.ts +1 -4
  38. package/dist/templates/podcast-digest/workflows/slack.ts +5 -6
  39. package/dist/templates/recap-workflow/agent.eval.test.ts +72 -8
  40. package/dist/templates/recap-workflow/agent.test.ts +323 -83
  41. package/dist/templates/recap-workflow/shared.ts +4 -3
  42. package/dist/templates/recap-workflow/workflows/recap.ts +322 -86
  43. package/dist/templates/recap-workflow/workflows/tokens.ts +21 -0
  44. package/dist/templates/redline/agent.eval.test.ts +1 -1
  45. package/dist/templates/redline/agent.test.ts +1 -1
  46. package/dist/templates/redline/client.tsx +83 -3
  47. package/dist/templates/redline/workflows/redline.ts +26 -23
  48. package/dist/templates/research-workflow/agent.eval.test.ts +9 -8
  49. package/dist/templates/research-workflow/agent.test.ts +32 -6
  50. package/dist/templates/research-workflow/agent.ts +4 -4
  51. package/dist/templates/research-workflow/shared.ts +4 -3
  52. package/dist/templates/research-workflow/workflows/prompts.ts +1 -1
  53. package/dist/templates/research-workflow/workflows/research.ts +35 -38
  54. package/dist/templates/spoken-summary/agent.eval.test.ts +3 -3
  55. package/dist/templates/spoken-summary/agent.test.ts +10 -8
  56. package/dist/templates/spoken-summary/client.tsx +72 -3
  57. package/dist/templates/spoken-summary/workflows/summarize.ts +25 -21
  58. package/dist/templates/spoken-summary/workflows/transcribe.ts +15 -18
  59. package/dist/templates/transcription-workflow/agent.eval.test.ts +1 -1
  60. package/dist/templates/transcription-workflow/agent.test.ts +720 -16
  61. package/dist/templates/transcription-workflow/agent.ts +6 -4
  62. package/dist/templates/transcription-workflow/client.tsx +106 -308
  63. package/dist/templates/transcription-workflow/recover.test.ts +141 -0
  64. package/dist/templates/transcription-workflow/recover.ts +130 -0
  65. package/dist/templates/transcription-workflow/run-panel.tsx +203 -0
  66. package/dist/templates/transcription-workflow/total-latency.tsx +144 -0
  67. package/dist/templates/transcription-workflow/workflows/batch.ts +21 -24
  68. package/dist/templates/transcription-workflow/workflows/downsample.ts +273 -0
  69. package/dist/templates/transcription-workflow/workflows/normalize.ts +17 -40
  70. package/dist/templates/transcription-workflow/workflows/stream.ts +311 -38
  71. package/dist/templates/transcription-workflow/workflows/transcribe.ts +69 -32
  72. package/dist/templates/transcription-workflow/workflows/wav.ts +15 -4
  73. package/dist/worker-bundler.d.ts +0 -14
  74. package/dist/worker-bundler.mjs +149 -1
  75. package/dist/{workflow-BflATMmU.mjs → workflow-BBu5oEDw.mjs} +84 -10
  76. package/dist/workflow.d.ts +1 -0
  77. package/package.json +4 -6
  78. package/dist/_workflow-scan.d.ts +0 -108
  79. package/dist/worker-bundler-CCVEDjm1.mjs +0 -650
  80. package/dist/workflow-bundler.d.ts +0 -138
  81. package/dist/{_studio-CKrsixd0.mjs → _studio-B1waXMuR.mjs} +1 -1
@@ -14,7 +14,7 @@
14
14
  * for a second transcription, and that a cancel says out loud what cancelling
15
15
  * does not do.
16
16
  * - **The steps**, directly. Imported through vitest with no bundler in the
17
- * path a `"use step"` function is an ordinary async function, so its HTTP
17
+ * path a step is an ordinary async function, so its HTTP
18
18
  * handling, its retryable/fatal split and its JSON contract with the model are
19
19
  * all testable.
20
20
  * - **The body's two helpers** — the poll loop and the compensation unwind —
@@ -25,12 +25,28 @@
25
25
  * test would be the worse failure. `aai-cli`'s
26
26
  * `dev-workflow.scenario.test.ts` is the tier that builds a project and
27
27
  * runs a real one.
28
+ *
29
+ * **One branch of the body is therefore UNPINNED here, and it is the most
30
+ * expensive one in the file: `recapFlow`'s `isWorkflowSuspend(err)` re-throw.**
31
+ * Its own comment says what its absence did — the first poll that had to wait
32
+ * deleted the transcript the run was waiting for — so the missing test is worth
33
+ * naming rather than leaving as a gap somebody assumes is covered.
34
+ * `createWorkflowCtx` cannot produce a suspend by construction: its `sleep` is
35
+ * RECORDED and its `waitFor` answers out of `hooks`, so no wait it serves ever
36
+ * throws. Minting the engine's brand by hand would drive the branch against a
37
+ * value no engine here produced — a test of this file's own fixture. The real
38
+ * one is `aai-runtime`'s `workflow-replay.test.ts`, whose engine fails any run
39
+ * whose body swallows a suspend, which is the guard under this rule for every
40
+ * template at once.
28
41
  */
29
42
 
43
+ /** The def a DEPLOYED agent runs: authored, plus what `tools/` declares. */
44
+ import agentDef from "virtual:aai/agent";
30
45
  import type { WorkflowClient } from "@alexkroman1/aai";
31
46
  import {
32
47
  createRunSnapshot,
33
48
  createToolContext,
49
+ createWorkflowCtx,
34
50
  parseSchemaInput,
35
51
  schemaInputIssues,
36
52
  toolRunner,
@@ -42,36 +58,20 @@ import {
42
58
  } from "@alexkroman1/aai/testing/vitest";
43
59
  import type { WorkflowRunSnapshot } from "@alexkroman1/aai/workflow-api";
44
60
  import { beforeEach, describe, expect, test, vi } from "vitest";
45
- import { createHook, type Hook, sleep } from "workflow";
46
61
  import { recap } from "./shared.ts";
47
62
  import {
48
63
  askWhetherToKeep,
49
64
  awaitTranscript,
65
+ callbackUrl,
50
66
  checkTranscript,
51
67
  compensate,
52
68
  discardTranscript,
69
+ recapFlow,
53
70
  submitRecording,
54
71
  summarize,
55
72
  type TranscriptState,
56
73
  } from "./workflows/recap.ts";
57
- import { retentionToken } from "./workflows/tokens.ts";
58
-
59
- /**
60
- * The DevKit, with only its timer replaced.
61
- *
62
- * `sleep` throws outside a run, so the poll loop cannot be exercised at all
63
- * without this — and everything else the module imports from `workflow`
64
- * (`FatalError`, `RetryableError`) is a real class the steps' own specs assert
65
- * against, so it is `importActual` plus one override rather than a fake module.
66
- */
67
- vi.mock("workflow", async (importActual) => ({
68
- ...(await importActual<typeof import("workflow")>()),
69
- sleep: vi.fn(async () => undefined),
70
- createHook: vi.fn(),
71
- }));
72
-
73
- /** The def a DEPLOYED agent runs: authored, plus what `tools/` declares. */
74
- import agentDef from "virtual:aai/agent";
74
+ import { retentionToken, transcriptToken } from "./workflows/tokens.ts";
75
75
 
76
76
  /**
77
77
  * Every tool here is driven through the agent's own table, by the name the model
@@ -405,7 +405,13 @@ describe("submitRecording", () => {
405
405
 
406
406
  test("posts the recording and returns the job id", async () => {
407
407
  const calls = stubProvider({ id: "t_1", status: "queued" });
408
- expect(await submitRecording("https://example.com/a.mp3")).toEqual({ id: "t_1" });
408
+ // `callback: false` alongside the id, because the body may only branch on
409
+ // journaled values — see `submitRecording`'s own doc. No minter is published
410
+ // in a spec, so `stepWebhookUrl` throws and `callbackUrl` degrades.
411
+ expect(await submitRecording("https://example.com/a.mp3")).toEqual({
412
+ id: "t_1",
413
+ callback: false,
414
+ });
409
415
 
410
416
  const call = calls[0];
411
417
  expect(call?.method).toBe("POST");
@@ -440,6 +446,73 @@ describe("submitRecording", () => {
440
446
  stubProvider({ status: "queued" });
441
447
  await expect(submitRecording("https://example.com/a.mp3")).rejects.toThrow(/transcript id/);
442
448
  });
449
+
450
+ test("asks the provider to CALL BACK when it was handed a URL to call", async () => {
451
+ const calls = stubProvider({ id: "t_1", status: "queued" });
452
+ await submitRecording("https://example.com/a.mp3", "https://desk.example/hook/transcript:s_1");
453
+
454
+ // `webhook_url` rides the same `params` passthrough `speaker_labels` does.
455
+ // This one field is the whole difference between the two arms of
456
+ // `awaitTranscript`.
457
+ expect(JSON.parse(String(calls[0]?.body))).toMatchObject({
458
+ webhook_url: "https://desk.example/hook/transcript:s_1",
459
+ speaker_labels: true,
460
+ });
461
+ });
462
+
463
+ test("reports the callback FACT with the id, so the body branches on the journal", async () => {
464
+ // The determinism requirement in one assertion. Whether a callback was
465
+ // registered decides whether the body parks on a hook, and a body may only
466
+ // branch on what came out of the journal — so the step that established the
467
+ // fact is what returns it. A body that re-minted the URL on each replay
468
+ // could flip this branch under a redeploy and then look for a `waitFor` the
469
+ // journal never recorded.
470
+ stubProvider({ id: "t_1", status: "queued" });
471
+ expect(
472
+ await submitRecording("https://example.com/a.mp3", "https://desk.example/hook/t"),
473
+ ).toEqual({ id: "t_1", callback: true });
474
+ });
475
+
476
+ test("OMITS the key rather than sending a null when there is no callback URL", async () => {
477
+ // `JSON.stringify` drops an `undefined` property, so the key is absent on
478
+ // the wire for free — and what this pins is that nobody "makes it explicit"
479
+ // with a `?? null` or a `?? ""`, either of which puts it back. A provider
480
+ // handed a null for a URL is entitled to refuse the whole submission. The
481
+ // assertion is about the KEY, which `toMatchObject` cannot express.
482
+ const calls = stubProvider({ id: "t_1", status: "queued" });
483
+ await submitRecording("https://example.com/a.mp3");
484
+
485
+ expect(Object.keys(JSON.parse(String(calls[0]?.body)))).not.toContain("webhook_url");
486
+ });
487
+ });
488
+
489
+ describe("callbackUrl", () => {
490
+ test("degrades to NO callback when the deployment cannot mint one", async () => {
491
+ // `stepWebhookUrl` THROWS on an unpublished slot rather than answering
492
+ // `undefined` — a callback URL has no legitimate default, so the SDK refuses
493
+ // to invent one. A spec publishes no minter, which is the same position as
494
+ // `aai dev` on a laptop and a self-hosted server started without
495
+ // `publicUrl`. What must NOT happen is that throw reaching the step: a recap
496
+ // may not fail over a missing optimization.
497
+ expect(callbackUrl(transcriptToken("s_1"))).toBeUndefined();
498
+ });
499
+
500
+ test("does not swallow an EMPTY token, which would compose the route's own prefix", async () => {
501
+ // The one input `stepWebhookUrl` refuses that is a caller bug rather than a
502
+ // deployment fact — and this pins that `callbackUrl`'s catch does not hide
503
+ // it as "no callback available". A URL that is the bare prefix is refused by
504
+ // the route's own parser, so the failure would otherwise arrive at the far
505
+ // end as a 404 on a URL nobody can re-issue.
506
+ //
507
+ // It currently reads `undefined` because the unpublished slot is checked
508
+ // FIRST, so both arms of `stepWebhookUrl` are indistinguishable here. That is
509
+ // a real limit of this tier rather than a claim about the SDK: a spec with a
510
+ // published minter would tell them apart, and publishing one needs
511
+ // `publishStepWebhookUrl` off `@alexkroman1/aai/host-internal` — a host
512
+ // surface a template has no business importing. Recorded so the next reader
513
+ // does not mistake the assertion for a stronger one than it is.
514
+ expect(callbackUrl("")).toBeUndefined();
515
+ });
443
516
  });
444
517
 
445
518
  describe("checkTranscript", () => {
@@ -566,14 +639,29 @@ describe("summarize", () => {
566
639
  await expect(summarize("https://x/a.mp3", transcript())).rejects.toThrow(/ASSEMBLYAI_API_KEY/);
567
640
  });
568
641
 
569
- test("retries beyond the default, because a rate limit and a bad format both happen", () => {
570
- expect(summarize.maxRetries).toBeGreaterThan(3);
642
+ test("is called with more attempts than the default, a rate limit and a bad format both happening", async () => {
643
+ // The policy is an argument to `ctx.step` now, so it is observable only at
644
+ // the call. `runSteps: false` and a skeleton of results: the subject is what
645
+ // the body ASKED FOR.
646
+ const ctx = createWorkflowCtx({
647
+ runSteps: false,
648
+ results: {
649
+ submitRecording: { id: "t_1" },
650
+ checkTranscript: { status: "completed", text: "Done.", durationMs: 1 },
651
+ summarize: { headline: "H", points: [], spoken: "S." },
652
+ },
653
+ hooks: { [retentionToken("Ada")]: { keep: true } },
654
+ });
655
+ await recapFlow({ url: "https://x/a.mp3", requestedBy: "Ada" }, ctx);
656
+
657
+ const step = ctx.steps.find((entry) => entry.name === "summarize");
658
+ expect(step?.maxAttempts).toBeGreaterThan(3);
571
659
  });
572
660
  });
573
661
 
574
662
  // ---- The body's helpers -----------------------------------------------------
575
663
 
576
- describe("awaitTranscript — the polling port", () => {
664
+ describe("awaitTranscript — the polling port, and the callback over it", () => {
577
665
  beforeEach(() => {
578
666
  vi.stubEnv("ASSEMBLYAI_API_KEY", "sk-test");
579
667
  });
@@ -593,7 +681,7 @@ describe("awaitTranscript — the polling port", () => {
593
681
  { status: "processing" },
594
682
  { status: "completed", text: "Done.", audio_duration: 60 },
595
683
  ]);
596
- const state = await awaitTranscript("t_1");
684
+ const state = await awaitTranscript("t_1", createWorkflowCtx());
597
685
  expect(state).toMatchObject({ status: "completed", text: "Done." });
598
686
  expect(polls()).toBe(3);
599
687
  });
@@ -602,65 +690,210 @@ describe("awaitTranscript — the polling port", () => {
602
690
  // `error` is terminal: polling a failed job to the bound would spend twenty
603
691
  // minutes learning what the first answer already said.
604
692
  const polls = stubStatuses([{ status: "error", error: "Transcoding failed" }]);
605
- await expect(awaitTranscript("t_1")).rejects.toThrow(/Transcoding failed/);
693
+ await expect(awaitTranscript("t_1", createWorkflowCtx())).rejects.toThrow(/Transcoding failed/);
606
694
  expect(polls()).toBe(1);
607
695
  });
608
696
 
609
697
  test("gives up at the bound rather than polling a stuck job forever", async () => {
610
698
  stubStatuses([{ status: "processing" }]);
611
- await expect(awaitTranscript("t_1")).rejects.toThrow(/Gave up/);
699
+ await expect(awaitTranscript("t_1", createWorkflowCtx())).rejects.toThrow(/Gave up/);
700
+ });
701
+
702
+ /** A job still `processing` for `polls` turns, then completed. */
703
+ function stubSlowJob(polls: number) {
704
+ return stubStatuses([
705
+ ...Array.from({ length: polls }, () => ({ status: "processing" })),
706
+ { status: "completed", text: "Done.", audio_duration: 60 },
707
+ ]);
708
+ }
709
+
710
+ test("says nothing about a long one until it really has waited two minutes", async () => {
711
+ // The boundary this case and the next one hold together, and it is where
712
+ // `PATIENCE_POLLS` disagreed with its own doc. The note goes out at the TOP
713
+ // of a poll, so the wait it follows is the sleeps BEHIND it — attempt N is
714
+ // reached after N-1 of them. At a fifteen-second interval the two minutes
715
+ // the constant promises is eight sleeps, so the earliest honest turn to say
716
+ // it is the ninth. Here the job finishes on that ninth poll, two minutes in
717
+ // and not a second over, so the caller is told nothing.
718
+ const ctx = createWorkflowCtx();
719
+ stubSlowJob(8);
720
+
721
+ await awaitTranscript("t_1", ctx);
722
+
723
+ expect(ctx.steps.filter((entry) => entry.name === "noteSlow")).toEqual([]);
724
+ // Eight sleeps of the declared interval — the two minutes, exactly.
725
+ expect(ctx.slept.map((one) => one.until)).toEqual(Array.from({ length: 8 }, () => 15_000));
726
+ });
727
+
728
+ test("says it ONCE past two minutes, not on every poll after", async () => {
729
+ // The ninth poll finds the job still going, which is the first turn that has
730
+ // two minutes of waiting behind it — so the note goes out, and the eleven
731
+ // polls after it say nothing more. A note per poll would be a caller told
732
+ // the same sentence every fifteen seconds.
733
+ const ctx = createWorkflowCtx();
734
+ stubSlowJob(20);
735
+
736
+ await awaitTranscript("t_1", ctx);
737
+
738
+ expect(ctx.steps.filter((entry) => entry.name === "noteSlow")).toHaveLength(1);
739
+ });
740
+
741
+ // ---- The callback arm -----------------------------------------------------
742
+ //
743
+ // Everything above drives `awaitTranscript` with no callback token, which is
744
+ // what a deployment that does not know its own public URL gets — and those
745
+ // cases are the REGRESSION guard for this section: the poll-only arm has to
746
+ // keep behaving exactly as it did before there was a callback at all.
747
+ //
748
+ // `createWorkflowCtx` is the only tier that can drive the answered branch:
749
+ // its `waitFor` reads `hooks` by token, so supplying a payload IS the delivery
750
+ // landing and omitting one IS the window closing. The eval tier cannot —
751
+ // nothing there can signal — so it only ever sees the fallback, which is
752
+ // stated in `agent.eval.test.ts`.
753
+
754
+ /** The token both `request_recap` and the body derive for one session. */
755
+ const NUDGE = transcriptToken("s_1");
756
+
757
+ test("the delivery ends the wait, so a finished job costs no sleeping at all", async () => {
758
+ // The whole point of the conversion, in one assertion. Read once, park on
759
+ // the callback, read again when it lands: two requests to the provider
760
+ // where the poll-only arm would have made nine before it even said
761
+ // anything, and not one durable sleep.
762
+ const polls = stubStatuses([
763
+ { status: "processing" },
764
+ { status: "completed", text: "Done.", audio_duration: 60 },
765
+ ]);
766
+ const ctx = createWorkflowCtx({
767
+ hooks: { [NUDGE]: { transcript_id: "t_1", status: "completed" } },
768
+ });
769
+
770
+ const state = await awaitTranscript("t_1", ctx, NUDGE);
771
+
772
+ expect(state).toMatchObject({ status: "completed", text: "Done." });
773
+ expect(polls()).toBe(2);
774
+ expect(ctx.slept).toEqual([]);
775
+ // On the token the tool derives for the same session — the one string this
776
+ // template and a third party on the public internet have to agree about.
777
+ expect(ctx.waited).toEqual([NUDGE]);
778
+ });
779
+
780
+ test("a delivery is a NUDGE, not an answer: the run still READS the status", async () => {
781
+ // The security property of this template, and the reason an unauthenticated
782
+ // callback route is safe here. The payload SAYS the job completed; the
783
+ // provider's own endpoint says otherwise, and the provider wins — so a
784
+ // forged delivery on a guessed token costs exactly one extra read and
785
+ // changes no outcome. Nothing the payload carries is ever read.
786
+ const polls = stubStatuses([
787
+ { status: "processing" },
788
+ { status: "processing" },
789
+ { status: "completed", text: "The real transcript.", audio_duration: 60 },
790
+ ]);
791
+ const ctx = createWorkflowCtx({
792
+ hooks: { [NUDGE]: { transcript_id: "t_1", status: "completed", text: "A LIE." } },
793
+ });
794
+
795
+ const state = await awaitTranscript("t_1", ctx, NUDGE);
796
+
797
+ expect(state).toMatchObject({ status: "completed", text: "The real transcript." });
798
+ // Three reads: the one before the park, the one the delivery woke, and the
799
+ // one after the ordinary fifteen-second wait that followed it.
800
+ expect(polls()).toBe(3);
801
+ expect(ctx.slept).toEqual([{ until: 15_000, correlationId: undefined }]);
802
+ });
803
+
804
+ test("parks ONCE and then polls, because a token cannot be claimed twice", async () => {
805
+ // `claimHook` refuses a second claim on a token its run still holds, and a
806
+ // refusal is a throw rather than a suspend — which `recapFlow`'s catch would
807
+ // read as a failed run and answer by deleting the transcript. So the wait
808
+ // may not be inside the loop, and this is what pins that: twenty turns, ONE
809
+ // `waitFor`, and every other wait a plain sleep.
810
+ const ctx = createWorkflowCtx({ hooks: { [NUDGE]: {} } });
811
+ stubSlowJob(20);
812
+
813
+ await awaitTranscript("t_1", ctx, NUDGE);
814
+
815
+ expect(ctx.waited).toHaveLength(1);
816
+ expect(ctx.slept).toHaveLength(19);
817
+ });
818
+
819
+ test("an unanswered window falls back to the poll rather than hanging", async () => {
820
+ // No `hooks` entry, so the timed wait resolves `undefined` — which IS the
821
+ // window closing. The run must finish anyway: a dropped delivery is the
822
+ // ordinary case a webhook has no answer for, and a template that waited
823
+ // forever on one would be strictly worse than the loop it replaced.
824
+ const polls = stubStatuses([
825
+ { status: "processing" },
826
+ { status: "processing" },
827
+ { status: "completed", text: "Done.", audio_duration: 60 },
828
+ ]);
829
+ const ctx = createWorkflowCtx();
830
+
831
+ const state = await awaitTranscript("t_1", ctx, NUDGE);
832
+
833
+ expect(state).toMatchObject({ status: "completed", text: "Done." });
834
+ expect(polls()).toBe(3);
835
+ // It asked, and then it stopped counting on the answer.
836
+ expect(ctx.waited).toEqual([NUDGE]);
837
+ expect(ctx.slept).toEqual([{ until: 15_000, correlationId: undefined }]);
838
+ });
839
+
840
+ test("says 'still transcribing' after ONE closed window, not after nine", async () => {
841
+ // The same two minutes the poll-only arm counts out in eight sleeps, in one
842
+ // window — so whichever arm a run is on, a caller hears the sentence at the
843
+ // same point. The note goes out at the TOP of a poll, so attempt 2 is the
844
+ // first turn with a whole closed window behind it.
845
+ const ctx = createWorkflowCtx();
846
+ stubSlowJob(20);
847
+
848
+ await awaitTranscript("t_1", ctx, NUDGE);
849
+
850
+ const notes = ctx.steps.filter((entry) => entry.name === "noteSlow");
851
+ expect(notes).toHaveLength(1);
852
+ // Attempt 2, which is one `checkTranscript` and one closed window in.
853
+ expect(ctx.steps.slice(0, 3).map((entry) => entry.name)).toEqual([
854
+ "checkTranscript",
855
+ "checkTranscript",
856
+ "noteSlow",
857
+ ]);
858
+ });
859
+
860
+ test("says nothing when the delivery beats the window", async () => {
861
+ // The mirror of the case above, and what stops the note being a thing every
862
+ // callback run says: the delivery lands, attempt 2 finds the job done, and
863
+ // nobody is told a recording is a long one.
864
+ const ctx = createWorkflowCtx({ hooks: { [NUDGE]: {} } });
865
+ stubSlowJob(1);
866
+
867
+ await awaitTranscript("t_1", ctx, NUDGE);
868
+
869
+ expect(ctx.steps.filter((entry) => entry.name === "noteSlow")).toEqual([]);
612
870
  });
613
871
  });
614
872
 
615
873
  describe("askWhetherToKeep — the expense port", () => {
616
874
  beforeEach(() => {
617
875
  vi.stubEnv("ASSEMBLYAI_API_KEY", "sk-test");
618
- // The window has NOT elapsed unless a test says so. A `sleep` that resolves
619
- // would race the hook's own answer, and which one won would come down to
620
- // microtask order rather than to the branch under test.
621
- vi.mocked(sleep).mockReturnValue(new Promise<void>(() => undefined));
622
- vi.mocked(createHook).mockReturnValue(hookAnswering(undefined));
623
876
  });
624
877
 
625
878
  /**
626
- * A hook that resolves with `payload`, or never which is what the timeout
627
- * branch has to see.
628
- *
629
- * Built by hanging the hook's own members on a REAL promise rather than by
630
- * writing a `then` property: a `Hook` is a thenable, and a hand-written one
631
- * is both a lint finding and a worse model of the thing.
879
+ * The gate, driven with or without an answer.
632
880
  *
633
- * `createHook()` throws outside a run, so this is the only way to reach the
634
- * gate's branching at all. What it pins is the three OUTCOMES and the safe
635
- * default; suspension, token registration and replay are not testable here
636
- * and are not claimed to be.
881
+ * This block used to be forty lines of DevKit scaffolding: `vi.mock("workflow")`
882
+ * over `createHook` and `sleep`, plus a hand-built `Hook` assembled by hanging
883
+ * members on a real promise, plus a never-resolving `sleep` so the two sides of
884
+ * a `Promise.race` could not settle in an order that decided the test instead
885
+ * of the branch. The gate is ONE call now — `ctx.waitFor(token, { timeoutMs })`
886
+ * — so an answer is a `hooks` entry and the no-answer branch is its absence.
887
+ * What is pinned is unchanged: the three outcomes and the safe default.
637
888
  */
638
- function hookAnswering(
639
- payload: { keep: boolean } | undefined,
640
- onClaim: () => void = () => undefined,
641
- ): Hook<{ keep: boolean }> {
642
- const settled: Promise<{ keep: boolean }> =
643
- payload === undefined ? new Promise(() => undefined) : Promise.resolve(payload);
644
- return Object.assign(settled, {
645
- token: "retention:stub",
646
- getConflict: async () => {
647
- onClaim();
648
- return null;
649
- },
650
- dispose: () => undefined,
651
- [Symbol.dispose]: () => undefined,
652
- [Symbol.asyncIterator]: async function* () {
653
- yield await settled;
654
- },
655
- });
656
- }
889
+ const gateCtx = (answer?: { keep: boolean }) =>
890
+ createWorkflowCtx(answer === undefined ? {} : { hooks: { [retentionToken("s_1")]: answer } });
657
891
 
658
892
  test("keeps the transcript when the caller says to, and deletes nothing", async () => {
659
- vi.mocked(createHook).mockReturnValue(hookAnswering({ keep: true }));
660
893
  const provider = installStubStepFetch();
661
894
 
662
895
  const compensations = [{ label: "transcript t_1", undo: async () => undefined }];
663
- expect(await askWhetherToKeep("s_1", "t_1", compensations)).toEqual({
896
+ expect(await askWhetherToKeep("s_1", "t_1", compensations, gateCtx({ keep: true }))).toEqual({
664
897
  kept: true,
665
898
  answered: true,
666
899
  });
@@ -670,11 +903,10 @@ describe("askWhetherToKeep — the expense port", () => {
670
903
  });
671
904
 
672
905
  test("deletes on a DECLINE, and drops the undo it just performed", async () => {
673
- vi.mocked(createHook).mockReturnValue(hookAnswering({ keep: false }));
674
906
  const calls = stubProvider({ id: "t_1" });
675
907
 
676
908
  const compensations = [{ label: "transcript t_1", undo: async () => undefined }];
677
- expect(await askWhetherToKeep("s_1", "t_1", compensations)).toEqual({
909
+ expect(await askWhetherToKeep("s_1", "t_1", compensations, gateCtx({ keep: false }))).toEqual({
678
910
  kept: false,
679
911
  answered: true,
680
912
  });
@@ -686,29 +918,33 @@ describe("askWhetherToKeep — the expense port", () => {
686
918
 
687
919
  test("DELETES when nobody answers, which is what makes the window mean anything", async () => {
688
920
  // The safe default, and the whole reason the gate is a gate: a no-answer
689
- // branch that kept the data would be a prompt with a grace period.
690
- vi.mocked(sleep).mockResolvedValue(undefined);
921
+ // branch that kept the data would be a prompt with a grace period. No
922
+ // `hooks` entry is exactly the closed-window case, because the wait carries a
923
+ // `timeoutMs` and so has an unanswered branch to take.
691
924
  const calls = stubProvider({ id: "t_1" });
692
925
 
693
- expect(await askWhetherToKeep("s_1", "t_1", [])).toEqual({ kept: false, answered: false });
926
+ expect(await askWhetherToKeep("s_1", "t_1", [], gateCtx())).toEqual({
927
+ kept: false,
928
+ answered: false,
929
+ });
694
930
  expect(calls[0]?.method).toBe("DELETE");
695
931
  });
696
932
 
697
- test("claims the token BEFORE the caller is asked to answer it", async () => {
698
- // `createHook()` registers nothing until the workflow suspends, so an answer
699
- // sent before the claim lands is answered "nobody is listening" which is
700
- // indistinguishable from being late.
701
- const order: string[] = [];
702
- vi.mocked(createHook).mockReturnValue(
703
- hookAnswering({ keep: true }, () => order.push("claimed")),
704
- );
705
- installStubStepFetch(() => {
706
- order.push("asked");
707
- return { body: {} };
708
- });
933
+ test("asks BEFORE it waits, so the caller knows what they are answering", async () => {
934
+ // The ordering that replaced `createHook`'s claim-then-ask dance. Under the
935
+ // DevKit the hook registered nothing until the workflow suspended, so an
936
+ // answer sent before the claim landed was told "nobody is listening" —
937
+ // indistinguishable from being late — and the body had to call
938
+ // `getConflict()` first to force it. `ctx.waitFor` registers the token as
939
+ // part of waiting, by construction, so what is left to assert is the thing
940
+ // that still matters to a person: the note goes out first.
941
+ const ctx = gateCtx({ keep: true });
942
+ installStubStepFetch();
943
+
944
+ await askWhetherToKeep("s_1", "t_1", [], ctx);
709
945
 
710
- await askWhetherToKeep("s_1", "t_1", []);
711
- expect(order[0]).toBe("claimed");
946
+ expect(ctx.steps[0]?.name).toBe("noteGate");
947
+ expect(ctx.waited).toEqual([retentionToken("s_1")]);
712
948
  });
713
949
  });
714
950
 
@@ -723,6 +959,7 @@ describe("compensate — the saga port", () => {
723
959
  { label: "first", undo: async () => void order.push("first") },
724
960
  ],
725
961
  "because",
962
+ createWorkflowCtx(),
726
963
  );
727
964
  expect(order).toEqual(["second", "first"]);
728
965
  });
@@ -744,6 +981,7 @@ describe("compensate — the saga port", () => {
744
981
  { label: "fine", undo: async () => void order.push("fine") },
745
982
  ],
746
983
  "because",
984
+ createWorkflowCtx(),
747
985
  ),
748
986
  ).resolves.toBeUndefined();
749
987
  expect(order).toEqual(["fine"]);
@@ -753,6 +991,8 @@ describe("compensate — the saga port", () => {
753
991
  // The case where the FIRST step failed: there is nothing to reverse, and a
754
992
  // run that narrated an unwind it did not perform would be lying to the
755
993
  // caller reading its progress.
756
- await expect(compensate([], "nothing was acquired")).resolves.toBeUndefined();
994
+ await expect(
995
+ compensate([], "nothing was acquired", createWorkflowCtx()),
996
+ ).resolves.toBeUndefined();
757
997
  });
758
998
  });
@@ -10,9 +10,10 @@
10
10
  * tool failure. A tool is its own file, so the declaration needs a home that is
11
11
  * neither the agent nor any one tool.
12
12
  *
13
- * The `"use workflow"` BODY stays in `workflows/recap.ts`: the Workflow DevKit
14
- * builder scans that directory and rewrites what it finds, and a body written
15
- * anywhere else runs inline once with no durability and nothing saying so.
13
+ * The BODY stays in `workflows/recap.ts` by CONVENTION rather than by
14
+ * mechanism — nothing scans that directory any more, and a body reached with a
15
+ * `ctx` is durable wherever it is written. Keeping it there is what makes the
16
+ * declaration, the tools and the body findable from one another.
16
17
  */
17
18
 
18
19
  import { workflow } from "@alexkroman1/aai";