@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,342 @@
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 and drives the four legs one at
6
+ // a time. This drives the WHOLE BODY — `spokenSummaryFlow` from the top — and
7
+ // what it is here to check is the thing that is this template's whole reason to
8
+ // exist and that no per-step spec can see: the AUDIO ROUND TRIP. A recording
9
+ // goes in, and what comes out is a run whose OUTPUT NAMES A FILE that is really
10
+ // in the store, put there by the same step that spoke it.
11
+ //
12
+ // `describeWorkflowEval` picks the providers for you and says which it picked:
13
+ //
14
+ // * with `ASSEMBLYAI_API_KEY` — a LIVE run. The `{ live: true }` case below
15
+ // downloads a real four-minute news clip, really transcribes it and really
16
+ // summarizes it. That spends money and about a minute.
17
+ // * without one — a SCRIPTED run: the same body, the same four legs, with the
18
+ // provider answered in memory.
19
+ //
20
+ // Three of the four cases are SCRIPTED IN BOTH MODES, deliberately. Their claims
21
+ // are about WIRING — that the id in the output is the id of the file that was
22
+ // written, that the voice was handed the script and not the bullet list, that
23
+ // the poll loop asks for its wait — and a live provider can neither confirm nor
24
+ // deny any of them: it can only be asked and then have its answer accepted,
25
+ // which is not evidence. The live case is the one that answers "is the summary
26
+ // really of this recording".
27
+ //
28
+ // The UPLOAD STORE is faked in both modes, and that is not a mode question: it
29
+ // is this app's own storage (a database row and a blob), not a provider, and an
30
+ // eval has neither. `{ writable: true }` is what lets the store accept the
31
+ // write — opt-in precisely so a step that stored a file nobody meant it to
32
+ // still fails.
33
+ //
34
+ // TWO PROVIDER LEGS ARE NOT REACHABLE LIVE from an eval today, and both are
35
+ // named where they are worked around rather than left as a surprise:
36
+ //
37
+ // * the streaming UPLOAD leg needs a `stepFetch` — see `liveStepFetch` below.
38
+ // * `stepSpeak` needs a synthesizer, and the engine publishes none by default
39
+ // while `@alexkroman1/aai-runtime` exports no real one to pass. So the VOICE
40
+ // is faked in every case here, live included, and the live case's claim is
41
+ // about the transcript and the summary rather than about audible audio.
42
+ //
43
+ // WHAT NO EVAL HERE COVERS: durability. Imported through vitest with no bundler
44
+ // in the path, a `"use workflow"` body is an ordinary async function — no
45
+ // journal, no replay, and no per-step retry, so a rate-limited live run FAILS
46
+ // where a deployed one would have ridden it out, and the resume-replays-the-id
47
+ // property that makes speak-and-store ONE step is argued here rather than
48
+ // exercised. `run.slept` below is the other half of that admission written as
49
+ // an assertion. `aai-cli`'s `dev-workflow.scenario.test.ts` is the tier that
50
+ // really suspends and resumes a run.
51
+ import {
52
+ installStubSpeech,
53
+ installStubTranscribe,
54
+ installStubUploads,
55
+ } from "@alexkroman1/aai/testing/vitest";
56
+ import { describeWorkflowEval } from "@alexkroman1/aai-runtime/eval/vitest";
57
+ import { expect } from "vitest";
58
+ import agentDef, { spokenSummary } from "./agent.ts";
59
+ import { POLL_INTERVAL } from "./workflows/transcribe.ts";
60
+
61
+ /** The id every case uploads the recording under. */
62
+ const UPLOAD_ID = "upl_eval";
63
+
64
+ /** The public sample recording — four and a half minutes of real speech. */
65
+ const LIVE_RECORDING = "https://assembly.ai/wildfires.mp3";
66
+
67
+ /** What a scripted transcript says, so a summary of it is checkable. */
68
+ const TRANSCRIPT =
69
+ "Right, standup. The launch is on for Tuesday the fourth. Two bugs are left in " +
70
+ "checkout, both assigned to Priya, and neither is a blocker. Marketing wants the " +
71
+ "blog post by Monday. If the second bug slips we ship anyway and patch on Wednesday.";
72
+
73
+ /**
74
+ * The reply the model is scripted to give.
75
+ *
76
+ * The `spoken` script deliberately shares NO wording with `points`, which is
77
+ * what makes "the voice was handed the script" an assertion rather than a
78
+ * coincidence — see the second case.
79
+ */
80
+ const REPLY = {
81
+ headline: "Launch is on for Tuesday",
82
+ points: ["Ship Tuesday the fourth", "Two checkout bugs, neither blocking", "Blog post by Monday"],
83
+ spoken:
84
+ "Everything is lined up for the fourth. A couple of small things are still open " +
85
+ "in checkout and Priya has both of them; nothing there is holding the release. " +
86
+ "Marketing needs the write-up at the start of the week.",
87
+ };
88
+
89
+ /**
90
+ * A `stepFetch` for the LIVE legs, and it exists to work around a gap rather
91
+ * than to add anything.
92
+ *
93
+ * The eval engine publishes no `stepFetch`, so a step's HTTP falls back to
94
+ * `globalThis.fetch` — which cannot send this app's UPLOAD leg at all.
95
+ * `stepTranscribeUpload` streams a stored recording window by window, and an
96
+ * iterable body requires `duplex: "half"`; the published fetch adds it (see
97
+ * `sdk/step-fetch.ts`, which says so) and the fallback does not, so a live run
98
+ * dies on `RequestInit: duplex option is required when sending a body` before
99
+ * the provider is ever reached. `EvalWorkflowsOptions.stepFetch` is the
100
+ * documented seam for a host to supply its own, and this is the smallest one
101
+ * that works. Every scripted case below REPLACES it, publishing being a
102
+ * replacement.
103
+ */
104
+ const liveStepFetch = (url: string, init: Record<string, unknown> = {}): Promise<Response> =>
105
+ globalThis.fetch(url, { ...init, duplex: "half" } as RequestInit);
106
+
107
+ /** Publish this app's own store, writable because the last step writes to it. */
108
+ function publish(bytes: Uint8Array, name: string, type: string) {
109
+ return installStubUploads({ [UPLOAD_ID]: { bytes, name, type } }, { writable: true });
110
+ }
111
+
112
+ /**
113
+ * Answer every leg of the run in memory: the three transcription calls, and the
114
+ * model.
115
+ *
116
+ * ONE fake, because publishing a `stepFetch` REPLACES — a flow that transcribes
117
+ * AND calls a model cannot install two, which is exactly what `otherwise` is
118
+ * for. The transcription half is the SDK's own fake rather than this file's
119
+ * hand-typed wire: it routes off the SDK's endpoint constants, so a case cannot
120
+ * pass because the fake and the step agree on a typo.
121
+ */
122
+ function scriptProvider(options: { text?: string; pendingPolls?: number } = {}) {
123
+ return installStubTranscribe({
124
+ text: options.text ?? TRANSCRIPT,
125
+ durationSec: 42,
126
+ // Passed straight through rather than conditionally spread: the option
127
+ // already admits `undefined`, and `guard-invariants` rule 2 counts the
128
+ // spread.
129
+ pendingPolls: options.pendingPolls,
130
+ otherwise: (request) =>
131
+ request.url.includes("llm-gateway")
132
+ ? { body: { choices: [{ message: { content: JSON.stringify(REPLY) } }] } }
133
+ : undefined,
134
+ });
135
+ }
136
+
137
+ describeWorkflowEval(
138
+ agentDef,
139
+ (test) => {
140
+ test("the run's output NAMES a file that one step spoke and stored", async ({ app }) => {
141
+ // Scripted in both modes: the claim is that the id in the output is the id
142
+ // of the file in the store, and no provider can be asked to make that true.
143
+ // This is the case that catches speak-and-store coming apart — a run that
144
+ // returned bytes, or an id nothing wrote, or two ids because the synthesis
145
+ // and the store became two steps.
146
+ const uploads = publish(new Uint8Array(64), "standup.wav", "audio/wav");
147
+ const provider = scriptProvider();
148
+ const speech = installStubSpeech({ pcmBytes: 96_000 });
149
+
150
+ const run = await app.run(spokenSummary, { recording: UPLOAD_ID });
151
+
152
+ // The error FIRST, so a failed run names its own reason instead of
153
+ // reporting "expected 'failed' to be 'completed'".
154
+ expect(run.error).toBeUndefined();
155
+ expect(run.status).toBe("completed");
156
+ const output = run.output;
157
+ if (output === undefined) expect.fail("a completed run must carry an output");
158
+
159
+ // EXACTLY one write. Two would mean the synthesis and the store had come
160
+ // apart into two steps, which is the mistake this template exists to argue
161
+ // against — a step is journaled by its return value, so an id replays and
162
+ // bytes do not.
163
+ expect(uploads.writes).toHaveLength(1);
164
+ const written = uploads.writes[0];
165
+ if (written === undefined) expect.fail("the speaking step must have stored a file");
166
+ // The output carries the ID of that write, and an id is a string — not the
167
+ // audio. A run's output is read back as JSON.
168
+ expect(output.audio).toBe(written.id);
169
+ expect(typeof output.audio).toBe("string");
170
+
171
+ // And the bytes are really there, and are really a WAV: named and typed for
172
+ // a browser, because the byte route serves what it was given and nothing
173
+ // plays a file handed to it as octet-stream.
174
+ expect(written.name).toBe("summary.wav");
175
+ expect(written.type).toBe("audio/wav");
176
+ expect(written.bytes.byteLength).toBe(44 + 96_000);
177
+ expect(String.fromCharCode(...written.bytes.subarray(0, 4))).toBe("RIFF");
178
+ expect(String.fromCharCode(...written.bytes.subarray(8, 12))).toBe("WAVE");
179
+ // 96,000 bytes at 24 kHz mono 16-bit is two seconds, which is what the
180
+ // page prints next to the player.
181
+ expect(output.audioDurationMs).toBe(2000);
182
+
183
+ // The rest of the round trip, so a run that stored audio for the wrong text
184
+ // is not mistaken for a working one.
185
+ expect(output.source).toBe("standup.wav");
186
+ expect(output.durationMs).toBe(42_000);
187
+ expect(output.transcript).toBe(TRANSCRIPT);
188
+ expect(output.points).toHaveLength(3);
189
+ expect(speech.calls).toHaveLength(1);
190
+
191
+ // Four legs, four narrated lines, in order — which is what a page watching
192
+ // the run renders.
193
+ expect(run.reported[0]).toMatch(/^Uploading standup\.wav /);
194
+ expect(run.reported).toContain("Summarizing the transcript.");
195
+ expect(run.reported.at(-1)).toMatch(/^Recorded a 2s summary in \w+'s voice\.$/);
196
+ // The job finished on its first poll, so no durable wait was asked for.
197
+ expect(run.slept).toEqual([]);
198
+ // One upload of the recording, and one submit — the split that exists so a
199
+ // fault in the submit does not re-upload the file.
200
+ expect(provider.calls.filter((call) => call.leg === "upload")).toHaveLength(1);
201
+ expect(provider.calls.filter((call) => call.leg === "submit")).toHaveLength(1);
202
+ });
203
+
204
+ test("the voice reads the SCRIPT the model was asked for, not the points", async ({ app }) => {
205
+ // Scripted in both modes: what a live voice says is not evidence about
206
+ // which string it was handed. This is the case that catches the template's
207
+ // central prompt decision regressing — synthesize the bullet list and you
208
+ // get a voice reading "one. two. three." with no connective tissue.
209
+ publish(new Uint8Array(64), "standup.wav", "audio/wav");
210
+ const provider = scriptProvider();
211
+ const speech = installStubSpeech();
212
+
213
+ const run = await app.run(spokenSummary, { recording: UPLOAD_ID, voice: "michael" });
214
+
215
+ expect(run.error).toBeUndefined();
216
+ const spokenText = speech.calls[0]?.text;
217
+ expect(spokenText).toBe(REPLY.spoken);
218
+ // The script and the points share no wording, so this is the assertion:
219
+ // whatever was spoken, it was not the list.
220
+ for (const point of REPLY.points) expect(spokenText).not.toContain(point);
221
+ // The form's choice really reaches the synthesizer.
222
+ expect(speech.calls[0]?.voice).toBe("michael");
223
+ // Both shapes survive to the output — one to read, one that was heard.
224
+ expect(run.output?.points).toEqual(REPLY.points);
225
+ expect(run.output?.spoken).toBe(REPLY.spoken);
226
+
227
+ // And the model was ASKED for both, over the transcript it was given. A
228
+ // prompt that stopped asking for a script is how the field goes missing.
229
+ const prompt = String(provider.calls.find((call) => call.leg === "other")?.body ?? "");
230
+ expect(prompt).toContain("READ ALOUD");
231
+ expect(prompt).toContain("The launch is on for Tuesday the fourth");
232
+ });
233
+
234
+ test("a recording with no speech stops before the model and the voice", async ({ app }) => {
235
+ // Scripted in both modes, and it costs nothing live either way: silence
236
+ // transcribes SUCCESSFULLY to nothing, so without a terminal failure here
237
+ // the run would go on to summarize no words and store half a second of
238
+ // audio — a green run with an empty product.
239
+ const uploads = publish(new Uint8Array(64), "silence.wav", "audio/wav");
240
+ const provider = scriptProvider({ text: " " });
241
+ const speech = installStubSpeech();
242
+
243
+ const run = await app.run(spokenSummary, { recording: UPLOAD_ID });
244
+
245
+ expect(run.status).toBe("failed");
246
+ expect(run.error).toMatch(/no speech in that recording/i);
247
+ expect(run.output).toBeUndefined();
248
+ // Nothing was summarized and nothing was spoken, which is the half that
249
+ // makes this more than an error-message assertion.
250
+ expect(provider.calls.filter((call) => call.leg === "other")).toEqual([]);
251
+ expect(speech.calls).toEqual([]);
252
+ expect(uploads.writes).toEqual([]);
253
+ expect(run.reported).not.toContain("Summarizing the transcript.");
254
+ });
255
+
256
+ test("an unfinished job is waited out with a DURABLE sleep, not a busy loop", async ({
257
+ app,
258
+ }) => {
259
+ // Scripted in both modes: a live job cannot be asked to stay queued for
260
+ // exactly two polls, and this is the one place the poll loop's shape is
261
+ // visible — one submit, one poll per round, and a recorded wait between
262
+ // them. A loop that re-submitted, or one that spun with no wait, both
263
+ // produce a correct transcript and a wrong bill.
264
+ publish(new Uint8Array(64), "standup.wav", "audio/wav");
265
+ const provider = scriptProvider({ pendingPolls: 2 });
266
+ installStubSpeech();
267
+
268
+ const run = await app.run(spokenSummary, { recording: UPLOAD_ID });
269
+
270
+ expect(run.error).toBeUndefined();
271
+ expect(run.output?.transcript).toBe(TRANSCRIPT);
272
+ // Two waits for three polls: asked for and — this being an eval rather
273
+ // than a deployment — recorded rather than taken.
274
+ expect(run.slept).toEqual([{ duration: POLL_INTERVAL }, { duration: POLL_INTERVAL }]);
275
+ expect(provider.calls.filter((call) => call.leg === "poll")).toHaveLength(3);
276
+ // The expensive half happened ONCE, which is the whole reason the upload
277
+ // and the submit are separate steps.
278
+ expect(provider.calls.filter((call) => call.leg === "upload")).toHaveLength(1);
279
+ expect(provider.calls.filter((call) => call.leg === "submit")).toHaveLength(1);
280
+ });
281
+
282
+ test(
283
+ "really transcribes and summarizes a real recording",
284
+ async ({ app }) => {
285
+ // LIVE ONLY, and the one case here that measures rather than checks
286
+ // wiring. Three things have to be real for it to pass: the file has to
287
+ // stream to the provider, the transcript has to be of THAT recording, and
288
+ // the model has to write a script from that transcript.
289
+ //
290
+ // The VOICE is faked even here, and that is a harness limit rather than a
291
+ // choice — `stepSpeak` reads a published synthesizer, the eval engine
292
+ // publishes none, and no real one is exported to pass it. So what this
293
+ // case does NOT claim is that the audio is audible; the round trip's
294
+ // structure (one write, an id in the output, real WAV framing) is what
295
+ // the first case pins, in memory, where it is a fact rather than a hope.
296
+ const speech = installStubSpeech({ pcmBytes: 48_000 });
297
+ const response = await fetch(LIVE_RECORDING);
298
+ expect(response.ok).toBe(true);
299
+ const mp3 = new Uint8Array(await response.arrayBuffer());
300
+ const uploads = publish(mp3, "wildfires.mp3", "audio/mpeg");
301
+
302
+ const run = await app.run(spokenSummary, { recording: UPLOAD_ID });
303
+
304
+ expect(run.error).toBeUndefined();
305
+ expect(run.status).toBe("completed");
306
+ const output = run.output;
307
+ if (output === undefined) expect.fail("a completed run must carry an output");
308
+
309
+ // The transcript is of THIS recording — a news segment about smoke from
310
+ // Canadian wildfires reaching the US east coast.
311
+ expect(output.transcript).toMatch(/wildfire/i);
312
+ expect(output.transcript).toMatch(/canada/i);
313
+ expect(output.words).toBeGreaterThan(400);
314
+ // The FILENAME a reader sees is the one they uploaded, not the opaque id.
315
+ expect(output.source).toBe("wildfires.mp3");
316
+ expect(output.durationMs).toBeGreaterThan(250_000);
317
+
318
+ // The summary is of the transcript, not of summarizing in general.
319
+ const written = `${output.headline} ${output.points.join(" ")} ${output.spoken}`;
320
+ expect(written).toMatch(/smoke|wildfire|air/i);
321
+ expect(output.points.length).toBeGreaterThan(0);
322
+ expect(output.points.length).toBeLessThanOrEqual(4);
323
+ // A SCRIPT rather than a list: sentences, no bullet markers, and long
324
+ // enough that a voice reading it has something to say. This is the
325
+ // template's central prompt decision, measured against a real model.
326
+ expect(output.spoken).toMatch(/[.!?]/);
327
+ expect(output.spoken).not.toMatch(/^\s*[-*\u2022]/m);
328
+ expect(output.spoken.length).toBeGreaterThan(80);
329
+ // And it is the SCRIPT that was spoken, not the points a page renders.
330
+ expect(speech.calls[0]?.text).toBe(output.spoken);
331
+
332
+ // The store really holds what the output names.
333
+ expect(uploads.writes).toHaveLength(1);
334
+ const stored = uploads.read(output.audio);
335
+ if (stored === undefined) expect.fail("the run's `audio` id must name a stored file");
336
+ expect(String.fromCharCode(...stored.bytes.subarray(0, 4))).toBe("RIFF");
337
+ },
338
+ { live: true },
339
+ );
340
+ },
341
+ { stepFetch: liveStepFetch },
342
+ );
@@ -0,0 +1,273 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ // An EVAL: does the line really refuse to say what it cannot ground? Run it
4
+ // with `aai eval`.
5
+ //
6
+ // `agent.test.ts` drives the retriever and each CRAG node directly. What it
7
+ // cannot see is the claim this template exists to make — that a MODEL holding
8
+ // this tool list cannot state a fee it did not just retrieve. So these cases
9
+ // drive a real session and read the verdicts off the tool results: which
10
+ // documents the grader passed, whether the answer was grounded, and whether an
11
+ // answer it could not ground was WITHHELD.
12
+ //
13
+ // **One thing a SCRIPTED run cannot reach: `ctx.generate` with a SCHEMA.** The
14
+ // stub model registers as an LLM provider, so `ctx.generate` resolves it, and it
15
+ // answers a plain text generation from the script. Every node in this template's
16
+ // loop passes a `schema` though (`binaryScore`), and that overload comes back
17
+ // `{"error":"The knowledge base lookup failed: No output generated."}` — so the
18
+ // whole corrective loop is unreachable scripted. That is the template's central
19
+ // tool, so the two cases that run without a key are written as invariants both
20
+ // modes satisfy (a failed lookup is REPORTED, never answered around), and the
21
+ // two that read a real verdict are `{ live: true }`.
22
+ //
23
+ // **`system-prompt.md` is applied HERE, not by `agent.ts`.** The build discovers
24
+ // the file, so an eval driving the raw default export would run this agent with
25
+ // the FRAMEWORK DEFAULT prompt — and the discipline that prompt imposes is the
26
+ // entire subject of this file. Measured against the default: the model answers
27
+ // product questions from its own knowledge and skips the tools the prompt exists
28
+ // to route it through, so a case run that way measures nothing it claims to.
29
+
30
+ import { withSystemPrompt } from "@alexkroman1/aai/manifest";
31
+ import { withDiscoveredTools } from "@alexkroman1/aai/testing";
32
+ import { type EvalSession, toolResultIn } from "@alexkroman1/aai-runtime/eval";
33
+ import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
34
+ import { expect } from "vitest";
35
+ import { z } from "zod";
36
+ import authoredAgent from "./agent.ts";
37
+ import systemPrompt from "./system-prompt.md?raw";
38
+
39
+ /** The def a DEPLOYED agent runs — see `agent.test.ts` on why the glob is here. */
40
+ const agentDef = withSystemPrompt(
41
+ withDiscoveredTools(authoredAgent, import.meta.glob("./tools/*.ts", { eager: true })),
42
+ systemPrompt,
43
+ );
44
+
45
+ /**
46
+ * What the BROWSER is sent, as this eval reads it.
47
+ *
48
+ * Parsed rather than cast: `state.updated` carries `unknown`, so a projection
49
+ * that stopped matching FAILS naming the field, where the cast this replaced
50
+ * handed the assertions `undefined` and failed a line later on something else.
51
+ * It names only the fields asserted below, so `supportView` may grow without
52
+ * touching this.
53
+ */
54
+ const ProjectedSupport = z.object({
55
+ ticket: z.string().nullable(),
56
+ trace: z
57
+ .object({
58
+ exhausted: z.boolean(),
59
+ docs: z.array(z.object({ relevant: z.boolean() })),
60
+ steps: z.array(z.object({ node: z.string() })),
61
+ })
62
+ .nullable(),
63
+ });
64
+
65
+ /**
66
+ * What `answer_question` answered, validated.
67
+ *
68
+ * All-optional, and that is the shape of the CONTRACT rather than laxness: the
69
+ * tool has three legal outcomes — a graded answer, a withheld one carrying
70
+ * guidance, and a reported failure — so a required field would fail two of the
71
+ * three. What the schema still buys is that a field the case reads is the type
72
+ * the case thinks it is.
73
+ */
74
+ const Lookup = z.object({
75
+ answer: z.string().nullable().optional(),
76
+ sources: z.array(z.string()).optional(),
77
+ grounded: z.boolean().optional(),
78
+ answersTheQuestion: z.boolean().optional(),
79
+ rewrites: z.number().optional(),
80
+ guidance: z.string().optional(),
81
+ error: z.string().optional(),
82
+ });
83
+
84
+ /** Every `syncState` frame, in stream order. */
85
+ function frames(session: EvalSession) {
86
+ return session
87
+ .events()
88
+ .flatMap((event) =>
89
+ event.type === "state.updated" ? [ProjectedSupport.parse(event.state)] : [],
90
+ );
91
+ }
92
+
93
+ /**
94
+ * What `answer_question` answered, in call order.
95
+ *
96
+ * `toolResultIn` over a ONE-CALL list per call: the name is that call's own, so
97
+ * the reader's "no such call" and "two calls" throws are unreachable and what is
98
+ * left is the parse, the schema, and its "never completed" failure — which is
99
+ * the half the hand-rolled `JSON.parse(call.result ?? "null")` this replaced got
100
+ * wrong, reading `null` for a tool that never came back.
101
+ */
102
+ function lookups(session: EvalSession) {
103
+ return session
104
+ .toolCalls()
105
+ .filter((call) => call.name === "answer_question")
106
+ .map((call) => toolResultIn([call], call.name, Lookup));
107
+ }
108
+
109
+ describeEval(agentDef, (test) => {
110
+ test(
111
+ "nothing about the product is said without going through the knowledge base",
112
+ async ({ session }) => {
113
+ const turn = await session.say("How much notice do I have to give if I want to cancel?");
114
+
115
+ // The prompt's one rule: `answer_question` is the only route to anything
116
+ // about Meridian Fibre. A model that answered a notice period from its own
117
+ // knowledge would sound identical down a phone.
118
+ const asked = turn.toolCalls.filter((call) => call.name === "answer_question");
119
+ // The reply rides in the message, because the failure that matters here is
120
+ // a turn that SPOKE without looking anything up — "let me check that for
121
+ // you" and then nothing, or worse, a notice period from memory.
122
+ expect(
123
+ asked.length,
124
+ `tools called: [${turn.toolCalls.map((c) => c.name).join(", ")}]; said: ${turn.text}`,
125
+ ).toBe(1);
126
+
127
+ for (const payload of lookups(session)) {
128
+ // Three legal outcomes, and the invariant that spans them: an answer the
129
+ // caller is given is an answer the hallucination grader passed. `grounded:
130
+ // false` reaching the model is the one thing the whole apparatus exists
131
+ // to prevent — and a broken lookup is REPORTED rather than worked around.
132
+ if (payload.error !== undefined) continue;
133
+ if (payload.answer === null) continue;
134
+ expect(payload.grounded, `ungrounded answer handed to the model: ${payload.answer}`).toBe(
135
+ true,
136
+ );
137
+ }
138
+ },
139
+ {
140
+ stubReply: [{ tool: "answer_question", args: { question: "notice to cancel" } }, "One sec."],
141
+ },
142
+ );
143
+
144
+ test(
145
+ "a logged ticket's callback number never leaves the server",
146
+ async ({ session }) => {
147
+ // Two turns, because that is the shape the prompt asks for: the lookup comes
148
+ // back empty, the line offers a ticket, and only then is there a number to
149
+ // take. Asked for in one breath, a live model reasonably answers the offer
150
+ // instead of acting on it.
151
+ await session.say("Can I add a pay-monthly mobile SIM to my broadband account?");
152
+ await session.say("Yes please, log that one — my callback number is 07700 900123.");
153
+
154
+ const logged = session.toolCalls().find((call) => call.name === "log_ticket");
155
+ expect(
156
+ logged,
157
+ `tools called: ${session
158
+ .toolCalls()
159
+ .map((c) => c.name)
160
+ .join(", ")}`,
161
+ ).toBeDefined();
162
+ expect(logged?.result).toMatch(/TCK\d{4}/);
163
+
164
+ const latest = frames(session).at(-1);
165
+ // The projection carries the REFERENCE and nothing else — that is why
166
+ // `supportView` exists rather than `syncState` taking the state itself.
167
+ expect(latest?.ticket).toMatch(/^TCK\d{4}$/);
168
+ for (const frame of frames(session)) {
169
+ expect(JSON.stringify(frame)).not.toMatch(/900123/);
170
+ }
171
+ },
172
+ {
173
+ stubReply: [
174
+ { tool: "answer_question", args: { question: "add a mobile SIM to my account" } },
175
+ "I don't have that documented. Shall I log a ticket for a callback?",
176
+ {
177
+ tool: "log_ticket",
178
+ args: { question: "add a mobile SIM to my account", callback: "07700 900123" },
179
+ },
180
+ "That's logged — the reference is T C K four thousand and one.",
181
+ ],
182
+ },
183
+ );
184
+
185
+ test(
186
+ "the answer comes from the document the caller asked about, not the one next to it",
187
+ async ({ session }) => {
188
+ // Short and single, deliberately: the claim here is which document the
189
+ // grader passed, so the question is phrased to measure that and not the
190
+ // loop's pacing.
191
+ //
192
+ // It used to say the corrective path could not fit the runtime's 30s
193
+ // per-tool deadline. Re-measured against the live gateway, that is no
194
+ // longer true by an order of magnitude — the two-attempt path runs in
195
+ // 2.8s of nine model calls — so the phrasing above is a choice about what
196
+ // this case measures rather than a way around a clock. The numbers and
197
+ // what they do and do not license are on `LOOKUP_BUDGET_MS` in
198
+ // `tools/answer_question.ts`.
199
+ const turn = await session.say("How much notice do I have to give to cancel my contract?");
200
+
201
+ const [payload] = lookups(session);
202
+ expect(
203
+ payload,
204
+ `tools called: [${turn.toolCalls.map((c) => c.name).join(", ")}]; said: ${turn.text}`,
205
+ ).toBeDefined();
206
+ // The whole verdict in the message: `grounded: undefined` on its own does
207
+ // not say whether the lookup failed, or ran and refused.
208
+ const verdict = JSON.stringify(payload);
209
+ expect(payload?.grounded, verdict).toBe(true);
210
+ expect(payload?.answersTheQuestion, verdict).toBe(true);
211
+
212
+ const sources = payload?.sources ?? [];
213
+ expect(sources).toContain("Cancelling your contract");
214
+ // THE claim, and the reason the knowledge base is built the way it is:
215
+ // "cancelling your contract" and "cancelling an engineer visit" are two
216
+ // documents, two fees and one word apart. A retriever with no grader hands
217
+ // back both, and sixty pounds gets read down the phone as the answer to a
218
+ // contract question.
219
+ expect(sources).not.toContain("Cancelling or rebooking an engineer visit");
220
+ // Either spelling: the claim is that the notice period came out of D5, not
221
+ // how the answer prompt chose to write a numeral.
222
+ expect(String(payload?.answer)).toMatch(/thirty days|30 days/i);
223
+ expect(String(payload?.answer)).not.toMatch(/sixty pound|abortive/i);
224
+ expect(turn.text).not.toMatch(/sixty|abortive/i);
225
+ },
226
+ // Live only: the grader's verdict IS the measurement.
227
+ //
228
+ // THE CLOCK, and it is no longer the limit it was recorded as here.
229
+ // `answer_question` is up to eleven sequential model calls and the tool
230
+ // executor abandons any tool at `TOOL_EXECUTION_TIMEOUT_MS` (30s) — a
231
+ // deadline neither an agent author nor an eval can raise — so this comment
232
+ // used to say the two-attempt corrective path did not fit, which made this
233
+ // case a coin toss on the gateway rather than a reading of the grader.
234
+ // Re-measured: the whole loop is 2.0-3.1s, the corrective path 2.8s of nine
235
+ // calls, and eight consecutive live runs of this suite were green. The
236
+ // margin belongs to the service, not to us, so the tool keeps its own
237
+ // budget and this case keeps a question that settles on one attempt; if the
238
+ // gateway slows down again the failure says which, because a spent budget
239
+ // comes back as the graph's own exit sentence rather than as a timeout.
240
+ { live: true },
241
+ );
242
+
243
+ test(
244
+ "a question nothing covers is refused, after the corrective edge has tried",
245
+ async ({ session }) => {
246
+ const turn = await session.say(
247
+ "Can I add a pay-monthly mobile SIM with unlimited data to my broadband account?",
248
+ );
249
+
250
+ const [payload] = lookups(session);
251
+ expect(
252
+ payload,
253
+ `tools called: [${turn.toolCalls.map((c) => c.name).join(", ")}]; said: ${turn.text}`,
254
+ ).toBeDefined();
255
+ // Withheld, not softened: `answer: null` is the tool refusing to hand the
256
+ // model something to read out, and the guidance is the exit the grading
257
+ // apparatus needs — a support line that can only answer will answer wrong.
258
+ // A lookup that BROKE and one that ran and found nothing are different
259
+ // findings, and neither is readable from "answer is not null".
260
+ expect(payload?.answer, JSON.stringify(payload)).toBeNull();
261
+ expect(String(payload?.guidance)).toMatch(/log_ticket/);
262
+ // The corrective edge really ran: nothing relevant means the RETRIEVAL was
263
+ // wrong, so the query is rewritten and retried before the loop gives up.
264
+ expect(payload?.rewrites).toBeGreaterThanOrEqual(1);
265
+
266
+ const trace = frames(session).at(-1)?.trace;
267
+ expect(trace?.exhausted).toBe(true);
268
+ expect(trace?.docs.every((doc) => !doc.relevant)).toBe(true);
269
+ expect(trace?.steps.map((step) => step.node)).toContain("transform_query");
270
+ },
271
+ { live: true },
272
+ );
273
+ });
@@ -7,7 +7,8 @@ The one rule that matters:
7
7
 
8
8
  Running a call:
9
9
 
10
- - The caller is waiting while the lookup runs, so say a short "let me check that for you" before calling `answer_question` never in silence.
10
+ - **Every turn in which the caller asks anything about Meridian Fibre begins with a call to `answer_question`.** The call goes out first, in that turn, before you decide what to say. That is the whole procedure and there is no version of it where the lookup happens later.
11
+ - "Let me check that for you" is not a check. It is what you may say while the call you have already made is running, in the same turn — five words, no more. **A turn that ends on that sentence with no `answer_question` call in it has hung up on the caller**: they are holding a line that never comes back, which is worse than the silence the sentence exists to fill. If you have said it, the tool call is already out or the turn is not finished.
11
12
  - Give the answer it returns in your own words, keeping every number exactly as it came back.
12
13
  - When it returns no answer, say plainly that you do not have that documented. Do not guess, and do not soften it into a maybe. Offer to log a ticket.
13
14
  - When it returns an answer with a caveat, give the answer and then offer the ticket.