@alexkroman1/aai-cli 10.0.1 → 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.
- package/dist/{_bundler-B31MqYaD.mjs → _bundler-C31rlcYz.mjs} +1 -1
- package/dist/{_dev-server-Bo5dSBzQ.mjs → _dev-server-DWvkNoIS.mjs} +3 -3
- package/dist/{_vite-env-BNveawd1.mjs → _vite-env-D9Qj6KPX.mjs} +4 -2
- package/dist/_workflow-determinism-f9J-5Zlj.mjs +206 -0
- package/dist/_workflow-determinism.d.ts +118 -0
- package/dist/{build-DKC2e9o_.mjs → build-BLlLOrng.mjs} +4 -2
- package/dist/cli.mjs +372 -353
- package/dist/{client-bundler-CWnG42cU.mjs → client-bundler-j5c46x0G.mjs} +3 -2
- package/dist/client-bundler.mjs +1 -1
- package/dist/{deploy-CchW5pmw.mjs → deploy-BzZ-r4iM.mjs} +6 -4
- package/dist/{dev-BfVl1uBA.mjs → dev-BDykFvcc.mjs} +1 -1
- package/dist/{eval-DwNwdqmS.mjs → eval-0qEOdm0l.mjs} +1 -1
- package/dist/{init-BRfoc2EK.mjs → init-BtJI306C.mjs} +1 -1
- package/dist/{login-BBuM1sxH.mjs → login-DeUETobb.mjs} +4 -1
- package/dist/scaffold/CLAUDE.md +190 -11
- package/dist/scaffold/package.json +4 -4
- package/dist/{studio-wVWNLREn.mjs → studio-BxAS_FQQ.mjs} +4 -1
- package/dist/templates/call-audit/agent.test.ts +105 -26
- package/dist/templates/call-audit/workflows/audit.ts +9 -26
- package/dist/templates/dispatch-center/client.tsx +36 -2
- package/dist/templates/infocom-adventure/client.tsx +37 -9
- package/dist/templates/link-digest/agent.eval.test.ts +1 -1
- package/dist/templates/link-digest/agent.test.ts +164 -8
- package/dist/templates/link-digest/workflows/digest.ts +63 -20
- package/dist/templates/podcast-digest/agent.eval.test.ts +7 -4
- package/dist/templates/podcast-digest/agent.test.ts +265 -24
- package/dist/templates/podcast-digest/workflows/digest.ts +2 -2
- package/dist/templates/podcast-digest/workflows/feeds.ts +78 -73
- package/dist/templates/recap-workflow/agent.test.ts +206 -20
- package/dist/templates/recap-workflow/workflows/recap.ts +17 -14
- package/dist/templates/redline/agent.test.ts +137 -11
- package/dist/templates/research-workflow/agent.eval.test.ts +1 -1
- package/dist/templates/research-workflow/agent.test.ts +131 -9
- package/dist/templates/research-workflow/workflows/research.ts +1 -1
- package/dist/templates/retail/client.tsx +30 -2
- package/dist/templates/spoken-summary/agent.eval.test.ts +4 -1
- package/dist/templates/spoken-summary/agent.test.ts +117 -8
- package/dist/templates/spoken-summary/workflows/summarize.ts +1 -1
- package/dist/templates/transcription-workflow/agent.test.ts +127 -13
- package/dist/templates/transcription-workflow/workflows/batch.ts +6 -6
- package/dist/templates/transcription-workflow/workflows/stream.ts +4 -3
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +4 -23
- package/dist/{test-CvwgeVSQ.mjs → test-CiLab-AA.mjs} +2 -1
- package/dist/worker-bundler.mjs +1 -1
- 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
|
|
11
|
-
*
|
|
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.
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
-
* **
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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 {
|
|
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
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
// stack, DELETED the transcript the run was waiting for,
|
|
342
|
-
// deletion as successful and re-threw
|
|
343
|
-
// back out and recorded the run as healthily suspended. The
|
|
344
|
-
// every signal said fine.
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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.
|