@alexkroman1/aai-cli 7.0.0 → 8.1.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 (122) hide show
  1. package/README.md +1 -0
  2. package/dist/{_bundler-B4RqNF5Z.mjs → _bundler-CDuYl5Gb.mjs} +1 -1
  3. package/dist/{_dev-server-CiKFH9qw.mjs → _dev-server-Dt3TCjhj.mjs} +6 -2
  4. package/dist/{_init-CE4HKdgV.mjs → _init-BhEfQ6Yi.mjs} +1 -1
  5. package/dist/_resource-commands.d.ts +23 -0
  6. package/dist/{_slug-api-nRFaBEPJ.mjs → _slug-api-z-USxPtL.mjs} +14 -10
  7. package/dist/_slug-api.d.ts +5 -2
  8. package/dist/{_templates-FI3xPEMj.mjs → _templates-4WcKOjS5.mjs} +4 -5
  9. package/dist/_templates.d.ts +3 -4
  10. package/dist/{build-6jfaakgc.mjs → build-BBxf8vom.mjs} +2 -2
  11. package/dist/cli.mjs +338 -346
  12. package/dist/{deploy-DvYeD8ia.mjs → deploy-DRC8jWr8.mjs} +1 -1
  13. package/dist/{dev-B59JIfWO.mjs → dev-QuuOOUtv.mjs} +1 -1
  14. package/dist/eval-DfQ8cm5p.mjs +56 -0
  15. package/dist/eval.d.ts +43 -0
  16. package/dist/{init-CpJVjEg0.mjs → init-CUo87gcI.mjs} +2 -2
  17. package/dist/{logs-9q-psa4q.mjs → logs-CQ2T46dr.mjs} +1 -1
  18. package/dist/scaffold/CLAUDE.md +409 -68
  19. package/dist/scaffold/package.json +5 -4
  20. package/dist/scaffold/server.mjs +10 -3
  21. package/dist/scaffold/tsconfig.json +0 -1
  22. package/dist/{secret-CVvSLIDV.mjs → secret-Ct34kdgU.mjs} +1 -1
  23. package/dist/{storage-BvUrnvM3.mjs → storage-i_79ZwAR.mjs} +1 -1
  24. package/dist/{studio--MUV0cid.mjs → studio-CzTzPqe1.mjs} +1 -1
  25. package/dist/templates/briefing-desk/agent.test.ts +350 -0
  26. package/dist/templates/briefing-desk/agent.ts +30 -0
  27. package/dist/templates/briefing-desk/shared.ts +200 -0
  28. package/dist/templates/briefing-desk/system-prompt.md +39 -0
  29. package/dist/templates/briefing-desk/tools/briefing_so_far.ts +35 -0
  30. package/dist/templates/briefing-desk/tools/research_topic.ts +88 -0
  31. package/dist/templates/briefing-desk/tools/verify_claim.ts +77 -0
  32. package/dist/templates/call-audit/agent.eval.test.ts +474 -0
  33. package/dist/templates/code-interpreter/agent.eval.test.ts +165 -0
  34. package/dist/templates/dispatch-center/agent.eval.test.ts +349 -0
  35. package/dist/templates/embedded-assets/agent.eval.test.ts +138 -0
  36. package/dist/templates/health-assistant/agent.eval.test.ts +167 -0
  37. package/dist/templates/infocom-adventure/agent.eval.test.ts +158 -0
  38. package/dist/templates/infocom-adventure/agent.test.ts +49 -18
  39. package/dist/templates/infocom-adventure/agent.ts +21 -0
  40. package/dist/templates/infocom-adventure/shared.ts +20 -9
  41. package/dist/templates/infocom-adventure/system-prompt.md +1 -1
  42. package/dist/templates/infocom-adventure/tools/game_state_move.ts +3 -2
  43. package/dist/templates/link-digest/agent.eval.test.ts +195 -0
  44. package/dist/templates/math-buddy/agent.eval.test.ts +146 -0
  45. package/dist/templates/night-owl/agent.eval.test.ts +272 -0
  46. package/dist/templates/personal-finance/agent.eval.test.ts +168 -0
  47. package/dist/templates/personal-finance/system-prompt.md +4 -0
  48. package/dist/templates/pipeline-simple/agent.eval.test.ts +78 -0
  49. package/dist/templates/pipeline-simple/agent.ts +2 -2
  50. package/dist/templates/pizza-ordering/agent.eval.test.ts +171 -0
  51. package/dist/templates/pizza-ordering/agent.test.ts +15 -0
  52. package/dist/templates/pizza-ordering/shared.ts +20 -1
  53. package/dist/templates/pizza-ordering/system-prompt.md +6 -0
  54. package/dist/templates/plan-and-execute/agent.eval.test.ts +209 -0
  55. package/dist/templates/podcast-digest/agent.eval.test.ts +411 -0
  56. package/dist/templates/podcast-digest/agent.test.ts +30 -59
  57. package/dist/templates/podcast-digest/agent.ts +1 -1
  58. package/dist/templates/podcast-digest/workflows/digest.ts +52 -19
  59. package/dist/templates/podcast-digest/workflows/slack.ts +48 -169
  60. package/dist/templates/recap-workflow/agent.eval.test.ts +366 -0
  61. package/dist/templates/redline/agent.eval.test.ts +209 -0
  62. package/dist/templates/research-workflow/agent.eval.test.ts +329 -0
  63. package/dist/templates/retail/address.ts +87 -1
  64. package/dist/templates/retail/agent.eval.test.ts +314 -0
  65. package/dist/templates/retail/agent.test.ts +171 -91
  66. package/dist/templates/retail/agent.ts +35 -1
  67. package/dist/templates/retail/cancel.ts +116 -0
  68. package/dist/templates/retail/client.tsx +39 -0
  69. package/dist/templates/retail/payment.ts +107 -0
  70. package/dist/templates/retail/pending.test.ts +196 -0
  71. package/dist/templates/retail/pending.ts +137 -0
  72. package/dist/templates/retail/registry.test.ts +119 -8
  73. package/dist/templates/retail/returns.ts +102 -0
  74. package/dist/templates/retail/shared.test.ts +42 -0
  75. package/dist/templates/retail/shared.ts +35 -0
  76. package/dist/templates/retail/store.test.ts +6 -4
  77. package/dist/templates/retail/store.ts +113 -57
  78. package/dist/templates/retail/swap.test.ts +4 -4
  79. package/dist/templates/retail/swap.ts +248 -15
  80. package/dist/templates/retail/system-prompt.md +62 -30
  81. package/dist/templates/retail/tools/cancel_change.ts +33 -0
  82. package/dist/templates/retail/tools/cancel_pending_order.ts +15 -59
  83. package/dist/templates/retail/tools/confirm_change.ts +42 -0
  84. package/dist/templates/retail/tools/exchange_delivered_order_items.ts +21 -66
  85. package/dist/templates/retail/tools/modify_pending_order_address.ts +12 -24
  86. package/dist/templates/retail/tools/modify_pending_order_items.ts +22 -60
  87. package/dist/templates/retail/tools/modify_pending_order_payment.ts +15 -77
  88. package/dist/templates/retail/tools/modify_user_address.ts +12 -15
  89. package/dist/templates/retail/tools/return_delivered_order_items.ts +16 -71
  90. package/dist/templates/retail/tools/transfer_to_human_agents.ts +4 -2
  91. package/dist/templates/simple/agent.eval.test.ts +54 -0
  92. package/dist/templates/solo-rpg/agent.eval.test.ts +320 -0
  93. package/dist/templates/spoken-summary/agent.eval.test.ts +342 -0
  94. package/dist/templates/support-line/agent.eval.test.ts +273 -0
  95. package/dist/templates/support-line/system-prompt.md +2 -1
  96. package/dist/templates/support-line/tools/answer_question.ts +56 -7
  97. package/dist/templates/transcription-workflow/agent.eval.test.ts +234 -0
  98. package/dist/templates/travel-concierge/agent.eval.test.ts +323 -0
  99. package/dist/templates/travel-concierge/agent.test.ts +110 -10
  100. package/dist/templates/travel-concierge/agent.ts +18 -9
  101. package/dist/templates/travel-concierge/shared.ts +110 -9
  102. package/dist/templates/travel-concierge/system-prompt.md +7 -1
  103. package/dist/templates/travel-concierge/tools/book_car_rental.ts +6 -3
  104. package/dist/templates/travel-concierge/tools/book_excursion.ts +6 -3
  105. package/dist/templates/travel-concierge/tools/book_hotel.ts +6 -3
  106. package/dist/templates/travel-concierge/tools/cancel_ticket.ts +6 -3
  107. package/dist/templates/travel-concierge/tools/search_car_rentals.ts +9 -5
  108. package/dist/templates/travel-concierge/tools/search_excursions.ts +9 -5
  109. package/dist/templates/travel-concierge/tools/search_flights.ts +9 -6
  110. package/dist/templates/travel-concierge/tools/search_hotels.ts +9 -5
  111. package/dist/templates/travel-concierge/tools/update_ticket.ts +6 -3
  112. package/dist/templates/web-researcher/agent.eval.test.ts +121 -0
  113. package/dist/templates/web-researcher/system-prompt.md +15 -4
  114. package/dist/{test-9kPKJI-w.mjs → test-BvyT6sAc.mjs} +24 -10
  115. package/dist/test.d.ts +29 -5
  116. package/dist/{worker-bundler-COxnqstQ.mjs → worker-bundler-CCVEDjm1.mjs} +45 -25
  117. package/dist/worker-bundler.mjs +1 -1
  118. package/dist/{workflow-D2AQf2Pl.mjs → workflow-BflATMmU.mjs} +19 -11
  119. package/package.json +5 -5
  120. package/dist/eject-C9WJyyr2.mjs +0 -116
  121. package/dist/eject.d.ts +0 -60
  122. package/dist/templates/infocom-adventure/tools/game_state_history.ts +0 -14
@@ -3,6 +3,16 @@ import { z } from "zod";
3
3
  import { runCorrectiveRag } from "../procedure.ts";
4
4
  import { recordQuestion, supportSlot } from "../shared.ts";
5
5
 
6
+ /**
7
+ * How long the whole corrective loop may run before it gives up.
8
+ *
9
+ * Under the runtime's own per-tool deadline (`TOOL_EXECUTION_TIMEOUT_MS`, 30s)
10
+ * with room for this tool's own bookkeeping, and written as a literal because
11
+ * that constant is on `@alexkroman1/aai/internal` — a subpath a template may
12
+ * not import. If the runtime's default ever moves, this is the number to move.
13
+ */
14
+ const LOOKUP_BUDGET_MS = 28_000;
15
+
6
16
  /**
7
17
  * The whole graph, as one tool.
8
18
  *
@@ -14,6 +24,33 @@ import { recordQuestion, supportSlot } from "../shared.ts";
14
24
  * The verdicts ride back with the answer rather than being swallowed. A
15
25
  * grounded-but-not-useful answer is still worth speaking, and the caller is the
16
26
  * one who should hear that it may not be the whole story.
27
+ *
28
+ * **The loop is given a BUDGET, because the runtime already has one and it is
29
+ * not this tool's.** The corrective path is up to eleven sequential model calls
30
+ * — two attempts of grade/generate/ground/use, plus a regeneration — and the
31
+ * tool executor abandons any tool at `TOOL_EXECUTION_TIMEOUT_MS` (30s), which
32
+ * an author cannot raise. Measured against a live gateway, the two-attempt path
33
+ * really does exceed it: the model was handed
34
+ * `{"error":"Tool \"answer_question\" timed out after 30000ms"}`, which tells
35
+ * it nothing it can act on and reads to a caller as the line going dead.
36
+ * `LOOKUP_BUDGET_MS` lands the overrun on the graph's OWN exit instead — the
37
+ * same "offer to look again, or log a ticket" the design already has for an
38
+ * interrupted run — so the slow path degrades into a sentence rather than into
39
+ * an internal error. It does not make the loop faster; it makes running out
40
+ * something the caller hears about.
41
+ *
42
+ * **Re-measured, and the loop is nowhere near the budget today.** Driven
43
+ * directly against the live gateway on the default model: the single-attempt
44
+ * path is 2.0-3.1s over four or five model calls, and the two-attempt
45
+ * CORRECTIVE path — nine calls, one query rewrite — is 2.8s, with each call
46
+ * 0.3-1.3s. A schema-constrained call is not the expensive kind either (572ms
47
+ * against 553ms for the same prompt without one), which is the guess anyone
48
+ * looking at this would make first. So the budget is a GUARD against a slow
49
+ * gateway rather than a bound the healthy path pushes against, and a question
50
+ * that trips a rewrite fits with room to spare. Keep it: the measurement above
51
+ * happened, and what changed is the service's latency, not the number of round
52
+ * trips. Do not go cutting round trips out of the graph on the strength of the
53
+ * old paragraph — measure first, the same way.
17
54
  */
18
55
  export default tool({
19
56
  description:
@@ -28,17 +65,29 @@ export default tool({
28
65
  try {
29
66
  // `ctx.signal` is what stops the graph on a barge-in: this loop is five
30
67
  // to nine model calls, and a caller who interrupts on the second should
31
- // not be charged for the rest.
32
- trace = await runCorrectiveRag(ctx.generate, args.question, ctx.signal);
68
+ // not be charged for the rest. The budget rides alongside it — see the
69
+ // module doc on `LOOKUP_BUDGET_MS` — and `AbortSignal.any` is what
70
+ // combines the two without any unlink bookkeeping.
71
+ trace = await runCorrectiveRag(
72
+ ctx.generate,
73
+ args.question,
74
+ AbortSignal.any([ctx.signal, AbortSignal.timeout(LOOKUP_BUDGET_MS)]),
75
+ );
33
76
  } catch (err: unknown) {
34
77
  // An INTERRUPTED lookup is not a broken one, and the difference is worth
35
- // a sentence: `ctx.signal` aborts on a barge-in AND on this call's own
36
- // timeout, and telling the model the knowledge base failed would have it
37
- // apologize for an outage that did not happen.
78
+ // a sentence: telling the model the knowledge base failed would have it
79
+ // apologize for an outage that did not happen. Which of the two signals
80
+ // fired decides WHICH sentence a barge-in means the caller is already
81
+ // talking, and a spent budget means they are still waiting.
38
82
  if (err instanceof ProcedureNotFinishedError) {
39
83
  return toolFailure(
40
- "That lookup was cut short before it finished. Offer to look again, " +
41
- "or to log a ticket with log_ticket.",
84
+ ctx.signal.aborted
85
+ ? "That lookup was cut short before it finished. Offer to look again, " +
86
+ "or to log a ticket with log_ticket."
87
+ : "That lookup ran out of time before it could be graded, so there is no " +
88
+ "answer to give. Say the check is taking too long, offer a narrower " +
89
+ "question, and offer to log a ticket with log_ticket. Do not answer " +
90
+ "from your own knowledge.",
42
91
  );
43
92
  }
44
93
  // A broken model call IS the tool's to report: the model can tell the
@@ -0,0 +1,234 @@
1
+ // Copyright 2026 the AAI authors. MIT license.
2
+ // An EVAL for a WORKFLOW APP: does the run actually do the work? Run it with
3
+ // `aai eval`.
4
+ //
5
+ // `agent.test.ts` asserts about the declaration, drives the four steps one at a
6
+ // time, and covers the WAV arithmetic as pure functions. This drives the WHOLE
7
+ // BODY — `transcribeFlow` from the top — and what it is here to check is the one
8
+ // thing no per-step spec can see: that a recording is really planned, really
9
+ // fanned out, and really stitched back into one transcript in the right order.
10
+ //
11
+ // `describeWorkflowEval` picks the providers for you and says which it picked:
12
+ //
13
+ // * with `ASSEMBLYAI_API_KEY` — a LIVE run. The `{ live: true }` case below
14
+ // downloads a real four-minute news clip, converts it with a real ffmpeg,
15
+ // and puts four real requests through the sync endpoint. That spends money
16
+ // and about a minute.
17
+ // * without one — a SCRIPTED run: the same body, the same plan, the same
18
+ // stitch, with the endpoint answered in memory.
19
+ //
20
+ // Two of the three cases are SCRIPTED IN BOTH MODES, deliberately. Their claims
21
+ // are about the PLAN and the SEAM — which window each request got, and what the
22
+ // merge does where two segments overlap — and those are facts about arithmetic
23
+ // that a live provider can neither confirm nor deny. The live case is the one
24
+ // that answers "is the transcript really of the recording".
25
+ //
26
+ // WHAT NO EVAL HERE COVERS: durability. Imported through vitest with no bundler
27
+ // in the path, a `"use workflow"` body is an ordinary async function — no
28
+ // journal, no replay, and no per-step retry, so the resume-after-segment-27
29
+ // property this template exists to demonstrate is NOT exercised here, and a
30
+ // rate-limited live run fails where a deployed one would have ridden it out.
31
+ // `aai-cli`'s `dev-workflow.scenario.test.ts` is the tier that really resumes a
32
+ // run.
33
+ import { encodeWav } from "@alexkroman1/aai/step";
34
+ import { installStubTranscribe, installStubUploads } from "@alexkroman1/aai/testing/vitest";
35
+ import { describeWorkflowEval } from "@alexkroman1/aai-runtime/eval/vitest";
36
+ import { expect } from "vitest";
37
+ import agentDef, { transcribe } from "./agent.ts";
38
+ import { TRANSCRIPT_STREAM } from "./workflows/stitch.ts";
39
+ import { SEGMENT_SECONDS } from "./workflows/wav.ts";
40
+
41
+ /** The id every case uploads under. */
42
+ const UPLOAD_ID = "upl_eval";
43
+
44
+ /** 16 kHz mono 16-bit — the format `normalize.ts` converts everything TO. */
45
+ const MONO_16K = { sampleRate: 16_000, channels: 1, bitsPerSample: 16 } as const;
46
+ /** Bytes one second of that format occupies. */
47
+ const BYTES_PER_SECOND = MONO_16K.sampleRate * MONO_16K.channels * (MONO_16K.bitsPerSample / 8);
48
+
49
+ /**
50
+ * Long enough to force a THREE-segment fan-out, which is the shape worth
51
+ * driving: one segment exercises no plan, two exercise one seam, three exercise
52
+ * a middle segment that is neither first nor last.
53
+ */
54
+ const SCRIPTED_SECONDS = 200;
55
+
56
+ /**
57
+ * A real, parseable, linear-PCM WAV of silence.
58
+ *
59
+ * Silence is fine here because the scripted cases never send it anywhere: what
60
+ * they assert is which BYTE RANGE each request was handed, and the bytes only
61
+ * have to be as long as the header says. It also takes the fast path through
62
+ * `normalizeRecording` — `parseWav` accepts it, so no ffmpeg is involved, which
63
+ * is what keeps these two cases runnable with no binary on `PATH`.
64
+ */
65
+ const SCRIPTED_WAV = encodeWav(new Uint8Array(SCRIPTED_SECONDS * BYTES_PER_SECOND), MONO_16K);
66
+
67
+ /** The public sample recording — four minutes of real speech, on a real CDN. */
68
+ const LIVE_RECORDING = "https://assembly.ai/wildfires.mp3";
69
+
70
+ /** Publish one in-memory upload store, writable because the converter needs one. */
71
+ function publish(bytes: Uint8Array, name: string, type: string) {
72
+ // `writable: true` is not optional for this template: `normalizeRecording`
73
+ // writes the CONVERTED file back as a new upload, and a read-only store would
74
+ // fail that step by name — which is the store telling the truth, and not what
75
+ // these cases are about.
76
+ return installStubUploads({ [UPLOAD_ID]: { bytes, name, type } }, { writable: true });
77
+ }
78
+
79
+ /**
80
+ * Does this request body carry a WAV header?
81
+ *
82
+ * Scanned as BYTES rather than decoded: the body is a multipart envelope around
83
+ * megabytes of audio, so `String(bytes)` is a comma-joined number list — which
84
+ * makes the assertion pass or fail for the wrong reason and prints five
85
+ * megabytes of it when it fails.
86
+ */
87
+ function carriesWavHeader(body: Uint8Array | string | undefined): boolean {
88
+ if (!(body instanceof Uint8Array)) return false;
89
+ const riff = [0x52, 0x49, 0x46, 0x46];
90
+ for (let at = 0; at <= body.length - riff.length; at++) {
91
+ if (riff.every((byte, offset) => body[at + offset] === byte)) return true;
92
+ }
93
+ return false;
94
+ }
95
+
96
+ /** The transcript chunks the run streamed, in the order they landed. */
97
+ function streamed(run: { emitted: readonly { namespace: string; chunk: unknown }[] }) {
98
+ return run.emitted
99
+ .filter((one) => one.namespace === TRANSCRIPT_STREAM)
100
+ .map((one) => one.chunk as { index: number; startMs: number; endMs: number; text: string });
101
+ }
102
+
103
+ describeWorkflowEval(agentDef, (test) => {
104
+ test("plans the fan-out from the header and gives each request its own window", async ({
105
+ app,
106
+ }) => {
107
+ // Scripted in both modes: the claim is which WINDOW each of the three
108
+ // requests was handed, and a live endpoint cannot answer that — it can only
109
+ // transcribe whatever it is sent. This is the case that catches an
110
+ // off-by-one in the plan, which otherwise produces audio the decoder
111
+ // transcribes into confident nonsense.
112
+ publish(SCRIPTED_WAV, "standup.wav", "audio/wav");
113
+ const provider = installStubTranscribe({
114
+ text: ["the first stretch", "the middle stretch", "the last stretch"],
115
+ });
116
+
117
+ const run = await app.run(transcribe, { recording: UPLOAD_ID });
118
+
119
+ expect(run.error).toBeUndefined();
120
+ expect(run.status).toBe("completed");
121
+ const output = run.output;
122
+ if (output === undefined) expect.fail("a completed run must carry an output");
123
+
124
+ // Three segments over 200 seconds at a 90-second stride, and the durations
125
+ // are the plan: each segment carries the 2-second OVERLAP that stops a cut
126
+ // landing mid-word, so they deliberately do not abut.
127
+ expect(output.segments).toBe(3);
128
+ expect(output.durationMs).toBe(SCRIPTED_SECONDS * 1000);
129
+ expect(streamed(run).map((chunk) => [chunk.index, chunk.startMs, chunk.endMs])).toEqual([
130
+ [0, 0, (SEGMENT_SECONDS + 2) * 1000],
131
+ [1, SEGMENT_SECONDS * 1000, (2 * SEGMENT_SECONDS + 2) * 1000],
132
+ [2, 2 * SEGMENT_SECONDS * 1000, SCRIPTED_SECONDS * 1000],
133
+ ]);
134
+
135
+ // One sync request per segment, each carrying a WHOLE WAV — the endpoint
136
+ // decodes every request independently, so a window with no header put back
137
+ // on it is bytes it refuses.
138
+ const sync = provider.calls.filter((call) => call.leg === "sync");
139
+ expect(sync).toHaveLength(3);
140
+ expect(sync.map((call) => carriesWavHeader(call.body))).toEqual([true, true, true]);
141
+ // The first two segments are a full stride plus the overlap; the last is the
142
+ // remainder, and is therefore much smaller. A plan that ran off the end of
143
+ // the file would show up here as a last segment the size of the others.
144
+ const sizes = sync.map((call) => call.body?.length ?? 0);
145
+ expect(sizes[0]).toBeGreaterThan((SEGMENT_SECONDS + 1) * BYTES_PER_SECOND);
146
+ expect(sizes[2]).toBeLessThan(sizes[0] ?? 0);
147
+
148
+ // Stitched in INDEX order, not completion order.
149
+ expect(output.transcript).toBe("the first stretch the middle stretch the last stretch");
150
+ // Reported before the fan-out, which is what makes a long run legible.
151
+ expect(run.reported).toContain("Split 3:20 of audio into 3 segments.");
152
+ });
153
+
154
+ test("drops the repeated words where two segments overlap", async ({ app }) => {
155
+ // Scripted in both modes for the same reason: the overlap exists so a cut
156
+ // never lands mid-word, and what makes that free is the merge dropping ONE
157
+ // copy of the repeated run. A live provider cannot be asked to produce a
158
+ // seam on demand, and this is where a regression would be invisible — a
159
+ // missed seam repeats a few words, a false one DELETES speech.
160
+ publish(SCRIPTED_WAV, "standup.wav", "audio/wav");
161
+ installStubTranscribe({
162
+ text: [
163
+ "the fire moved north through the valley overnight",
164
+ "through the valley overnight and reached the ridge by dawn",
165
+ "by dawn the crews had contained it",
166
+ ],
167
+ });
168
+
169
+ const run = await app.run(transcribe, { recording: UPLOAD_ID });
170
+
171
+ expect(run.error).toBeUndefined();
172
+ const transcript = run.output?.transcript ?? "";
173
+ expect(transcript).toBe(
174
+ "the fire moved north through the valley overnight and reached the ridge by dawn the crews had contained it",
175
+ );
176
+ // The repeated runs appear once each, which is the whole claim.
177
+ expect(transcript.match(/overnight/g)).toHaveLength(1);
178
+ expect(transcript.match(/by dawn/g)).toHaveLength(1);
179
+ });
180
+
181
+ test(
182
+ "really transcribes a real recording, end to end",
183
+ async ({ app }) => {
184
+ // LIVE ONLY, and it is the case that earns the template its name. Three
185
+ // separate things have to be real for it to pass: ffmpeg has to convert an
186
+ // MP3 into something the plan can cut, the plan has to cover the whole
187
+ // recording, and every segment's request has to come back with the words
188
+ // that are actually in it.
189
+ //
190
+ // It needs `ffmpeg` on `PATH` (or `AAI_FFMPEG_PATH`), which is what the
191
+ // template's own doc says a developer needs for anything that is not
192
+ // already a WAV. A deployed guest's image installs it.
193
+ const response = await fetch(LIVE_RECORDING);
194
+ expect(response.ok).toBe(true);
195
+ const mp3 = new Uint8Array(await response.arrayBuffer());
196
+ publish(mp3, "wildfires.mp3", "audio/mpeg");
197
+
198
+ const run = await app.run(transcribe, { recording: UPLOAD_ID });
199
+
200
+ expect(run.error).toBeUndefined();
201
+ expect(run.status).toBe("completed");
202
+ const output = run.output;
203
+ if (output === undefined) expect.fail("a completed run must carry an output");
204
+
205
+ // ffmpeg ran, and the run said so before spending minutes on it.
206
+ expect(run.reported.some((line) => line.startsWith("Converting wildfires.mp3"))).toBe(true);
207
+ // The FILENAME a reader sees is the one they uploaded, not the converted
208
+ // artifact's — `mergeTranscript` reads the ORIGINAL id for exactly this.
209
+ expect(output.source).toBe("wildfires.mp3");
210
+
211
+ // Four and a half minutes, so the plan really fanned out rather than
212
+ // sending one request.
213
+ expect(output.segments).toBeGreaterThanOrEqual(3);
214
+ expect(output.durationMs).toBeGreaterThan(250_000);
215
+
216
+ // Every segment came back with words in it, which is how "the whole
217
+ // recording was covered" is checked rather than assumed: a plan that ran
218
+ // past the end would leave a silent tail segment here.
219
+ const chunks = streamed(run);
220
+ expect(chunks).toHaveLength(output.segments);
221
+ for (const chunk of chunks) expect(chunk.text.length).toBeGreaterThan(0);
222
+
223
+ // And the transcript is of THIS recording — a news segment about smoke
224
+ // from Canadian wildfires reaching the US east coast.
225
+ expect(output.transcript).toMatch(/wildfire/i);
226
+ expect(output.transcript).toMatch(/canada/i);
227
+ expect(output.transcript).toMatch(/air quality/i);
228
+ // ~4.5 minutes of speech is several hundred words; a stitch that kept only
229
+ // the first segment would land far under this.
230
+ expect(output.words).toBeGreaterThan(400);
231
+ },
232
+ { live: true },
233
+ );
234
+ });
@@ -0,0 +1,323 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ // An EVAL: does the confirmation gate actually gate? Run it with `aai eval`.
4
+ //
5
+ // `agent.test.ts` drives each tool directly and asserts about the state it
6
+ // wrote. That is the right tier for "does `stageAction` refuse a second
7
+ // staging" — and it cannot answer the question this template exists to
8
+ // demonstrate, which is whether the gate still holds when a MODEL is the one
9
+ // picking tools. So these cases drive a real session and read the mechanism off
10
+ // the event stream: which tool ran, what the gated tool answered, and — the
11
+ // claim that matters — whether the caller's booking moved before they said yes.
12
+ //
13
+ // Two modes, per `describeEval`: with a key, a live model chooses the tools;
14
+ // without one, each case's `stubReply` scripts them. A scripted tool call really
15
+ // executes, so the flow gate really runs either way — what a stub run cannot
16
+ // tell you is whether the model would have chosen that tool.
17
+ //
18
+ // **`system-prompt.md` is applied HERE, not by `agent.ts`.** The build discovers
19
+ // the file, so an eval driving the raw default export would run this agent with
20
+ // the FRAMEWORK DEFAULT prompt — and the discipline that prompt imposes is the
21
+ // entire subject of this file. Measured against the default: the model answers
22
+ // product questions from its own knowledge and skips the tools the prompt exists
23
+ // to route it through, so a case run that way measures nothing it claims to.
24
+
25
+ import { withSystemPrompt } from "@alexkroman1/aai/manifest";
26
+ import { withDiscoveredTools } from "@alexkroman1/aai/testing";
27
+ import { type EvalSession, type EvalTurn, lastStateIn } from "@alexkroman1/aai-runtime/eval";
28
+ import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
29
+ import { expect } from "vitest";
30
+ import { z } from "zod";
31
+ import authoredAgent from "./agent.ts";
32
+ import systemPrompt from "./system-prompt.md?raw";
33
+
34
+ /**
35
+ * The def a DEPLOYED agent runs: authored, plus what `tools/` declares.
36
+ *
37
+ * The glob is written HERE rather than reached for from a shared helper for the
38
+ * reason `agent.test.ts` gives — this file ships, so it may not import anything
39
+ * outside its own template. An eval that forgot it would run an agent with NO
40
+ * tools and read as a model that refuses to act.
41
+ */
42
+ const agentDef = withSystemPrompt(
43
+ withDiscoveredTools(authoredAgent, import.meta.glob("./tools/*.ts", { eager: true })),
44
+ systemPrompt,
45
+ );
46
+
47
+ /**
48
+ * What the BROWSER is sent, as this eval reads it.
49
+ *
50
+ * Parsed rather than cast: `state.updated` carries `unknown`, so a projection
51
+ * that stopped matching FAILS naming the field, where the cast this replaced
52
+ * handed the assertions `undefined` and failed a line later on something else.
53
+ * It names only the fields asserted below, so `tripView` may grow without
54
+ * touching this.
55
+ */
56
+ const ProjectedTrip = z.object({
57
+ assistant: z.string(),
58
+ ticket: z.object({ flightId: z.string() }).nullable(),
59
+ bookings: z.array(z.unknown()),
60
+ pending: z.string().nullable(),
61
+ });
62
+
63
+ /**
64
+ * The `syncState` frames, in stream order, UP TO the first `confirm_action`.
65
+ *
66
+ * This is what makes "a staging tool mutates nothing" assertable against a live
67
+ * model: the caller's later yes is allowed to move the ticket, so a claim about
68
+ * the FINAL state is a claim about the conversation rather than about the gate.
69
+ * What the gate promises is that every frame before the confirmation shows the
70
+ * booking untouched — and `tripProjection` rides out on `state.updated` after
71
+ * every tool call, so the stream carries one per step.
72
+ */
73
+ function framesBeforeConfirm(session: EvalSession): z.infer<typeof ProjectedTrip>[] {
74
+ const views: z.infer<typeof ProjectedTrip>[] = [];
75
+ for (const event of session.events()) {
76
+ if (event.type === "tool.called" && event.toolName === "confirm_action") break;
77
+ if (event.type === "state.updated") views.push(ProjectedTrip.parse(event.state));
78
+ }
79
+ return views;
80
+ }
81
+
82
+ /**
83
+ * The latest frame — what the browser would be rendering now. `lastStateIn` is
84
+ * the SDK's reader for exactly this; the schema is why it is worth passing one.
85
+ */
86
+ const tripState = (session: EvalSession) => lastStateIn(session.events(), ProjectedTrip);
87
+
88
+ /**
89
+ * Drive a whole call, one caller line at a time, and hand back every turn.
90
+ *
91
+ * The cases below assert about the turn a MECHANISM fired in rather than about
92
+ * turn one, because how many turns a desk takes to get there is the model's
93
+ * business and it moved when the desk gate landed: the flight desk's brief says
94
+ * to search before quoting anything, so measured live this concierge now spends
95
+ * its first turn on `to_flight_assistant` and `search_flights` and reads the
96
+ * fare back before it stages. A case pinned to turn one is a flake with a
97
+ * misleading name — the same argument `retail`'s eval carries.
98
+ */
99
+ async function sayAll(session: EvalSession, lines: readonly string[]): Promise<EvalTurn[]> {
100
+ const turns: EvalTurn[] = [];
101
+ for (const line of lines) turns.push(await session.say(line));
102
+ return turns;
103
+ }
104
+
105
+ /** Every tool call of the call so far, flattened, in order. */
106
+ const callsIn = (turns: readonly EvalTurn[]) => turns.flatMap((turn) => turn.toolCalls);
107
+
108
+ /** The turn a named tool STAGED something in — the call that answered with the
109
+ * read-back rather than with a gate's refusal. */
110
+ const stagingTurn = (turns: readonly EvalTurn[], tool: string) =>
111
+ turns.find((turn) =>
112
+ turn.toolCalls.some(
113
+ (call) => call.name === tool && /awaitingConfirmation/.test(call.result ?? ""),
114
+ ),
115
+ );
116
+
117
+ describeEval(agentDef, (test) => {
118
+ test(
119
+ "a sensitive tool stages the change and moves nothing",
120
+ async ({ session }) => {
121
+ // Three lines, each of them the same REQUEST and none of them an answer to
122
+ // a read-back: what is asserted below is that the turn which staged did
123
+ // not also apply, so a line the model could read as consent ("correct",
124
+ // "that's right") would be measuring the caller instead of the desk.
125
+ const turns = await sayAll(session, [
126
+ "Move my ticket to flight LX52, the Wednesday one.",
127
+ "I want the Wednesday LX52 instead of the flight I'm on now.",
128
+ "Put me on LX52 on Wednesday, please.",
129
+ ]);
130
+
131
+ const staging = stagingTurn(turns, "update_ticket");
132
+ const attempts = callsIn(turns).filter((call) => call.name === "update_ticket");
133
+ // Named with the whole call, tools AND text: "expected undefined to be
134
+ // defined" says nothing about a desk that talked its way through three
135
+ // turns without staging, which is exactly the failure this case caught
136
+ // while the flight desk's brief had the read-back before the staging.
137
+ expect(
138
+ staging,
139
+ turns
140
+ .map(
141
+ (turn, i) =>
142
+ `turn ${i + 1}: [${turn.toolCalls.map((c) => c.name).join(", ")}] said: ${turn.text}`,
143
+ )
144
+ .join("\n"),
145
+ ).toBeDefined();
146
+ const staged = staging?.toolCalls.find((call) => call.name === "update_ticket");
147
+ // The tool answered with the read-back rather than with a receipt.
148
+ expect(staged?.result).toMatch(/awaitingConfirmation/);
149
+ // Any attempt that did NOT stage is the DESK GATE refusing:
150
+ // `update_ticket` belongs to the flight desk, so a model reaching for it
151
+ // before `to_flight_assistant` is told so and recovers inside the turn.
152
+ // That refusal is a legal step and it is asserted rather than tolerated —
153
+ // its own message is what pointed the model at the way in.
154
+ for (const attempt of attempts.filter((call) => call !== staged)) {
155
+ expect(attempt.result).toMatch(/to_flight_assistant|Not available yet/);
156
+ }
157
+ // The desk asks; it does not decide. A `confirm_action` AFTER the staging
158
+ // in the same turn is the agent confirming on its own initiative, which
159
+ // the system prompt forbids in as many words.
160
+ //
161
+ // After, not anywhere in the turn: measured live, a model that hears a
162
+ // second request as a yes reaches for `confirm_action` BEFORE it has
163
+ // staged anything, the gate refuses it (the case above is where that
164
+ // refusal is the subject), and it then stages properly. That is a wasted
165
+ // step rather than an unasked-for change, and folding the two together
166
+ // would fail this case for the behaviour the next one proves is safe.
167
+ // Narrowed first: `indexOf` takes a value, and `staged` is optional — the
168
+ // rewrite Biome offers for `findIndex` over an identity is UNSAFE for
169
+ // exactly that reason, and the assertion above is what makes an absent
170
+ // staging call a failure rather than a slice from 0.
171
+ const stagedAt = staged === undefined ? -1 : (staging?.toolCalls.indexOf(staged) ?? -1);
172
+ expect(staging?.toolCalls.slice(stagedAt + 1).map((call) => call.name) ?? []).not.toContain(
173
+ "confirm_action",
174
+ );
175
+
176
+ const views = framesBeforeConfirm(session);
177
+ const waiting = views.filter((view) => view.pending !== null);
178
+ expect(waiting.length, "no frame ever showed a staged change").toBeGreaterThan(0);
179
+ expect(waiting.at(-1)?.pending).toMatch(/LX52/);
180
+ // THE claim: through every frame up to the confirmation, the ticket is
181
+ // still the one the caller phoned in on. A staging tool that mutated
182
+ // would satisfy every assertion above this one.
183
+ for (const view of views) expect(view.ticket?.flightId).toBe("LX40");
184
+ },
185
+ {
186
+ stubReply: [
187
+ { tool: "to_flight_assistant", args: { request: "move my ticket to LX52" } },
188
+ { tool: "update_ticket", args: { flightId: "LX52" } },
189
+ "That would move you to LX52 on Wednesday, five ninety. Shall I go ahead?",
190
+ ],
191
+ },
192
+ );
193
+
194
+ test(
195
+ "confirm_action is refused while nothing is waiting",
196
+ async ({ session, mode }) => {
197
+ const turn = await session.say("Yes, I confirm — go ahead and do it.");
198
+
199
+ const attempts = turn.toolCalls.filter((call) => call.name === "confirm_action");
200
+ // In stub mode the script FORCES the call, so the gate is really
201
+ // exercised; a live model that declines to call it at all has honoured
202
+ // the same rule one level earlier, which is why the count is asserted
203
+ // only where it is determined.
204
+ if (mode === "stub") expect(attempts).toHaveLength(1);
205
+ for (const attempt of attempts) {
206
+ // The refusal names the position and quotes the state's instruction —
207
+ // that is what the model recovers from, and a gate that ran the body
208
+ // and then apologized would not carry it.
209
+ expect(attempt.result).toMatch(/Not available yet/);
210
+ expect(attempt.result).toMatch(/browsing/);
211
+ }
212
+ expect(tripState(session)?.pending ?? null).toBeNull();
213
+ expect(tripState(session)?.bookings ?? []).toHaveLength(0);
214
+ },
215
+ { stubReply: [{ tool: "confirm_action" }, "Nothing is waiting for your yes just now."] },
216
+ );
217
+
218
+ test(
219
+ "the caller's yes is the only thing that moves the ticket",
220
+ async ({ session }) => {
221
+ // Four lines, three of them a yes: which turn the desk stages in and which
222
+ // it applies in is its own business — the flight desk's brief has it
223
+ // search first — and saying yes repeatedly is what makes "once each"
224
+ // below a claim about the MECHANISM rather than about the model's pacing.
225
+ await sayAll(session, [
226
+ "Move my ticket to flight LX52, the Wednesday one.",
227
+ "Correct — LX52 on Wednesday. Please move my ticket to it.",
228
+ "Yes, that's right — go ahead and change it.",
229
+ "Yes. Confirm it, please.",
230
+ ]);
231
+
232
+ // The two gated tools' calls, in order.
233
+ const gated = session
234
+ .toolCalls()
235
+ .filter((call) => ["update_ticket", "confirm_action"].includes(call.name));
236
+ // "Did something" is read POSITIVELY — a staging answers
237
+ // `awaitingConfirmation`, an apply answers `applied` — so the complement
238
+ // is a claim rather than a definition.
239
+ const effective = gated.filter((call) =>
240
+ /awaitingConfirmation|"applied"/.test(call.result ?? ""),
241
+ );
242
+ // Staged first, applied second, once each. Reversed — or a confirm with no
243
+ // stage — is the regression this template's whole shape exists to prevent.
244
+ expect(effective.map((call) => call.name)).toEqual(["update_ticket", "confirm_action"]);
245
+ // Everything else has to be a GATE refusing, and nothing else: the desk
246
+ // gate turns away an `update_ticket` issued before
247
+ // `to_flight_assistant`, and the confirmation gate turns away a
248
+ // `confirm_action` issued before anything is staged (the case above is
249
+ // where that is the subject). Both are steps a live model really takes,
250
+ // neither moved the ticket, and neither is what this case is about.
251
+ for (const call of gated.filter((c) => !effective.includes(c))) {
252
+ expect(call.result, `${call.name} neither acted nor was refused`).toMatch(
253
+ /Not available yet|belongs to the/,
254
+ );
255
+ }
256
+
257
+ const applied = effective.at(-1);
258
+ expect(applied?.result).toMatch(/LX52/);
259
+
260
+ const view = tripState(session);
261
+ expect(view?.ticket?.flightId).toBe("LX52");
262
+ expect(view?.pending ?? null).toBeNull();
263
+ },
264
+ {
265
+ stubReply: [
266
+ // The delegation is in the script because the DESK GATE is real: the
267
+ // flight desk's tools refuse until `to_flight_assistant` has run, so a
268
+ // script that skipped it would exercise that refusal rather than this
269
+ // case's subject.
270
+ { tool: "to_flight_assistant", args: { request: "move my ticket to LX52" } },
271
+ { tool: "update_ticket", args: { flightId: "LX52" } },
272
+ "That's LX52 on Wednesday at five ninety. Confirm it?",
273
+ { tool: "confirm_action" },
274
+ "Done — you're on LX52 on Wednesday.",
275
+ ],
276
+ },
277
+ );
278
+
279
+ test(
280
+ "a hotel request goes to the hotel desk rather than being answered at the front",
281
+ async ({ session }) => {
282
+ const turn = await session.say(
283
+ "Leave the flight for now — I need a hotel in Boston for three nights, near the water.",
284
+ );
285
+
286
+ // The INDEX as well as the call, because what came before it is asserted
287
+ // below and `indexOf` on a possibly-undefined find is worse than both.
288
+ const handoffAt = turn.toolCalls.findIndex((call) => call.name === "to_hotel_assistant");
289
+ const handoff = turn.toolCalls[handoffAt];
290
+ expect(
291
+ handoff,
292
+ `tools called: [${turn.toolCalls.map((c) => c.name).join(", ")}]; said: ${turn.text}`,
293
+ ).toBeDefined();
294
+ // The brief IS the tool result, which is the whole port of their
295
+ // per-assistant prompt onto a session whose prompt is fixed at connect.
296
+ expect(handoff?.result).toMatch(/hotel desk/);
297
+ expect(handoff?.args.request).toBeTruthy();
298
+ // And the stack moved, which is what the caller's sidebar renders and what
299
+ // `complete_or_escalate` will pop.
300
+ expect(tripState(session)?.assistant).toBe("hotel");
301
+ // Anything the model reached for BEFORE delegating was refused, and so
302
+ // told nothing about Boston hotels: the desk gate is what makes the
303
+ // handoff above unavoidable rather than requested. This is the assertion
304
+ // that would have caught the old behaviour even if the model had happened
305
+ // to delegate afterwards.
306
+ const before = turn.toolCalls.slice(0, handoffAt);
307
+ for (const call of before.filter((c) => c.name.startsWith("search_"))) {
308
+ expect(call.result).toMatch(/belongs to the/);
309
+ }
310
+ },
311
+ // Live only: which desk the model hands the call to is exactly the judgement
312
+ // a script would be making on its behalf.
313
+ //
314
+ // This case is what turned the narrowing into a MECHANISM. It was written
315
+ // when the delegation was asked for in the prompt and nothing enforced it —
316
+ // one session has one tool list — and it measured the asking losing 0 of 5:
317
+ // the model called `search_hotels` from the concierge desk every run. Every
318
+ // desk tool now checks the stack (`requireDesk` in `shared.ts`), so what
319
+ // this measures is that the refusal really does route the model through the
320
+ // desk, inside one turn.
321
+ { live: true },
322
+ );
323
+ });