@alexkroman1/aai-cli 10.0.0 → 11.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 (45) hide show
  1. package/dist/{_bundler-B31MqYaD.mjs → _bundler-C31rlcYz.mjs} +1 -1
  2. package/dist/{_dev-server-Bo5dSBzQ.mjs → _dev-server-DWvkNoIS.mjs} +3 -3
  3. package/dist/{_vite-env-BNveawd1.mjs → _vite-env-D9Qj6KPX.mjs} +4 -2
  4. package/dist/_workflow-determinism-f9J-5Zlj.mjs +206 -0
  5. package/dist/_workflow-determinism.d.ts +118 -0
  6. package/dist/{build-DKC2e9o_.mjs → build-BLlLOrng.mjs} +4 -2
  7. package/dist/cli.mjs +372 -353
  8. package/dist/{client-bundler-CWnG42cU.mjs → client-bundler-j5c46x0G.mjs} +3 -2
  9. package/dist/client-bundler.mjs +1 -1
  10. package/dist/{deploy-CchW5pmw.mjs → deploy-BzZ-r4iM.mjs} +6 -4
  11. package/dist/{dev-BfVl1uBA.mjs → dev-BDykFvcc.mjs} +1 -1
  12. package/dist/{eval-DwNwdqmS.mjs → eval-0qEOdm0l.mjs} +1 -1
  13. package/dist/{init-BRfoc2EK.mjs → init-BtJI306C.mjs} +1 -1
  14. package/dist/{login-BBuM1sxH.mjs → login-DeUETobb.mjs} +4 -1
  15. package/dist/scaffold/CLAUDE.md +190 -11
  16. package/dist/scaffold/package.json +4 -4
  17. package/dist/{studio-wVWNLREn.mjs → studio-BxAS_FQQ.mjs} +4 -1
  18. package/dist/templates/call-audit/agent.test.ts +105 -26
  19. package/dist/templates/call-audit/workflows/audit.ts +9 -26
  20. package/dist/templates/dispatch-center/client.tsx +36 -2
  21. package/dist/templates/infocom-adventure/client.tsx +37 -9
  22. package/dist/templates/link-digest/agent.eval.test.ts +1 -1
  23. package/dist/templates/link-digest/agent.test.ts +164 -8
  24. package/dist/templates/link-digest/workflows/digest.ts +63 -20
  25. package/dist/templates/podcast-digest/agent.eval.test.ts +7 -4
  26. package/dist/templates/podcast-digest/agent.test.ts +265 -24
  27. package/dist/templates/podcast-digest/workflows/digest.ts +2 -2
  28. package/dist/templates/podcast-digest/workflows/feeds.ts +78 -73
  29. package/dist/templates/recap-workflow/agent.test.ts +206 -20
  30. package/dist/templates/recap-workflow/workflows/recap.ts +17 -14
  31. package/dist/templates/redline/agent.test.ts +137 -11
  32. package/dist/templates/research-workflow/agent.eval.test.ts +1 -1
  33. package/dist/templates/research-workflow/agent.test.ts +131 -9
  34. package/dist/templates/research-workflow/workflows/research.ts +1 -1
  35. package/dist/templates/retail/client.tsx +30 -2
  36. package/dist/templates/spoken-summary/agent.eval.test.ts +4 -1
  37. package/dist/templates/spoken-summary/agent.test.ts +117 -8
  38. package/dist/templates/spoken-summary/workflows/summarize.ts +1 -1
  39. package/dist/templates/transcription-workflow/agent.test.ts +127 -13
  40. package/dist/templates/transcription-workflow/workflows/batch.ts +6 -6
  41. package/dist/templates/transcription-workflow/workflows/stream.ts +4 -3
  42. package/dist/templates/transcription-workflow/workflows/transcribe.ts +4 -23
  43. package/dist/{test-CvwgeVSQ.mjs → test-CiLab-AA.mjs} +2 -1
  44. package/dist/worker-bundler.mjs +1 -1
  45. package/package.json +25 -24
@@ -7,8 +7,9 @@
7
7
  *
8
8
  * Three tiers, and the line between them is what this file is careful about:
9
9
  *
10
- * - **The tools**, against a stubbed `ctx.workflows`. That is the only honest
11
- * way to unit-test them (the real client needs a Workflow DevKit world), and
10
+ * - **The tools**, against a stubbed `ctx.workflows`. That is the honest way to
11
+ * unit-test a TOOL what a tool owns is the call it makes, not what the run
12
+ * does afterwards — and
12
13
  * it is enough: what the agent half promises is that the handoff passes a
13
14
  * correlation key, that a second request finds the live run instead of paying
14
15
  * for a second transcription, and that a cancel says out loud what cancelling
@@ -20,24 +21,24 @@
20
21
  * - **The body's two helpers** — the poll loop and the compensation unwind —
21
22
  * with `sleep` stubbed. What that asserts is ORDERING and BRANCHING, which is
22
23
  * ordinary logic and worth pinning; it asserts nothing about durability,
23
- * replay or suspension, and could not. `recapFlow` itself is deliberately not
24
- * driven here for exactly that reason a body test dressed up as a durability
25
- * test would be the worse failure. `aai-cli`'s
26
- * `dev-workflow.scenario.test.ts` is the tier that builds a project and
27
- * runs a real one.
24
+ * replay or suspension, and could not.
25
+ * - **`recapFlow` itself, durably.** The last block runs it on the real replay
26
+ * engine over an in-memory journal (`runWorkflow`,
27
+ * `@alexkroman1/aai-runtime/testing`), which this file used to say needed a
28
+ * built world. It is the tier that reaches this desk's two most expensive
29
+ * claims: that a resume does not re-transcribe, and that the RETENTION GATE's
30
+ * unanswered window deletes. `aai-cli`'s `dev-workflow.scenario.test.ts` is
31
+ * still the tier above it, with a project and a real queue.
28
32
  *
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
33
+ * **The branch this file used to name as its biggest gap no longer exists.** It
34
+ * was `recapFlow`'s `if (isWorkflowSuspend(err)) throw err;` — the guard whose
35
+ * absence had once deleted the transcript the run was waiting for and it was
36
+ * unpinnable here by construction, since `createWorkflowCtx`'s `sleep` is
35
37
  * 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.
38
+ * suspended. A wait now hands the body a promise that never settles, so a
39
+ * suspension cannot reach a `catch` at all and there is no branch left to test:
40
+ * `aai-runtime`'s `workflow-replay-suspend.ts` carries the mechanism and
41
+ * `workflow-replay.test.ts` the engine-level proof, for every template at once.
41
42
  */
42
43
 
43
44
  /** The def a DEPLOYED agent runs: authored, plus what `tools/` declares. */
@@ -49,6 +50,7 @@ import {
49
50
  createWorkflowCtx,
50
51
  parseSchemaInput,
51
52
  schemaInputIssues,
53
+ stubGatewayRoute,
52
54
  toolRunner,
53
55
  } from "@alexkroman1/aai/testing";
54
56
  import {
@@ -57,6 +59,7 @@ import {
57
59
  installStubGateway as stubGateway,
58
60
  } from "@alexkroman1/aai/testing/vitest";
59
61
  import type { WorkflowRunSnapshot } from "@alexkroman1/aai/workflow-api";
62
+ import { runWorkflow } from "@alexkroman1/aai-runtime/testing";
60
63
  import { beforeEach, describe, expect, test, vi } from "vitest";
61
64
  import { recap } from "./shared.ts";
62
65
  import {
@@ -798,7 +801,7 @@ describe("awaitTranscript — the polling port, and the callback over it", () =>
798
801
  // Three reads: the one before the park, the one the delivery woke, and the
799
802
  // one after the ordinary fifteen-second wait that followed it.
800
803
  expect(polls()).toBe(3);
801
- expect(ctx.slept).toEqual([{ until: 15_000, correlationId: undefined }]);
804
+ expect(ctx.slept).toEqual([{ label: "poll", until: 15_000, correlationId: undefined }]);
802
805
  });
803
806
 
804
807
  test("parks ONCE and then polls, because a token cannot be claimed twice", async () => {
@@ -834,7 +837,7 @@ describe("awaitTranscript — the polling port, and the callback over it", () =>
834
837
  expect(polls()).toBe(3);
835
838
  // It asked, and then it stopped counting on the answer.
836
839
  expect(ctx.waited).toEqual([NUDGE]);
837
- expect(ctx.slept).toEqual([{ until: 15_000, correlationId: undefined }]);
840
+ expect(ctx.slept).toEqual([{ label: "poll", until: 15_000, correlationId: undefined }]);
838
841
  });
839
842
 
840
843
  test("says 'still transcribing' after ONE closed window, not after nine", async () => {
@@ -996,3 +999,186 @@ describe("compensate — the saga port", () => {
996
999
  ).resolves.toBeUndefined();
997
1000
  });
998
1001
  });
1002
+
1003
+ /**
1004
+ * `recapFlow` itself, on the real replay engine.
1005
+ *
1006
+ * `runWorkflow` starts the declared workflow on `createInProcessWorkflowEngine`
1007
+ * over an in-memory journal and drives one delivery at a time, with a suspension
1008
+ * RECORDED rather than waited out. So the poll cadence a deployed run spends
1009
+ * fifteen seconds a turn on costs this file nothing, and the run really parks,
1010
+ * really resumes off its journal, and really closes its window.
1011
+ *
1012
+ * Two of this desk's claims are only reachable here, and both are expensive when
1013
+ * wrong:
1014
+ *
1015
+ * - **A resume does not re-transcribe.** Twenty minutes of provider time is
1016
+ * already paid for, which is why `summarize` is its own step; nothing below
1017
+ * this tier can show that a second walk did not submit the recording again.
1018
+ * - **The retention gate's unanswered window DELETES.** "A gate whose no-answer
1019
+ * branch keeps the data is not a gate", and for a desk holding transcripts of
1020
+ * other people's meetings the default is what the whole pattern is about.
1021
+ * `ctx.workflows.wakeUp` deliberately cannot end an approval window — that is
1022
+ * `SleepRecord.kind`'s entire reason — so `expireWaits()` is the only thing
1023
+ * that reaches the branch.
1024
+ *
1025
+ * Note the body takes the POLL arm throughout: `callbackUrl` degrades to
1026
+ * `undefined` in a spec (no minter is published), so `job.callback` is `false`
1027
+ * and the first-turn callback park is never entered. That is the same arm a
1028
+ * local `aai dev` run takes, and its own doc says so.
1029
+ */
1030
+ describe("the run is DURABLE", () => {
1031
+ const URL = "https://example.com/standup.mp3";
1032
+ const INPUT = { url: URL, requestedBy: "sess_1" };
1033
+ const RECAP = '{"headline":"Standup","points":["a","b"],"spoken":"They shipped it."}';
1034
+
1035
+ /**
1036
+ * The provider and the model, behind ONE published `stepFetch`.
1037
+ *
1038
+ * Every step's HTTP goes through the slot, the model call included, so a
1039
+ * gateway stub installed over `globalThis.fetch` beside a provider stub would
1040
+ * be bypassed — `link-digest/agent.test.ts` carries the same note.
1041
+ * `stubGatewayRoute` is the composition for it.
1042
+ *
1043
+ * `transcript` is a list of successive `GET` answers, so a spec says how many
1044
+ * polls the job takes by how many entries it gives.
1045
+ */
1046
+ function stubWorld(transcript: readonly Record<string, unknown>[]) {
1047
+ const model = stubGatewayRoute(RECAP);
1048
+ const deletes: string[] = [];
1049
+ let polls = 0;
1050
+ const provider = installStubStepFetch((request) => {
1051
+ const routed = model.route(request);
1052
+ if (routed) return routed;
1053
+ if (request.method === "DELETE") {
1054
+ deletes.push(request.url);
1055
+ return { status: 200, body: {} };
1056
+ }
1057
+ if (request.method === "POST") return { status: 200, body: { id: "t_1", status: "queued" } };
1058
+ // The last answer repeats, so a spec that wants one more poll than it
1059
+ // scripted gets the terminal state rather than an index error.
1060
+ const at = Math.min(polls++, transcript.length - 1);
1061
+ return { status: 200, body: transcript[at] ?? {} };
1062
+ });
1063
+ return { model, deletes, provider, polls: () => polls };
1064
+ }
1065
+
1066
+ const PROCESSING = { id: "t_1", status: "processing" };
1067
+ const DONE = {
1068
+ id: "t_1",
1069
+ status: "completed",
1070
+ text: "We shipped it.",
1071
+ audio_duration: 600,
1072
+ };
1073
+
1074
+ beforeEach(() => {
1075
+ vi.stubEnv("ASSEMBLYAI_API_KEY", "sk-test");
1076
+ });
1077
+
1078
+ test("submits once, then parks on the poll cadence rather than blocking", async () => {
1079
+ stubWorld([PROCESSING, DONE]);
1080
+ const started = Date.now();
1081
+ const run = await runWorkflow(recap, INPUT, { name: "recap" });
1082
+
1083
+ expect(run.status).toBe("running");
1084
+ // The 15s poll interval, journaled — the run is in progress and not
1085
+ // executing, which is what a caller polling it sees.
1086
+ expect(run.wakeAt).toBeGreaterThan(started);
1087
+ expect(run.steps.map((step) => step.key)).toEqual(["checkTranscript#0", "submitRecording#0"]);
1088
+ });
1089
+
1090
+ test("resumes past the poll WITHOUT re-submitting the recording", async () => {
1091
+ // The claim `summarize` is a separate step for: twenty minutes of provider
1092
+ // time is already paid for, and a resume must not spend it again.
1093
+ const world = stubWorld([PROCESSING, DONE]);
1094
+ const run = await runWorkflow(recap, INPUT, { name: "recap" });
1095
+ await run.advanceSleep();
1096
+
1097
+ // Parked again, now on the retention gate — a hook with a deadline.
1098
+ expect(run.status).toBe("running");
1099
+ expect(run.steps.map((step) => step.key)).toEqual([
1100
+ "checkTranscript#0",
1101
+ "checkTranscript#1",
1102
+ "noteGate#0",
1103
+ "submitRecording#0",
1104
+ "summarize#0",
1105
+ ]);
1106
+ expect(run.deliveries).toBe(2);
1107
+ // ONE submit across two walks — `submitRecording#0` came back out of the
1108
+ // journal on the second — and one model call.
1109
+ expect(
1110
+ world.provider.calls.filter((call) => call.method === "POST" && !call.url.includes("chat")),
1111
+ ).toHaveLength(1);
1112
+ expect(world.model.calls).toHaveLength(1);
1113
+ });
1114
+
1115
+ test("an answer of KEEP leaves the transcript on the account", async () => {
1116
+ const world = stubWorld([DONE]);
1117
+ const run = await runWorkflow(recap, INPUT, { name: "recap" });
1118
+ await run.signal(retentionToken("sess_1"), { keep: true });
1119
+
1120
+ expect(run.status).toBe("completed");
1121
+ expect(run.output).toMatchObject({ kept: true, answered: true, requestedBy: "sess_1" });
1122
+ // No discard step reached at all, and nothing deleted.
1123
+ expect(run.steps.map((step) => step.name)).not.toContain("discardOnDecline");
1124
+ expect(world.deletes).toEqual([]);
1125
+ });
1126
+
1127
+ test("an UNANSWERED window deletes, which is the safe default the gate exists for", async () => {
1128
+ // The branch nothing else can reach: `ctx.workflows.wakeUp` must not close
1129
+ // an approval window, and the deadline carries no correlation id, so the
1130
+ // only public route to this outcome is to wait out two real minutes.
1131
+ const world = stubWorld([DONE]);
1132
+ const run = await runWorkflow(recap, INPUT, { name: "recap" });
1133
+ expect(run.status).toBe("running");
1134
+
1135
+ await run.expireWaits();
1136
+ expect(run.status).toBe("completed");
1137
+ // `answered: false` is the distinction the type carries: the caller did not
1138
+ // decline, they said nothing, and the desk deleted anyway.
1139
+ expect(run.output).toMatchObject({ kept: false, answered: false });
1140
+ expect(run.steps.map((step) => step.name)).toContain("discardOnDecline");
1141
+ expect(world.deletes).toHaveLength(1);
1142
+ expect(world.deletes[0]).toContain("t_1");
1143
+ });
1144
+
1145
+ test("a signal that arrives after the window closed cannot reopen it", async () => {
1146
+ // `closeHook` is a compare-and-set, so the walk that timed out and every
1147
+ // later replay read the same branch — the divergence `HookRecord.closed`
1148
+ // exists to prevent.
1149
+ stubWorld([DONE]);
1150
+ const run = await runWorkflow(recap, INPUT, { name: "recap" });
1151
+ await run.expireWaits();
1152
+ expect(run.output).toMatchObject({ kept: false });
1153
+
1154
+ await run.signal(retentionToken("sess_1"), { keep: true });
1155
+ expect(run.signalled).toBe(false);
1156
+ expect(run.output).toMatchObject({ kept: false });
1157
+ });
1158
+
1159
+ test("a failure after the transcript exists UNWINDS it, and the run still fails", async () => {
1160
+ // The saga. `summarize` is given prose instead of JSON on every attempt, so
1161
+ // the step exhausts its patience and the body's catch runs the compensation
1162
+ // stack — which must delete the transcript the run acquired.
1163
+ const model = stubGatewayRoute("Here is a recap, in prose, as you did not ask.");
1164
+ const deletes: string[] = [];
1165
+ installStubStepFetch((request) => {
1166
+ const routed = model.route(request);
1167
+ if (routed) return routed;
1168
+ if (request.method === "DELETE") {
1169
+ deletes.push(request.url);
1170
+ return { status: 200, body: {} };
1171
+ }
1172
+ if (request.method === "POST") return { status: 200, body: { id: "t_1", status: "queued" } };
1173
+ return { status: 200, body: DONE };
1174
+ });
1175
+
1176
+ const run = await runWorkflow(recap, INPUT, { name: "recap" });
1177
+ expect(run.status).toBe("failed");
1178
+ expect(run.error).toMatch(/JSON/i);
1179
+ // The undo ran, as a STEP — which is what makes a crash during the unwind
1180
+ // resume with the finished ones replayed rather than run twice.
1181
+ expect(run.steps.map((step) => step.name)).toContain("discardTranscript");
1182
+ expect(deletes).toHaveLength(1);
1183
+ });
1184
+ });
@@ -111,7 +111,7 @@
111
111
  * just your shell.
112
112
  */
113
113
 
114
- import { isWorkflowSuspend, type WorkflowCtx } from "@alexkroman1/aai";
114
+ import type { WorkflowCtx } from "@alexkroman1/aai";
115
115
  import { report, requireStepEnv, stepFetch, stepWebhookUrl } from "@alexkroman1/aai/step";
116
116
  import {
117
117
  FatalError,
@@ -333,18 +333,21 @@ export async function recapFlow(input: { url: string; requestedBy: string }, ctx
333
333
  const retention = await askWhetherToKeep(input.requestedBy, job.id, compensations, ctx);
334
334
  return { ...recap, ...retention, requestedBy: input.requestedBy };
335
335
  } catch (err) {
336
- // **A suspend is not a failure, and this catch is why that matters.** The
337
- // body above WAITS three ways now — `awaitTranscript` parks on the
338
- // provider's callback, then sleeps between polls, and the gate waits for an
339
- // answer and every one of them suspends by throwing, so it lands here.
340
- // Without this line the first poll that had to wait unwound the compensation
341
- // stack, DELETED the transcript the run was waiting for, journaled the
342
- // deletion as successful and re-threw; the engine saw its own signal come
343
- // back out and recorded the run as healthily suspended. The data was gone and
344
- // every signal said fine. `replayRun` now also fails a run that swallows one,
345
- // so a body that forgets this is loud rather than silently destructive — but
346
- // the body is the place it belongs.
347
- if (isWorkflowSuspend(err)) throw err;
336
+ // **A suspension cannot arrive here, and it once could.** The body above
337
+ // WAITS three ways — `awaitTranscript` parks on the provider's callback,
338
+ // then sleeps between polls, and the gate waits for an answer — and each of
339
+ // those used to suspend by THROWING, so it landed in this catch. This saga
340
+ // is the code that paid for it: the first poll that had to wait unwound the
341
+ // compensation stack, DELETED the transcript the run was waiting for,
342
+ // journaled the deletion as successful and re-threw, and the engine saw its
343
+ // own signal come back out and recorded the run as healthily suspended. The
344
+ // data was gone and every signal said fine.
345
+ //
346
+ // The guard that lived here (`if (isWorkflowSuspend(err)) throw err;`) is
347
+ // gone because the hazard is: a wait now hands back a promise that never
348
+ // settles, so a parked body does not reach a `catch` at all. This block sees
349
+ // step failures, and nothing else.
350
+ //
348
351
  // The saga's whole point. Everything acquired above is released, in reverse,
349
352
  // before the failure is re-thrown — and because each undo is a STEP, a crash
350
353
  // during the unwind resumes with the finished ones replayed from the journal
@@ -441,7 +444,7 @@ export async function awaitTranscript(
441
444
  // read at the top of the next turn is what establishes the fact.
442
445
  await ctx.waitFor(nudge, { timeoutMs: CALLBACK_WINDOW_MS });
443
446
  } else {
444
- await ctx.sleep(POLL_INTERVAL_MS);
447
+ await ctx.sleep("poll", POLL_INTERVAL_MS);
445
448
  }
446
449
  }
447
450
  throw new Error(`Gave up on that recording after ${MAX_POLLS} checks.`);
@@ -1,25 +1,26 @@
1
1
  /**
2
2
  * Specs for the redline desk — the reflection port, as a workflow app.
3
3
  *
4
- * Same honest line as `link-digest`'s spec, for the same reason: the workflow
5
- * BODY is only durable once the Workflow DevKit's build has transformed it, so
6
- * testing it here would exercise a plain async function and prove nothing about
7
- * replay. What IS testable is the declaration (three things that are silent when
8
- * wrong — the `page: "static"` field, the workflow's name, and the input schema)
9
- * and the STEPS, which imported with no bundler in the path are ordinary async
4
+ * The declaration is testable (three things that are silent when wrong — the
5
+ * `page: "static"` field, the workflow's name, and the input schema) and so are
6
+ * the STEPS, which imported with no bundler in the path are ordinary async
10
7
  * functions: their JSON contract with the model, their `FatalError` guards, and
11
8
  * the pure helpers underneath them.
12
9
  *
13
- * The loop's EXIT is the one thing worth naming that a spec here cannot reach.
14
- * It is decided in the body, on a step's journaled verdict — see the module doc
15
- * in `workflows/redline.ts` — and what would prove it is a replay, which needs a
16
- * built world. The critique step's verdict handling is where the testable half
17
- * of that lives.
10
+ * **And so is the loop's EXIT**, which this file used to name as the one thing a
11
+ * spec here could not reach: "it is decided in the body, on a step's journaled
12
+ * verdict, and what would prove it is a replay, which needs a built world."
13
+ * There is no built world any more `runWorkflow`
14
+ * (`@alexkroman1/aai-runtime/testing`) runs this body on the real replay engine
15
+ * over an in-memory journal. The last block below is that claim, asserted the
16
+ * only way it can be: crash the desk mid-round, hand the journal to a fresh
17
+ * engine, and count the model calls the resume did NOT make.
18
18
  */
19
19
 
20
20
  import { FatalError } from "@alexkroman1/aai/step-errors";
21
21
  import { parseSchemaInput, schemaInputIssues } from "@alexkroman1/aai/testing";
22
22
  import { installStubGateway as stubGateway } from "@alexkroman1/aai/testing/vitest";
23
+ import { runWorkflow } from "@alexkroman1/aai-runtime/testing";
23
24
  import { beforeEach, describe, expect, test, vi } from "vitest";
24
25
  import agentDef, { MAX_ROUNDS, redline } from "./agent.ts";
25
26
  import {
@@ -209,3 +210,128 @@ describe("the steps", () => {
209
210
  });
210
211
  });
211
212
  });
213
+
214
+ /**
215
+ * The loop, against a real durable engine.
216
+ *
217
+ * `runWorkflow` starts the declared workflow on the engine `aai dev` uses, over
218
+ * an in-memory journal, and drives one delivery at a time. The desk has no
219
+ * suspension, so what these assert is the OTHER durable property — the one this
220
+ * module doc says is the shape neither sibling template has: a loop whose exit
221
+ * is decided at run time, on a step's journaled verdict, and which therefore has
222
+ * to take the same branch on every walk.
223
+ *
224
+ * The model is the whole world here (`writeDraft`, `critiqueDraft` and
225
+ * `reviseDraft` are all `stepGenerate*Classified`), so `stubGateway`'s scripted
226
+ * replies ARE the run, and its call log is what proves a replay did not pay for
227
+ * a round twice. Scripted in body order, with the last reply repeating.
228
+ */
229
+ describe("the run is DURABLE", () => {
230
+ const BRIEF = {
231
+ brief: "Explain why a 402 is the interesting status code for an agent to meet.",
232
+ audience: "engineers" as const,
233
+ mustCover: [],
234
+ };
235
+ const SHIP = '{"verdict":"ship","score":9,"notes":[]}';
236
+ const REVISE = '{"verdict":"revise","score":5,"notes":["thin in the middle"]}';
237
+
238
+ beforeEach(() => {
239
+ vi.stubEnv("ASSEMBLYAI_API_KEY", "sk-test");
240
+ });
241
+
242
+ test("stops on the CRITIC's verdict, and journals one step per call site reached", async () => {
243
+ const model = stubGateway(["A draft about 402s.", SHIP]);
244
+ const run = await runWorkflow(redline, { ...BRIEF, rounds: 3 }, { name: "redline" });
245
+
246
+ expect(run.status).toBe("completed");
247
+ expect(run.output?.shipped).toBe(true);
248
+ expect(run.output?.roundsRun).toBe(1);
249
+ // Two rounds of budget went unspent, so the journal holds one critique and
250
+ // no revision at all — `(name, occurrence)` identity means the entries are
251
+ // the record of which call sites the body actually reached.
252
+ expect(run.steps.map((step) => step.key)).toEqual(["critiqueDraft#0", "writeDraft#0"]);
253
+ expect(model).toHaveLength(2);
254
+ });
255
+
256
+ test("journals a round per iteration, so `critiqueDraft#1` is round two", async () => {
257
+ // Revise, revise, then ship: the loop runs to its budget of three.
258
+ const model = stubGateway([
259
+ "A draft about 402s.",
260
+ REVISE,
261
+ "A better draft.",
262
+ REVISE,
263
+ "A better draft still.",
264
+ SHIP,
265
+ ]);
266
+ const run = await runWorkflow(redline, { ...BRIEF, rounds: 3 }, { name: "redline" });
267
+
268
+ expect(run.status).toBe("completed");
269
+ expect(run.output?.roundsRun).toBe(3);
270
+ expect(run.steps.map((step) => step.key)).toEqual([
271
+ "critiqueDraft#0",
272
+ "critiqueDraft#1",
273
+ "critiqueDraft#2",
274
+ "reviseDraft#0",
275
+ "reviseDraft#1",
276
+ "writeDraft#0",
277
+ ]);
278
+ expect(model).toHaveLength(6);
279
+ });
280
+
281
+ test("a worker that dies mid-round replays the finished rounds instead of re-writing them", async () => {
282
+ // The claim the module doc makes and nothing could check: "a rate limit in
283
+ // round three replays rounds one and two from the journal for free and
284
+ // re-issues only the call that failed."
285
+ const model = stubGateway(["A draft about 402s.", REVISE, "A better draft.", SHIP]);
286
+ const run = await runWorkflow(
287
+ redline,
288
+ { ...BRIEF, rounds: 3 },
289
+ { name: "redline", crashAt: "reviseDraft" },
290
+ );
291
+
292
+ expect(run.crashed).toBe(true);
293
+ expect(run.steps.map((step) => step.key)).toEqual(["critiqueDraft#0", "writeDraft#0"]);
294
+ const spentBeforeTheCrash = model.length;
295
+ expect(spentBeforeTheCrash).toBe(2);
296
+
297
+ await run.restart();
298
+ expect(run.status).toBe("completed");
299
+ expect(run.output?.shipped).toBe(true);
300
+ // Four calls in total for a run that reached four call sites — so the
301
+ // resume paid for the revision and the second critique and NOT for the
302
+ // draft or the first critique, which came back out of the journal.
303
+ expect(model).toHaveLength(4);
304
+ expect(run.steps.map((step) => step.key)).toEqual([
305
+ "critiqueDraft#0",
306
+ "critiqueDraft#1",
307
+ "reviseDraft#0",
308
+ "writeDraft#0",
309
+ ]);
310
+ });
311
+
312
+ test("takes the SAME branch on the walk after a crash, because the verdict is journaled", async () => {
313
+ // The replay-stability claim itself. The critic said "ship" on the first
314
+ // walk; the model is then scripted to say "revise" to anything asked
315
+ // afterwards. A body that re-decided the loop on a fresh model call would
316
+ // carry on revising — a body that reads its journaled verdict cannot.
317
+ const model = stubGateway(["A draft about 402s.", SHIP, REVISE]);
318
+ const run = await runWorkflow(
319
+ redline,
320
+ { ...BRIEF, rounds: 3 },
321
+ { name: "redline", crashAt: "critiqueDraft" },
322
+ );
323
+ // Crashed BEFORE the critique's body ran, so nothing is journaled but the
324
+ // draft — and the resume is what reaches the verdict.
325
+ expect(run.crashed).toBe(true);
326
+ expect(run.steps.map((step) => step.key)).toEqual(["writeDraft#0"]);
327
+
328
+ await run.restart();
329
+ expect(run.status).toBe("completed");
330
+ expect(run.output?.shipped).toBe(true);
331
+ expect(run.output?.roundsRun).toBe(1);
332
+ // Two calls: the draft, and the critique the crash cost. The third scripted
333
+ // reply — the one that would have kept the loop going — is never asked for,
334
+ // because the loop read its verdict out of the journal.
335
+ expect(model).toHaveLength(2);
336
+ });
337
+ });
@@ -250,7 +250,7 @@ describeEval(
250
250
  // durable `sleep` rather than suspending, so what a case can honestly
251
251
  // claim is that the body asked — and that is the assertion that fails
252
252
  // if the suspension is ever deleted.
253
- expect(run?.slept).toEqual([{ duration: REVIEW_DELAY_MS }]);
253
+ expect(run?.slept).toEqual([{ label: "reviewWindow", duration: REVIEW_DELAY_MS }]);
254
254
 
255
255
  // Six model calls, all through the step slot: the fan-out's width came
256
256
  // from a journaled stage rather than from anything the body recomputed.