@alexkroman1/aai-cli 9.2.0 → 10.0.1
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-CDuYl5Gb.mjs → _bundler-B31MqYaD.mjs} +7 -33
- package/dist/_bundler.d.ts +3 -41
- package/dist/{_dev-server-BAR8Ya43.mjs → _dev-server-Bo5dSBzQ.mjs} +140 -44
- package/dist/_dev-server.d.ts +1 -8
- package/dist/_dev-vite-config.d.ts +21 -0
- package/dist/{_init-BNotRgV1.mjs → _init-BURbdHNM.mjs} +1 -1
- package/dist/{_slug-api--ZBmIGH-.mjs → _slug-api-CfGQq1Tt.mjs} +1 -1
- package/dist/{_templates-4WcKOjS5.mjs → _templates-0Zr2z6yA.mjs} +37 -2
- package/dist/_templates.d.ts +29 -0
- package/dist/{build-DvDdSwAS.mjs → build-DKC2e9o_.mjs} +1 -2
- package/dist/cli.mjs +46 -22
- package/dist/{delete-8pjH8n8n.mjs → delete-X_CRgCf6.mjs} +1 -1
- package/dist/{deploy-CjU3e-3P.mjs → deploy-CchW5pmw.mjs} +2 -2
- package/dist/{dev-dFzdvwa7.mjs → dev-BfVl1uBA.mjs} +1 -1
- package/dist/{init-DK7GpIF9.mjs → init-BRfoc2EK.mjs} +2 -2
- package/dist/{logs-D2Yhqe2w.mjs → logs-hWnJ66Bl.mjs} +2 -2
- package/dist/scaffold/CLAUDE.md +162 -54
- package/dist/scaffold/package.json +4 -6
- package/dist/scaffold/server.mjs +12 -23
- package/dist/{secret-CwgDLpSy.mjs → secret-B-3T5tvr.mjs} +2 -2
- package/dist/{studio-D3-HWIfk.mjs → studio-wVWNLREn.mjs} +4 -3
- package/dist/templates/call-audit/agent.eval.test.ts +1 -1
- package/dist/templates/call-audit/agent.test.ts +80 -8
- package/dist/templates/call-audit/client.tsx +87 -3
- package/dist/templates/call-audit/workflows/audit.ts +52 -32
- package/dist/templates/call-audit/workflows/ingest.ts +15 -15
- package/dist/templates/call-audit/workflows/media.ts +1 -1
- package/dist/templates/call-audit/workflows/summarize.ts +0 -4
- package/dist/templates/link-digest/agent.eval.test.ts +4 -2
- package/dist/templates/link-digest/agent.test.ts +25 -5
- package/dist/templates/link-digest/client.tsx +65 -7
- package/dist/templates/link-digest/workflows/digest.ts +39 -29
- package/dist/templates/podcast-digest/agent.eval.test.ts +5 -5
- package/dist/templates/podcast-digest/agent.test.ts +108 -2
- package/dist/templates/podcast-digest/client.tsx +89 -8
- package/dist/templates/podcast-digest/workflows/digest.ts +67 -48
- package/dist/templates/podcast-digest/workflows/feeds.ts +1 -4
- package/dist/templates/podcast-digest/workflows/slack.ts +5 -6
- package/dist/templates/recap-workflow/agent.eval.test.ts +72 -8
- package/dist/templates/recap-workflow/agent.test.ts +323 -83
- package/dist/templates/recap-workflow/shared.ts +4 -3
- package/dist/templates/recap-workflow/workflows/recap.ts +322 -86
- package/dist/templates/recap-workflow/workflows/tokens.ts +21 -0
- package/dist/templates/redline/agent.eval.test.ts +1 -1
- package/dist/templates/redline/agent.test.ts +1 -1
- package/dist/templates/redline/client.tsx +83 -3
- package/dist/templates/redline/workflows/redline.ts +26 -23
- package/dist/templates/research-workflow/agent.eval.test.ts +9 -8
- package/dist/templates/research-workflow/agent.test.ts +32 -6
- package/dist/templates/research-workflow/agent.ts +4 -4
- package/dist/templates/research-workflow/shared.ts +4 -3
- package/dist/templates/research-workflow/workflows/prompts.ts +1 -1
- package/dist/templates/research-workflow/workflows/research.ts +35 -38
- package/dist/templates/spoken-summary/agent.eval.test.ts +3 -3
- package/dist/templates/spoken-summary/agent.test.ts +10 -8
- package/dist/templates/spoken-summary/client.tsx +72 -3
- package/dist/templates/spoken-summary/workflows/summarize.ts +25 -21
- package/dist/templates/spoken-summary/workflows/transcribe.ts +15 -18
- package/dist/templates/transcription-workflow/agent.eval.test.ts +1 -1
- package/dist/templates/transcription-workflow/agent.test.ts +626 -14
- package/dist/templates/transcription-workflow/agent.ts +6 -4
- package/dist/templates/transcription-workflow/client.tsx +106 -308
- package/dist/templates/transcription-workflow/recover.test.ts +141 -0
- package/dist/templates/transcription-workflow/recover.ts +130 -0
- package/dist/templates/transcription-workflow/run-panel.tsx +203 -0
- package/dist/templates/transcription-workflow/total-latency.tsx +144 -0
- package/dist/templates/transcription-workflow/workflows/batch.ts +21 -24
- package/dist/templates/transcription-workflow/workflows/downsample.ts +273 -0
- package/dist/templates/transcription-workflow/workflows/normalize.ts +17 -40
- package/dist/templates/transcription-workflow/workflows/stream.ts +177 -24
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +69 -32
- package/dist/templates/transcription-workflow/workflows/wav.ts +15 -4
- package/dist/worker-bundler.d.ts +0 -14
- package/dist/worker-bundler.mjs +149 -1
- package/dist/{workflow-BflATMmU.mjs → workflow-BBu5oEDw.mjs} +84 -10
- package/dist/workflow.d.ts +1 -0
- package/package.json +4 -6
- package/dist/_workflow-scan.d.ts +0 -108
- package/dist/worker-bundler-CCVEDjm1.mjs +0 -650
- package/dist/workflow-bundler.d.ts +0 -138
- 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
|
|
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
|
-
|
|
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("
|
|
570
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
634
|
-
*
|
|
635
|
-
*
|
|
636
|
-
*
|
|
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
|
-
|
|
639
|
-
|
|
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
|
-
|
|
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({
|
|
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("
|
|
698
|
-
// `createHook
|
|
699
|
-
//
|
|
700
|
-
//
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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
|
-
|
|
711
|
-
expect(
|
|
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(
|
|
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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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";
|