@alexkroman1/aai-cli 7.0.0 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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-Me06wgeF.mjs} +2 -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-BvcctCn2.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
@@ -0,0 +1,474 @@
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` covers everything this desk DECIDES — every ffmpeg argv, both
6
+ // analysis parsers, and where `planSegments` cuts — as pure functions, because
7
+ // `workflows/media.ts` exists so that it can. What it cannot do is spawn, so
8
+ // the one thing it never sees is whether ffmpeg AGREES: whether the argv this
9
+ // desk builds really produces the pauses it plans against, and whether a plan
10
+ // derived from those pauses really addresses audio that exists.
11
+ //
12
+ // That is what this file is for. **Every case here runs a real ffmpeg** — five
13
+ // invocations per run, over audio built sample by sample so the pauses in it
14
+ // are known — and then checks where the desk cut. The twelve-byte planning bug
15
+ // `durationSeconds` documents was found exactly this way, and it is the class of
16
+ // bug that cannot be found any other way.
17
+ //
18
+ // It therefore needs `ffmpeg` and `ffprobe` on `PATH` (or `AAI_FFMPEG_PATH` /
19
+ // `AAI_FFPROBE_PATH`), which is what the template's own doc says a developer
20
+ // needs for anything that is not already a WAV. Measured against ffmpeg 7.1.5.
21
+ // A missing binary fails these cases with the SDK's installable message, which
22
+ // is the same thing `aai dev` does on a laptop without one.
23
+ //
24
+ // `describeWorkflowEval` picks the providers for you and says which it picked:
25
+ //
26
+ // * with `ASSEMBLYAI_API_KEY` — a LIVE run. The `{ live: true }` case below
27
+ // downloads a real four-and-a-half-minute recording, levels it with a real
28
+ // ffmpeg, fans three real requests out at its real pauses, and asks a real
29
+ // model to audit what came back. That spends money and about a minute.
30
+ // * without one — a SCRIPTED run: the same ffmpeg, the same plan, the same
31
+ // seams, with the transcription endpoint and the model answered in memory.
32
+ //
33
+ // Three of the four cases are SCRIPTED IN BOTH MODES, deliberately. Their claims
34
+ // are about WHERE THE DESK CUT and what it did at the seam, and a live provider
35
+ // can neither confirm nor deny either — it can only transcribe whatever it is
36
+ // sent. The live case is the one that answers "is the transcript really of the
37
+ // recording, and is the audit really of the transcript".
38
+ //
39
+ // TWO THINGS ARE FAKED IN EVERY MODE, and both are harness limits rather than
40
+ // choices:
41
+ //
42
+ // * the UPLOAD STORE, because it is this app's own storage (a row and a blob)
43
+ // rather than a provider, and an eval has neither. `{ writable: true }` is
44
+ // not optional here: this desk WRITES two files.
45
+ // * the VOICE — see `speakATone` below, which carries both reasons.
46
+ //
47
+ // WHAT NO EVAL HERE COVERS: durability. Imported through vitest with no bundler
48
+ // in the path, a `"use workflow"` body is an ordinary async function — no
49
+ // journal, no replay, and no per-step retry, so the resume-after-segment-27
50
+ // property is NOT exercised and a rate-limited live run fails where a deployed
51
+ // one would have ridden it out. `aai-cli`'s `dev-workflow.scenario.test.ts` is
52
+ // the tier that really resumes a run.
53
+ import { spawnSync } from "node:child_process";
54
+ import { encodeWav } from "@alexkroman1/aai/step";
55
+ import { installStubTranscribe, installStubUploads } from "@alexkroman1/aai/testing/vitest";
56
+ import { describeWorkflowEval } from "@alexkroman1/aai-runtime/eval/vitest";
57
+ import { describe, expect, test } from "vitest";
58
+ import agentDef, { audit } from "./agent.ts";
59
+ import { ANALYSIS_FORMAT, BYTES_PER_SECOND, MAX_SEGMENT_SECONDS } from "./workflows/media.ts";
60
+
61
+ /**
62
+ * Both binaries, or neither — every case here decodes, and three of the four
63
+ * read `ffprobe` back to check what the desk did.
64
+ */
65
+ const HAVE_FFMPEG = ["ffmpeg", "ffprobe"].every(
66
+ (bin) => spawnSync(bin, ["-version"], { stdio: "ignore" }).status === 0,
67
+ );
68
+
69
+ const HOW_TO =
70
+ "Install ffmpeg (`apt-get install ffmpeg`, `brew install ffmpeg`) or point\n" +
71
+ "AAI_FFMPEG_PATH / AAI_FFPROBE_PATH at binaries. A deployed guest always has them.";
72
+
73
+ // Biome's `noSkippedTests` flags the `describe.skip(…)` CALL form, so the gated
74
+ // suite references it instead — exactly as `aai/host/ffmpeg.scenario.test.ts`
75
+ // and `_pg-test-utils.ts` do.
76
+ const skipSuite = describe.skip;
77
+
78
+ /**
79
+ * `describeWorkflowEval`, gated on a real ffmpeg — and the skip ANNOUNCES itself.
80
+ *
81
+ * This file used to call `describeWorkflowEval` directly, on the reasoning (still
82
+ * in the header above) that "a missing binary fails these cases with the SDK's
83
+ * installable message, which is the same thing `aai dev` does on a laptop without
84
+ * one". That is right for a laptop and wrong for a RUNNER: `check.yml` installs
85
+ * ffmpeg on the Linux leg only — deliberately, because the scenario suite it was
86
+ * added for is OS-independent and brew costs minutes — so when `check:eval` joined
87
+ * that same job, the macOS leg went permanently red on a binary nobody had decided
88
+ * it should have.
89
+ *
90
+ * So this follows `describeWithFfmpeg` (`aai/host/ffmpeg.scenario.test.ts`), which
91
+ * follows `describeWithPg`: skip loudly, and let **`AAI_REQUIRE_FFMPEG`** — which
92
+ * the Linux leg's install step sets — turn the skip into a hard failure, so a
93
+ * broken install step cannot read as a green run. The coverage is unchanged on
94
+ * every machine that has the binary, which includes all of CI's Linux legs.
95
+ */
96
+ const describeWorkflowEvalWithFfmpeg: typeof describeWorkflowEval = (agent, define, options) => {
97
+ if (HAVE_FFMPEG) {
98
+ describeWorkflowEval(agent, define, options);
99
+ return;
100
+ }
101
+ if ((process.env.AAI_REQUIRE_FFMPEG ?? "") !== "") {
102
+ throw new Error(`AAI_REQUIRE_FFMPEG is set but no ffmpeg was found.\n${HOW_TO}`);
103
+ }
104
+ console.warn(`\n[skipped: no ffmpeg] ${agent.name} eval not run.\n${HOW_TO}\n`);
105
+ skipSuite(agent.name, () => {
106
+ // Named `test`, not aliased: Biome's `noMisplacedAssertion` matches the
107
+ // CALLEE IDENTIFIER, so an `expect` inside a `vitestTest(…)` is an error.
108
+ // Same trap `describeEval`'s own signature documents.
109
+ test("needs a real ffmpeg", () => {
110
+ expect(HAVE_FFMPEG).toBe(true);
111
+ });
112
+ });
113
+ };
114
+
115
+ /** The id every case uploads the recording under. */
116
+ const UPLOAD_ID = "upl_eval";
117
+
118
+ /** The public sample recording — four and a half minutes of real speech. */
119
+ const LIVE_RECORDING = "https://assembly.ai/wildfires.mp3";
120
+
121
+ /** Long enough to force a cut: the desk's cap is 110 seconds. */
122
+ const OVER_THE_CAP_SECONDS = 130;
123
+
124
+ /** What the model is scripted to answer with. */
125
+ const REPLY = {
126
+ headline: "Renewal call with Northwind",
127
+ risks: ["Nobody owns the migration date"],
128
+ actions: ["Ana to send revised pricing by Friday"],
129
+ spoken:
130
+ "The renewal is close to done, but the migration date still has nobody's name on it. " +
131
+ "Ana is sending revised pricing before the end of the week.",
132
+ };
133
+
134
+ /**
135
+ * One span of a synthetic recording: `loud` is speech, quiet is a pause.
136
+ *
137
+ * A 220 Hz sine at about 40% of full scale, which is ~27 dB above the desk's
138
+ * −35 dB silence floor, so `silencedetect` reads it as speech; a run of zero
139
+ * samples is what it reads as a pause. Built sample by sample rather than
140
+ * captured, because the POINT is that the pauses are at second 60 and second 62
141
+ * and nowhere else — which is the only way "did it cut in the pause" has a
142
+ * right answer.
143
+ */
144
+ type Span = { seconds: number; loud: boolean };
145
+
146
+ /** A 16 kHz mono 16-bit WAV of the spans, ready to hand ffmpeg. */
147
+ function recording(spans: readonly Span[]): Uint8Array {
148
+ const samples = spans.reduce((total, span) => total + Math.round(span.seconds * 16_000), 0);
149
+ const pcm = new Uint8Array(samples * 2);
150
+ const view = new DataView(pcm.buffer);
151
+ let at = 0;
152
+ for (const span of spans) {
153
+ const count = Math.round(span.seconds * 16_000);
154
+ for (let i = 0; i < count; i += 1, at += 1) {
155
+ // `setInt16(…, true)` rather than an `Int16Array`: little-endian is what
156
+ // `s16le` means, and a typed array would be whatever the platform is.
157
+ const value = span.loud
158
+ ? Math.round(13_000 * Math.sin((2 * Math.PI * 220 * at) / 16_000))
159
+ : 0;
160
+ view.setInt16(at * 2, value, true);
161
+ }
162
+ }
163
+ return encodeWav(pcm, ANALYSIS_FORMAT);
164
+ }
165
+
166
+ /**
167
+ * The synthesizer every case here runs on, live included — and it answers with a
168
+ * TONE rather than silence.
169
+ *
170
+ * Two reasons, and the second is a measurement:
171
+ *
172
+ * * `stepSpeak` reads a PUBLISHED synthesizer, the eval engine publishes none
173
+ * by default, and `@alexkroman1/aai-runtime` exports no real one to pass —
174
+ * so there is no arrangement in which the voice is real. The live case
175
+ * therefore claims nothing about audible audio.
176
+ * * it may not answer with SILENCE, which is what `installStubSpeech` does.
177
+ * `libmp3lame` ABORTS on pure digital silence — `Assertion failed:
178
+ * (el >= 0), psymodel.c:576`, exit 134 on ffmpeg 7.1.5 — so the mastering
179
+ * pass this template exists to demonstrate would fail on the fixture rather
180
+ * than on the code, and the failure would read as a broken template.
181
+ */
182
+ const speakATone = (request: { text: string; sampleRate: number }): Promise<Uint8Array> => {
183
+ // A second of tone per twenty characters, so a longer script really is a
184
+ // longer file and `audioDurationMs` is a number that means something.
185
+ const samples = Math.max(1, Math.round((request.text.length / 20) * request.sampleRate));
186
+ const pcm = new Uint8Array(samples * 2);
187
+ const view = new DataView(pcm.buffer);
188
+ for (let at = 0; at < samples; at += 1) {
189
+ view.setInt16(
190
+ at * 2,
191
+ Math.round(9000 * Math.sin((2 * Math.PI * 180 * at) / request.sampleRate)),
192
+ true,
193
+ );
194
+ }
195
+ return Promise.resolve(pcm);
196
+ };
197
+
198
+ /** Publish this app's own store, writable because this desk writes two files. */
199
+ function publish(bytes: Uint8Array, name: string, type: string) {
200
+ return installStubUploads({ [UPLOAD_ID]: { bytes, name, type } }, { writable: true });
201
+ }
202
+
203
+ /**
204
+ * Answer the sync transcription endpoint and the model in memory.
205
+ *
206
+ * ONE fake, because publishing a `stepFetch` REPLACES — a flow that transcribes
207
+ * AND calls a model cannot install two, which is what `otherwise` is for. The
208
+ * transcription half is the SDK's own fake rather than this file's hand-typed
209
+ * wire: it routes off the SDK's endpoint constants, so a case cannot pass
210
+ * because the fake and the step agree on a typo.
211
+ */
212
+ function scriptProvider(text: readonly string[]) {
213
+ return installStubTranscribe({
214
+ text,
215
+ otherwise: (request) =>
216
+ request.url.includes("/chat/completions")
217
+ ? { body: { choices: [{ message: { content: JSON.stringify(REPLY) } }] } }
218
+ : undefined,
219
+ });
220
+ }
221
+
222
+ describeWorkflowEvalWithFfmpeg(
223
+ agentDef,
224
+ (test) => {
225
+ test("cuts where ffmpeg heard a PAUSE, and reads the seam as a paragraph", async ({ app }) => {
226
+ // The case this template exists for, and the one that cannot be written
227
+ // without a real decoder: a 2-second pause at second 60 of a 130-second
228
+ // recording is the only place a cut may land, and where it landed decides
229
+ // both the byte ranges and how the transcript reads at the seam.
230
+ const uploads = publish(
231
+ recording([
232
+ { seconds: 60, loud: true },
233
+ { seconds: 2, loud: false },
234
+ { seconds: 68, loud: true },
235
+ ]),
236
+ "call.wav",
237
+ "audio/wav",
238
+ );
239
+ const provider = scriptProvider([
240
+ "so that is the renewal settled",
241
+ "on the migration date nobody has committed yet",
242
+ ]);
243
+
244
+ const run = await app.run(audit, { recording: UPLOAD_ID });
245
+
246
+ // The error FIRST, so a failed run names its own reason instead of
247
+ // reporting "expected 'failed' to be 'completed'".
248
+ expect(run.error).toBeUndefined();
249
+ expect(run.status).toBe("completed");
250
+ const output = run.output;
251
+ if (output === undefined) expect.fail("a completed run must carry an output");
252
+
253
+ // ffmpeg really ran: this is ffprobe's reading of the file, and the
254
+ // duration is the normalized PCM's own byte count rather than anything a
255
+ // container claimed.
256
+ expect(output.codec).toBe("pcm_s16le");
257
+ expect(output.durationMs).toBe(OVER_THE_CAP_SECONDS * 1000);
258
+
259
+ // TWO segments, and NEITHER is a blind cut — which is the whole claim.
260
+ // The desk found the pause, cut in the middle of it, and therefore owes
261
+ // no overlap, no seam matching and no de-duplication.
262
+ expect(output.segments).toBe(2);
263
+ expect(output.blindCuts).toBe(0);
264
+
265
+ // And the cut is at the pause's MIDPOINT, second 61 — not at 110, which
266
+ // is where a desk with no decoder would have had to cut.
267
+ const sync = provider.calls.filter((call) => call.leg === "sync");
268
+ expect(sync).toHaveLength(2);
269
+ const first = sync[0]?.body?.length ?? 0;
270
+ const second = sync[1]?.body?.length ?? 0;
271
+ // 61 seconds of audio plus a WAV header inside a multipart envelope.
272
+ expect(first).toBeGreaterThan(61 * BYTES_PER_SECOND);
273
+ expect(first).toBeLessThan(61 * BYTES_PER_SECOND + 2000);
274
+ // 69 seconds — and together they are the whole recording, exactly once.
275
+ expect(second).toBeGreaterThan(69 * BYTES_PER_SECOND);
276
+ expect(second).toBeLessThan(69 * BYTES_PER_SECOND + 2000);
277
+
278
+ // A cut placed in a pause is a turn boundary, so the seam is a PARAGRAPH
279
+ // break. This is the one place `cutInSpeech` changes an output rather
280
+ // than a report, and the assertion that pairs with the next case.
281
+ expect(output.transcript).toBe(
282
+ "so that is the renewal settled\n\non the migration date nobody has committed yet",
283
+ );
284
+
285
+ // 2 seconds of pause in 130 is 98% speech, measured by ffmpeg rather
286
+ // than assumed, and the loudness is a real measurement of real audio.
287
+ expect(output.speechPercent).toBe(98);
288
+ expect(output.loudnessBefore).toBeLessThan(0);
289
+ expect(output.loudnessBefore).toBeGreaterThan(-70);
290
+
291
+ // The narration says what it found, which is what makes a long run
292
+ // legible — and names the ONE pause it cut in.
293
+ expect(run.reported[0]).toMatch(/^Reading call\.wav \(/);
294
+ expect(run.reported).toContain("Levelling 2:10 of pcm_s16le to 16 kHz mono.");
295
+ expect(run.reported).toContain(
296
+ `Levelled 2:10 from ${output.loudnessBefore} LUFS, 98% speech across 1 pause.`,
297
+ );
298
+ expect(run.reported).toContain("Transcribing 0:00–1:01.");
299
+ expect(run.reported).toContain("Transcribing 1:01–2:10.");
300
+
301
+ // Two files written: the levelled PCM everything downstream addresses,
302
+ // and the mastered audit. Nothing durable was asked for.
303
+ expect(uploads.writes.map((one) => one.name)).toEqual(["call.pcm", "audit.mp3"]);
304
+ expect(run.slept).toEqual([]);
305
+ });
306
+
307
+ test("an unbroken monologue gets a BLIND cut, and says so", async ({ app }) => {
308
+ // The case the desk cannot serve, driven rather than argued: 130 seconds
309
+ // of unbroken speech has no pause to cut in, so it gets the arithmetic
310
+ // cut `transcription-workflow` always makes — at exactly the cap. Hiding
311
+ // that would leave a reader looking at a mangled word with no
312
+ // explanation, which is why `cutInSpeech` is counted and rendered.
313
+ publish(
314
+ recording([{ seconds: OVER_THE_CAP_SECONDS, loud: true }]),
315
+ "monologue.wav",
316
+ "audio/wav",
317
+ );
318
+ const provider = scriptProvider(["and then the second thing we agreed", "was the pricing"]);
319
+
320
+ const run = await app.run(audit, { recording: UPLOAD_ID });
321
+
322
+ expect(run.error).toBeUndefined();
323
+ const output = run.output;
324
+ if (output === undefined) expect.fail("a completed run must carry an output");
325
+
326
+ expect(output.segments).toBe(2);
327
+ // ONE blind cut — the first segment's end. The recording's own end is
328
+ // not a cut this planner invented, so it is not counted.
329
+ expect(output.blindCuts).toBe(1);
330
+ // 100% speech, because ffmpeg found no pause at all.
331
+ expect(output.speechPercent).toBe(100);
332
+ expect(run.reported).toContain(
333
+ `Levelled 2:10 from ${output.loudnessBefore} LUFS, 100% speech across 0 pauses.`,
334
+ );
335
+
336
+ // The cut is at the cap exactly, which is what "no candidate in range"
337
+ // means — and the first request is therefore 110 seconds of audio.
338
+ expect(run.reported).toContain("Transcribing 0:00–1:50.");
339
+ const sync = provider.calls.filter((call) => call.leg === "sync");
340
+ const first = sync[0]?.body?.length ?? 0;
341
+ expect(first).toBeGreaterThan(MAX_SEGMENT_SECONDS * BYTES_PER_SECOND);
342
+ expect(first).toBeLessThan(MAX_SEGMENT_SECONDS * BYTES_PER_SECOND + 2000);
343
+
344
+ // A blind cut landed MID-SENTENCE, so the seam is a space rather than a
345
+ // paragraph break. The pair with the previous case is the point: the same
346
+ // recording length, the same two segments, a different reading.
347
+ expect(output.transcript).toBe("and then the second thing we agreed was the pricing");
348
+ });
349
+
350
+ test("the audit comes back as a real MP3 that the run's output NAMES", async ({ app }) => {
351
+ // The way OUT, which is the second thing having a decoder buys: a step
352
+ // speaks, ffmpeg masters what it said, and the run's output carries an
353
+ // upload ID rather than the bytes. A short recording, because what this
354
+ // case is about is the last invocation rather than the fan-out.
355
+ const uploads = publish(recording([{ seconds: 5, loud: true }]), "quick.wav", "audio/wav");
356
+ scriptProvider(["short and to the point"]);
357
+
358
+ const run = await app.run(audit, { recording: UPLOAD_ID });
359
+
360
+ expect(run.error).toBeUndefined();
361
+ const output = run.output;
362
+ if (output === undefined) expect.fail("a completed run must carry an output");
363
+ // Under the cap, so no cut at all.
364
+ expect(output.segments).toBe(1);
365
+ expect(output.blindCuts).toBe(0);
366
+
367
+ // TWO writes, in this order, and the output names the SECOND. A run that
368
+ // returned the levelled PCM's id would be handing a page a headerless
369
+ // file nothing can play.
370
+ expect(uploads.writes).toHaveLength(2);
371
+ const [pcm, mp3] = uploads.writes;
372
+ if (pcm === undefined || mp3 === undefined) expect.fail("both files must be written");
373
+ // Named after the recording, and typed HONESTLY: raw samples with no
374
+ // container, so not `audio/wav` and not `audio/L16` either.
375
+ expect(pcm.name).toBe("quick.pcm");
376
+ expect(pcm.type).toBe("application/octet-stream");
377
+ expect(pcm.bytes.byteLength).toBe(5 * BYTES_PER_SECOND);
378
+ expect(output.audio).toBe(mp3.id);
379
+ expect(mp3.name).toBe("audit.mp3");
380
+ // Typed for the browser: the byte route serves what it was given, and
381
+ // nothing plays inline a file handed over as octet-stream.
382
+ expect(mp3.type).toBe("audio/mpeg");
383
+
384
+ // And it is really an MP3, which is a claim about ffmpeg's output rather
385
+ // than about our naming: an MPEG audio frame begins with eleven set bits,
386
+ // or the file opens with an ID3 tag.
387
+ const head = mp3.bytes.subarray(0, 3);
388
+ const framed = head[0] === 0xff && ((head[1] ?? 0) & 0xe0) === 0xe0;
389
+ const tagged = String.fromCharCode(...head) === "ID3";
390
+ expect(framed || tagged).toBe(true);
391
+ expect(output.audioBytes).toBe(mp3.bytes.byteLength);
392
+ expect(output.audioBytes).toBeGreaterThan(0);
393
+ // The compression is the reason the pass exists: the WAV that went in is
394
+ // 2 bytes per sample at 24 kHz, and the MP3 is a fraction of it.
395
+ const wavBytes = 44 + (output.audioDurationMs / 1000) * 24_000 * 2;
396
+ expect(output.audioBytes).toBeLessThan(wavBytes / 4);
397
+ // The run says both numbers, which is what makes the trade visible.
398
+ expect(run.reported.at(-1)).toMatch(
399
+ /^Recorded a \d+s audit in \w+'s voice — .* of MP3, from .* of WAV\.$/,
400
+ );
401
+
402
+ // The model really read the transcript, and the audit really reached the
403
+ // output — including the empty-array case being an ANSWER.
404
+ expect(output.headline).toBe(REPLY.headline);
405
+ expect(output.risks).toEqual(REPLY.risks);
406
+ expect(output.spoken).toBe(REPLY.spoken);
407
+ // The RUN's own clock, from two journaled steps rather than a body-level
408
+ // `Date.now()` a replay would re-read.
409
+ expect(output.elapsedMs).toBeGreaterThanOrEqual(0);
410
+ });
411
+
412
+ test(
413
+ "really levels, cuts and audits a real call recording",
414
+ async ({ app }) => {
415
+ // LIVE ONLY, and the case that earns the template its name. Four things
416
+ // have to be real: ffmpeg has to decode an MP3 nobody here made, the
417
+ // pauses it finds have to cover the whole recording, every segment's
418
+ // request has to come back with the words that are in it, and the model
419
+ // has to audit what came back.
420
+ const response = await fetch(LIVE_RECORDING);
421
+ expect(response.ok).toBe(true);
422
+ const mp3 = new Uint8Array(await response.arrayBuffer());
423
+ const uploads = publish(mp3, "wildfires.mp3", "audio/mpeg");
424
+
425
+ const run = await app.run(audit, { recording: UPLOAD_ID });
426
+
427
+ expect(run.error).toBeUndefined();
428
+ expect(run.status).toBe("completed");
429
+ const output = run.output;
430
+ if (output === undefined) expect.fail("a completed run must carry an output");
431
+
432
+ // ffprobe read the container it really was, and the FILENAME a reader
433
+ // sees is the one they uploaded rather than the converted artifact's.
434
+ expect(output.codec).toBe("mp3");
435
+ expect(output.source).toBe("wildfires.mp3");
436
+ expect(output.durationMs).toBeGreaterThan(250_000);
437
+
438
+ // Four and a half minutes against a 110-second cap is at least three
439
+ // segments, and real speech has pauses — so the cuts should be in them.
440
+ expect(output.segments).toBeGreaterThanOrEqual(3);
441
+ expect(output.blindCuts).toBe(0);
442
+ // A real recording is mostly speech and not all of it.
443
+ expect(output.speechPercent).toBeGreaterThan(50);
444
+ expect(output.speechPercent).toBeLessThan(100);
445
+ expect(output.loudnessBefore).toBeLessThan(0);
446
+
447
+ // Every segment came back with words, which is how "the whole recording
448
+ // was covered" is checked rather than assumed: a plan that ran past the
449
+ // end would leave a silent tail segment and a short transcript.
450
+ expect(output.words).toBeGreaterThan(400);
451
+ expect(output.transcript).toMatch(/wildfire/i);
452
+ expect(output.transcript).toMatch(/canada/i);
453
+ expect(output.transcript).toMatch(/air quality/i);
454
+
455
+ // The audit is of THIS transcript, and the spoken half is a SCRIPT
456
+ // rather than a list — the template's central prompt decision,
457
+ // measured against a real model.
458
+ expect(output.headline.length).toBeGreaterThan(0);
459
+ expect(`${output.headline} ${output.spoken}`).toMatch(/smoke|wildfire|air|health/i);
460
+ expect(output.risks.length).toBeLessThanOrEqual(4);
461
+ expect(output.actions.length).toBeLessThanOrEqual(4);
462
+ expect(output.spoken).toMatch(/[.!?]/);
463
+ expect(output.spoken).not.toMatch(/^\s*[-*•]/m);
464
+
465
+ // And the mastering pass ran on what the voice produced.
466
+ expect(uploads.writes.map((one) => one.name)).toEqual(["wildfires.pcm", "audit.mp3"]);
467
+ expect(uploads.read(output.audio)?.type).toBe("audio/mpeg");
468
+ expect(output.audioBytes).toBeGreaterThan(0);
469
+ },
470
+ { live: true },
471
+ );
472
+ },
473
+ { speech: speakATone },
474
+ );
@@ -0,0 +1,165 @@
1
+ // An EVAL: does Coda actually answer by RUNNING CODE? Run it with `aai eval`.
2
+ //
3
+ // `agent.test.ts` asserts about the config; this drives the real thing — the
4
+ // real session, the real tool executor, the real event stream, with only the
5
+ // microphone and the speaker faked.
6
+ //
7
+ // Two things about this file are worth copying into any template eval.
8
+ //
9
+ // **The prompt is applied here, because `system-prompt.md` IS the prompt and
10
+ // nothing in `agent.ts` imports it.** Discovery happens where the bundle is
11
+ // assembled (`aai build` reads the file; a spec does the same lowering), so an
12
+ // eval that drove `agent.ts` alone would measure an agent running on
13
+ // `DEFAULT_SYSTEM_PROMPT` — i.e. not the agent anybody deploys, and every claim
14
+ // this file makes about Coda's rules would be a claim about nothing.
15
+ //
16
+ // **`run_code` refuses unless the EVAL supplies an executor.** A deployed agent
17
+ // runs it only inside the guest sandbox — the Modal container is the security
18
+ // boundary — so off-platform the builtin declines rather than evaluating
19
+ // model-written JavaScript in the host process. That is right, and it left this
20
+ // template's whole subject assertable as a CALL and never as an answer: a
21
+ // `toBeDefined()` on the result is satisfied by the refusal itself. So the suite
22
+ // passes `runCode` (below) — a developer's own machine may, a deployment may
23
+ // not — and every case here asserts BOTH halves: that Coda reached for code, and
24
+ // what the code came back with.
25
+ import { runInNewContext } from "node:vm";
26
+ import { withSystemPrompt } from "@alexkroman1/aai/manifest";
27
+ import { errorMessage } from "@alexkroman1/aai/utils";
28
+ import type { RunCodeExecutor } from "@alexkroman1/aai-runtime/eval";
29
+ import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
30
+ import { expect } from "vitest";
31
+ import authored from "./agent.ts";
32
+ import systemPrompt from "./system-prompt.md?raw";
33
+
34
+ const agentDef = withSystemPrompt(authored, systemPrompt);
35
+
36
+ /** The code every `run_code` call in this turn carried, joined. */
37
+ const codeIn = (turn: { toolCalls: readonly { name: string; args: Record<string, unknown> }[] }) =>
38
+ turn.toolCalls
39
+ .filter((c) => c.name === "run_code")
40
+ .map((c) => String(c.args.code ?? ""))
41
+ .join("\n");
42
+
43
+ /**
44
+ * A `run_code` executor, so these cases can assert the ANSWER.
45
+ *
46
+ * The builtin refuses without one — the Modal container is the security
47
+ * boundary, and off-platform there is none — so a case could assert the CALL and
48
+ * the code it carried, and never what the code came back with. A `node:vm`
49
+ * context with a capturing `console.log` is what a developer would reach for on
50
+ * their own machine, and it is enough here: what runs is arithmetic, not a
51
+ * program. It is NOT a sandbox and does not pretend to be one; a deployed agent
52
+ * still gets the refusal.
53
+ *
54
+ * A template eval imports from `@alexkroman1/aai-runtime/eval` and
55
+ * `/eval/vitest` and nowhere else — the ROOT barrel drags the host runtime's
56
+ * node-reaching module graph into this project's TypeScript program, which is
57
+ * three errors in runtime files no eval ever calls. `RunCodeExecutor` is
58
+ * re-exported from `/eval` for exactly that reason.
59
+ */
60
+ const runCode: RunCodeExecutor = async (code) => {
61
+ const lines: string[] = [];
62
+ const log = (...args: unknown[]): void => {
63
+ lines.push(args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" "));
64
+ };
65
+ try {
66
+ runInNewContext(code, { console: { log } }, { timeout: 1000 });
67
+ } catch (err) {
68
+ return { error: errorMessage(err) };
69
+ }
70
+ return lines.join("\n");
71
+ };
72
+
73
+ /** What every `run_code` call in this turn PRINTED, joined. */
74
+ const outputIn = (turn: { toolCalls: readonly { name: string; result?: string }[] }) =>
75
+ turn.toolCalls
76
+ .filter((c) => c.name === "run_code")
77
+ .map((c) => c.result ?? "")
78
+ .join("\n");
79
+
80
+ describeEval(
81
+ agentDef,
82
+ (test) => {
83
+ test(
84
+ "writes code for arithmetic instead of answering from its head",
85
+ async ({ session }) => {
86
+ const turn = await session.say("What is 127 times 849?");
87
+
88
+ // The template's CRITICAL RULE, and the whole reason it declares
89
+ // run_code: a model that answers this one directly has regressed, and it
90
+ // is the easiest question in the file to answer wrongly with confidence.
91
+ expect(turn.toolCalls.map((c) => c.name)).toContain("run_code");
92
+ const code = codeIn(turn);
93
+ expect(code).toContain("127");
94
+ expect(code).toContain("849");
95
+
96
+ // And the code RAN, and got it right. That half needed an executor: with
97
+ // none, `run_code` answers "only available in the sandboxed runtime", so
98
+ // every claim above passes for an agent that then does the sum in its
99
+ // head — which is the exact regression the CRITICAL RULE exists to stop.
100
+ const output = outputIn(turn);
101
+ expect(output, `run_code printed: ${output}`).toContain("107823");
102
+
103
+ // "Report RESULTS, never intentions": the call goes out before Coda says
104
+ // anything, rather than after a sentence announcing it.
105
+ const firstTool = turn.events.findIndex((e) => e.type === "tool.called");
106
+ const firstSaid = turn.events.findIndex((e) => e.type === "agent-transcript.committed");
107
+ expect(firstSaid).toBeGreaterThan(-1);
108
+ expect(firstTool).toBeGreaterThan(-1);
109
+ expect(firstTool).toBeLessThan(firstSaid);
110
+ },
111
+ { live: true },
112
+ );
113
+
114
+ test(
115
+ "reaches for code for a calendar question too, not just sums",
116
+ async ({ session }) => {
117
+ const turn = await session.say("What day of the week was January 1st, 2000?");
118
+
119
+ // The prompt lists this exact question under "you MUST use code for".
120
+ // It is the case a narrower reading of the rule ("code is for maths")
121
+ // silently drops.
122
+ expect(turn.toolCalls.map((c) => c.name)).toContain("run_code");
123
+ expect(codeIn(turn)).toMatch(/Date|2000/);
124
+ // The code RAN rather than being refused — but the ANSWER is
125
+ // deliberately not asserted here, and the reason is worth knowing before
126
+ // adding it back. Coda writes `new Date("2000-01-01").getDay()`, which
127
+ // parses as UTC midnight and is then read in LOCAL time: correct in the
128
+ // guest sandbox (UTC, Saturday) and one day out on any developer west of
129
+ // Greenwich (measured: this printed "Friday"). Asserting the weekday
130
+ // would measure the machine running the eval. The two arithmetic cases
131
+ // in this suite have no such dependency and do assert their answers.
132
+ const output = outputIn(turn);
133
+ expect(output, `run_code printed: ${output}`).not.toMatch(
134
+ /only available in the sandboxed runtime/,
135
+ );
136
+ expect(output).not.toBe("");
137
+ },
138
+ { live: true },
139
+ );
140
+
141
+ test(
142
+ "the run_code builtin is wired to the agent's tool executor",
143
+ async ({ session }) => {
144
+ const turn = await session.say("Add one and one for me.");
145
+
146
+ // The wiring claim, and it really discriminates: a tool the agent does
147
+ // NOT declare produces a `tool.called` with no result at all, so the
148
+ // paired result is what says `builtinTools: ["run_code"]` still resolves
149
+ // through to an executable tool.
150
+ //
151
+ // `toBeDefined()` used to be the whole assertion, and it was satisfied by
152
+ // the REFUSAL — "run_code is only available in the sandboxed runtime" is a
153
+ // defined result. Asserting the printed answer is what makes this a claim
154
+ // about the executor rather than about the string it declined with.
155
+ const [call] = turn.toolCalls;
156
+ expect(call?.name).toBe("run_code");
157
+ expect(call?.result).toBe("2");
158
+ expect(turn.completed).toBe(true);
159
+ },
160
+ { stubReply: [{ tool: "run_code", args: { code: "console.log(1 + 1)" } }, "That's two."] },
161
+ );
162
+ },
163
+ // `runCode` is what makes these cases about the ANSWER and not just the call.
164
+ { runCode },
165
+ );