@alexkroman1/aai-cli 13.1.0 → 13.2.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 (27) hide show
  1. package/dist/scaffold/package.json +4 -4
  2. package/dist/templates/call-audit/agent.eval.test.ts +14 -11
  3. package/dist/templates/call-audit/agent.test.ts +20 -4
  4. package/dist/templates/call-audit/workflows/ingest.ts +10 -1
  5. package/dist/templates/code-interpreter/agent.eval.test.ts +27 -17
  6. package/dist/templates/dispatch-center/agent.eval.test.ts +18 -24
  7. package/dist/templates/embedded-assets/agent.eval.test.ts +3 -3
  8. package/dist/templates/health-assistant/agent.eval.test.ts +38 -15
  9. package/dist/templates/link-digest/agent.eval.test.ts +24 -15
  10. package/dist/templates/math-buddy/agent.eval.test.ts +28 -17
  11. package/dist/templates/night-owl/agent.eval.test.ts +30 -15
  12. package/dist/templates/personal-finance/agent.eval.test.ts +27 -17
  13. package/dist/templates/pizza-ordering/agent.eval.test.ts +11 -6
  14. package/dist/templates/plan-and-execute/agent.eval.test.ts +14 -7
  15. package/dist/templates/recap-workflow/agent.eval.test.ts +49 -20
  16. package/dist/templates/redline/agent.eval.test.ts +32 -24
  17. package/dist/templates/research-workflow/agent.eval.test.ts +32 -22
  18. package/dist/templates/retail/agent.eval.test.ts +18 -34
  19. package/dist/templates/spoken-summary/agent.eval.test.ts +25 -16
  20. package/dist/templates/spoken-summary/agent.test.ts +9 -4
  21. package/dist/templates/support-line/agent.eval.test.ts +23 -26
  22. package/dist/templates/transcription-workflow/agent.test.ts +10 -0
  23. package/dist/templates/transcription-workflow/workflows/normalize.ts +10 -1
  24. package/dist/templates/transcription-workflow/workflows/sync-api.ts +5 -2
  25. package/dist/templates/transcription-workflow/workflows/transcribe.ts +11 -4
  26. package/dist/templates/travel-concierge/agent.eval.test.ts +37 -56
  27. package/package.json +4 -4
@@ -14,9 +14,9 @@
14
14
  "publish:agent": "aai publish"
15
15
  },
16
16
  "dependencies": {
17
- "@alexkroman1/aai": "^13.1.0",
18
- "@alexkroman1/aai-runtime": "^13.1.0",
19
- "@alexkroman1/aai-ui": "^13.1.0",
17
+ "@alexkroman1/aai": "^13.2.0",
18
+ "@alexkroman1/aai-runtime": "^13.2.0",
19
+ "@alexkroman1/aai-ui": "^13.2.0",
20
20
  "react": "^19.2.8",
21
21
  "react-dom": "^19.2.8",
22
22
  "tailwindcss": "^4.0.0",
@@ -24,7 +24,7 @@
24
24
  "zod": "^4.5.4"
25
25
  },
26
26
  "devDependencies": {
27
- "@alexkroman1/aai-cli": "^13.1.0",
27
+ "@alexkroman1/aai-cli": "^13.2.0",
28
28
  "@tailwindcss/vite": "^4.3.3",
29
29
  "@types/node": "^26.4.1",
30
30
  "@types/react": "^19.2.18",
@@ -52,6 +52,7 @@
52
52
  // the tier that really resumes a run.
53
53
  import { spawnSync } from "node:child_process";
54
54
  import { encodeWav } from "@alexkroman1/aai/step";
55
+ import { stubGatewayRoute } from "@alexkroman1/aai/testing";
55
56
  import { installStubTranscribe, installStubUploads } from "@alexkroman1/aai/testing/vitest";
56
57
  import { describeWorkflowEval } from "@alexkroman1/aai-runtime/eval/vitest";
57
58
  import { describe, expect, test } from "vitest";
@@ -211,19 +212,21 @@ function publish(bytes: Uint8Array, name: string, type: string) {
211
212
  * Answer the sync transcription endpoint and the model in memory.
212
213
  *
213
214
  * ONE fake, because publishing a `stepFetch` REPLACES — a flow that transcribes
214
- * AND calls a model cannot install two, which is what `otherwise` is for. The
215
- * transcription half is the SDK's own fake rather than this file's hand-typed
216
- * wire: it routes off the SDK's endpoint constants, so a case cannot pass
217
- * because the fake and the step agree on a typo.
215
+ * AND calls a model cannot install two, which is what `otherwise` is for. BOTH
216
+ * halves are the SDK's own fakes rather than this file's hand-typed wire, and
217
+ * for the same reason: each routes off the SDK's own endpoint constant, so a
218
+ * case cannot pass because the fake and the step agree on a typo. The gateway
219
+ * envelope is the half where that matters most — it is a WIRE shape, so getting
220
+ * a field wrong does not fail: `stepGenerate` reads no content, reports an empty
221
+ * completion, and the case blames the desk.
222
+ *
223
+ * `route` answers `undefined` for anything that is not a completion request, so
224
+ * it drops straight into `otherwise` and the transcription legs still reach the
225
+ * fake below it.
218
226
  */
219
227
  function scriptProvider(text: readonly string[]) {
220
- return installStubTranscribe({
221
- text,
222
- otherwise: (request) =>
223
- request.url.includes("/chat/completions")
224
- ? { body: { choices: [{ message: { content: JSON.stringify(REPLY) } }] } }
225
- : undefined,
226
- });
228
+ const model = stubGatewayRoute(JSON.stringify(REPLY));
229
+ return installStubTranscribe({ text, otherwise: (request) => model.route(request) });
227
230
  }
228
231
 
229
232
  describeWorkflowEvalWithFfmpeg(
@@ -928,9 +928,21 @@ describe("the body's step policy", () => {
928
928
  *
929
929
  * The block above drives the body through `createWorkflowCtx` with
930
930
  * `runSteps: false` and a journaled result per step, which is what makes it
931
- * affordable: `ingestRecording` runs ffmpeg, and this repo's test environment
932
- * has none — the `ingestRecording` and `narrate` specs above assert the
933
- * `FatalError` that produces, unconditionally.
931
+ * affordable: `ingestRecording` shells out to the ffmpeg toolchain, which this
932
+ * tier cannot usefully feed — the `ingestRecording` and `narrate` specs above
933
+ * assert the `FatalError` that produces, unconditionally.
934
+ *
935
+ * **The run fails here whether or not ffmpeg is installed, and the two failures
936
+ * do not say the same thing** — which is what the assertion below has to be
937
+ * written against. With no binary on `PATH` the step reports the missing
938
+ * toolchain as an instruction (the template's own doc promises that rather than
939
+ * `spawn ffmpeg ENOENT`); WITH one, the step gets a step further and `ffprobe`
940
+ * rejects the 2 KB stub above with `ffprobe exited with code 1`. This docblock
941
+ * used to state "this repo's test environment has none" as a premise, and the
942
+ * spec below matched `/ffmpeg/i` on the strength of it — so the test passed only
943
+ * on a machine where ffmpeg was ABSENT, and a developer with Homebrew's ffmpeg
944
+ * saw a red suite on a clean checkout of `main`. CI has no ffmpeg, so nothing
945
+ * caught it.
934
946
  *
935
947
  * So a whole run of this desk is not reachable here and this file does not
936
948
  * pretend otherwise; `aai-cli`'s `dev-workflow.scenario.test.ts` is the tier
@@ -953,7 +965,11 @@ describe("the run is DURABLE, as far as ffmpeg allows", () => {
953
965
  const run = await runWorkflow(audit, { recording: UPLOAD_ID }, { name: "audit" });
954
966
 
955
967
  expect(run.status).toBe("failed");
956
- expect(run.error).toMatch(/ffmpeg/i);
968
+ // Either member of the toolchain, for the reason this block's doc gives: the
969
+ // step names `ffmpeg` when the binary is missing and `ffprobe` when it is
970
+ // present, and this spec is about neither. What it is about is the two
971
+ // assertions below — the ENGINE honouring `FatalError`.
972
+ expect(run.error).toMatch(/ff(mpeg|probe)/i);
957
973
  const ingest = run.steps.find((step) => step.name === "ingestRecording");
958
974
  // The whole point of `FatalError`: `maxAttempts: 6` is the budget this call
959
975
  // site asks for, and a failure that cannot change must not spend it. A step
@@ -143,7 +143,16 @@ export async function ingestRecording(uploadId: string): Promise<Ingested> {
143
143
  const normalized = join(dir, "audio.pcm");
144
144
  const silenceLog = join(dir, "silence.txt");
145
145
 
146
- await readUploadToFile(uploadId, source, { size: stored.size });
146
+ // NO `size`, though `stored.size` is right there — and that is the whole
147
+ // difference between this copy being one window at a time and being
148
+ // `STEP_FILE_READ_CONCURRENCY` of them. Passing `size` means "I am judging
149
+ // completeness myself", which is what a body polling a still-arriving
150
+ // upload needs and is the opposite of what happened above: this step has
151
+ // already called `requireCompleteUpload`, so the file IS whole and the
152
+ // windows may land in any order. Omitting it lets `readUploadToFile`
153
+ // establish that for itself and fan out. The cost is one metadata round
154
+ // trip, against the dozens of window reads it overlaps.
155
+ await readUploadToFile(uploadId, source);
147
156
 
148
157
  // What it WAS, for the progress log and the page. Worth one ffprobe: "41
149
158
  // minutes of aac" explains the shape of the run, where "the recording" leaves
@@ -24,27 +24,37 @@
24
24
  // Coda reached for code, and what the code came back with.
25
25
 
26
26
  import agentDef from "virtual:aai/agent";
27
- import { createVmRunCode, toolResultsIn } from "@alexkroman1/aai-runtime/eval";
27
+ import {
28
+ createVmRunCode,
29
+ type EvalTurn,
30
+ toolArgsIn,
31
+ toolNames,
32
+ toolResultsIn,
33
+ } from "@alexkroman1/aai-runtime/eval";
28
34
  import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
29
35
  import { expect } from "vitest";
36
+ import { z } from "zod";
30
37
 
31
- /** The code every `run_code` call in this turn carried, joined. */
32
- const codeIn = (turn: { toolCalls: readonly { name: string; args: Record<string, unknown> }[] }) =>
33
- turn.toolCalls
34
- .filter((c) => c.name === "run_code")
35
- .map((c) => String(c.args.code ?? ""))
38
+ /**
39
+ * The code every `run_code` call in this turn carried, joined.
40
+ *
41
+ * Read through `toolArgsIn` WITH a schema, which is what that reader takes one
42
+ * for: `args` is `Record<string, unknown>` on the wire — the model wrote it and
43
+ * nothing validated it — so the `String(c.args.code ?? "")` this replaced turned
44
+ * an argument Coda renamed, or never sent, into `""`, and every claim below about
45
+ * the code she wrote would have been a claim about an empty string. A `code`
46
+ * that stops arriving FAILS here, naming the field.
47
+ */
48
+ const RunCodeArgs = z.object({ code: z.string() });
49
+ const codeIn = (turn: EvalTurn) =>
50
+ toolArgsIn(turn.toolCalls, "run_code", RunCodeArgs)
51
+ .map((args) => args.code)
36
52
  .join("\n");
37
53
 
38
54
  /**
39
- * A `run_code` executor, so these cases can assert the ANSWER.
40
- *
41
- * The builtin refuses without one the Modal container is the security
42
- * boundary, and off-platform there is none — so a case could assert the CALL and
43
- * the code it carried, and never what the code came back with.
44
- * `createVmRunCode()` is a `node:vm` context with a capturing `console.log`,
45
- * which is enough here: what runs is arithmetic, not a program. It is NOT a
46
- * sandbox and does not pretend to be one; a deployed agent still gets the
47
- * refusal.
55
+ * A `run_code` executor, so these cases can assert the ANSWER and not merely the
56
+ * call — `createVmRunCode`'s own doc carries why the builtin refuses without one
57
+ * and why a `node:vm` context is the right thing to hand it here.
48
58
  */
49
59
  const runCode = createVmRunCode();
50
60
 
@@ -59,7 +69,7 @@ describeEval(
59
69
  // The template's CRITICAL RULE, and the whole reason it declares
60
70
  // run_code: a model that answers this one directly has regressed, and it
61
71
  // is the easiest question in the file to answer wrongly with confidence.
62
- expect(turn.toolCalls.map((c) => c.name)).toContain("run_code");
72
+ expect(toolNames(turn.toolCalls)).toContain("run_code");
63
73
  const code = codeIn(turn);
64
74
  expect(code).toContain("127");
65
75
  expect(code).toContain("849");
@@ -90,7 +100,7 @@ describeEval(
90
100
  // The prompt lists this exact question under "you MUST use code for".
91
101
  // It is the case a narrower reading of the rule ("code is for maths")
92
102
  // silently drops.
93
- expect(turn.toolCalls.map((c) => c.name)).toContain("run_code");
103
+ expect(toolNames(turn.toolCalls)).toContain("run_code");
94
104
  expect(codeIn(turn)).toMatch(/Date|2000/);
95
105
  // The code RAN rather than being refused — but the ANSWER is
96
106
  // deliberately not asserted here, and the reason is worth knowing before
@@ -34,7 +34,12 @@ import type { SessionEvent } from "@alexkroman1/aai/protocol";
34
34
  // What no eval here can see: anything below the audio boundary. Whether a
35
35
  // dispatcher reading a callsign in bursts lands as one turn is a property of
36
36
  // endpointing, and these fake speech stages remove it.
37
- import { type EvalSession, type EvalTurn, lastStateIn } from "@alexkroman1/aai-runtime/eval";
37
+ import {
38
+ type EvalSession,
39
+ lastStateIn,
40
+ toolNames,
41
+ turnCalling,
42
+ } from "@alexkroman1/aai-runtime/eval";
38
43
  import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
39
44
  import { expect } from "vitest";
40
45
  import { z } from "zod";
@@ -100,20 +105,6 @@ const boardEntry = (events: readonly SessionEvent[], id: string) =>
100
105
  const refusalAt = (state: string) =>
101
106
  new RegExp(`Not available yet: this conversation is at [\\\\"]*${state}`);
102
107
 
103
- /**
104
- * Drive a whole shift, one dispatcher line at a time, and hand back every turn.
105
- *
106
- * The cases below assert about the turn a MECHANISM fired in rather than about
107
- * turn number two: how many turns a desk takes to get there is the model's
108
- * business and it varies, and a case pinned to a turn index is a flake with a
109
- * misleading name.
110
- */
111
- async function sayAll(session: EvalSession, lines: readonly string[]): Promise<EvalTurn[]> {
112
- const turns: EvalTurn[] = [];
113
- for (const line of lines) turns.push(await session.say(line));
114
- return turns;
115
- }
116
-
117
108
  /** Every call to `tool` across the whole shift. */
118
109
  const callsTo = (session: EvalSession, tool: string) =>
119
110
  session.toolCalls().filter((c) => c.name === tool);
@@ -200,12 +191,15 @@ describeEval(dispatchAgent, (test) => {
200
191
  test(
201
192
  "rolls units on a logged incident and follows them to monitoring",
202
193
  async ({ session }) => {
203
- const turns = await sayAll(session, [THE_CALL, "Dispatch the recommended units now."]);
194
+ const turns = await session.sayAll([THE_CALL, "Dispatch the recommended units now."]);
204
195
 
205
- const dispatching = turns.find((t) =>
206
- t.toolCalls.some((c) => c.name === "resources_dispatch"),
207
- );
208
- const rolled = dispatching?.toolCalls.find((c) => c.name === "resources_dispatch");
196
+ // The turn the dispatch fired in, whichever one that turned out to be:
197
+ // how many turns a desk spends getting there is the model's business, so
198
+ // a case pinned to turn two is a flake with a misleading name. A shift
199
+ // that never dispatched at all fails HERE, with every turn's tool list in
200
+ // the message, rather than as an `undefined` three assertions later.
201
+ const dispatching = turnCalling(turns, "resources_dispatch");
202
+ const rolled = dispatching.toolCalls.find((c) => c.name === "resources_dispatch");
209
203
  // Units really assigned — `dispatched` is empty when every requested
210
204
  // callsign was busy, which is the case the fourth test owns.
211
205
  expect(rolled?.result).toMatch(/"dispatched":\[\{/);
@@ -215,7 +209,7 @@ describeEval(dispatchAgent, (test) => {
215
209
  // The board agrees: the incident is dispatched, not merely triaged.
216
210
  expect(boardEntry(session.events(), FIRST_INCIDENT)?.status).toBe("dispatched");
217
211
  // And the order is the one the desk's flow requires: log, then dispatch.
218
- const names = session.toolCalls().map((c) => c.name);
212
+ const names = toolNames(session.toolCalls());
219
213
  expect(names.indexOf("resources_dispatch")).toBeGreaterThan(names.indexOf("incident_create"));
220
214
  },
221
215
  {
@@ -230,7 +224,7 @@ describeEval(dispatchAgent, (test) => {
230
224
  test(
231
225
  "closing an incident releases the units that were on it",
232
226
  async ({ session }) => {
233
- await sayAll(session, [
227
+ await session.sayAll([
234
228
  THE_CALL,
235
229
  "Dispatch the recommended units, emergency priority.",
236
230
  "Units report the patient is transported and they're clear. Close it out.",
@@ -252,7 +246,7 @@ describeEval(dispatchAgent, (test) => {
252
246
  // The board agrees, which is the half a browser would show.
253
247
  expect(boardEntry(session.events(), FIRST_INCIDENT)?.status).toBe("resolved");
254
248
  // And the shift ran in the order the flow requires.
255
- const names = session.toolCalls().map((c) => c.name);
249
+ const names = toolNames(session.toolCalls());
256
250
  expect(names.indexOf("incident_create")).toBeGreaterThanOrEqual(0);
257
251
  expect(names.indexOf("resources_dispatch")).toBeGreaterThan(names.indexOf("incident_create"));
258
252
  expect(names.lastIndexOf("incident_update_status")).toBeGreaterThan(
@@ -279,7 +273,7 @@ describeEval(dispatchAgent, (test) => {
279
273
  test(
280
274
  "a unit already rolling is not sent to a second call",
281
275
  async ({ session }) => {
282
- await sayAll(session, [
276
+ await session.sayAll([
283
277
  THE_CALL,
284
278
  "Send Medic-1 to Oak Street.",
285
279
  "New call: 12 Pine Lane, chest pains. Log it.",
@@ -25,7 +25,7 @@
25
25
  * tool-choice claim below then passes or fails for the wrong reason.
26
26
  */
27
27
  import agentDef from "virtual:aai/agent";
28
- import { toolResultIn } from "@alexkroman1/aai-runtime/eval";
28
+ import { toolNames, toolResultIn } from "@alexkroman1/aai-runtime/eval";
29
29
  import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
30
30
  import { expect } from "vitest";
31
31
  import { z } from "zod";
@@ -57,7 +57,7 @@ describeEval(agentDef, (test) => {
57
57
  // about voice frameworks in general.
58
58
  const turn = await session.say("Can your agents make HTTP requests?");
59
59
 
60
- expect(turn.toolCalls.map((c) => c.name)).toEqual(["search_knowledge"]);
60
+ expect(toolNames(turn.toolCalls)).toEqual(["search_knowledge"]);
61
61
  const call = turn.toolCalls[0]!;
62
62
  expect(typeof (call.args as { query?: unknown }).query).toBe("string");
63
63
  // The right entry, out of four: the one this question is really about.
@@ -81,7 +81,7 @@ describeEval(agentDef, (test) => {
81
81
  async ({ session }) => {
82
82
  const turn = await session.say("What topics can you help me with?");
83
83
 
84
- expect(turn.toolCalls.map((c) => c.name)).toEqual(["list_topics"]);
84
+ expect(toolNames(turn.toolCalls)).toEqual(["list_topics"]);
85
85
  // Every question in `knowledge.json` and nothing else — the check that
86
86
  // catches an index built from a stale copy of the asset.
87
87
  expect(toolResultIn(turn.toolCalls, "list_topics", z.array(z.string()))).toEqual(
@@ -34,14 +34,36 @@
34
34
  * tool-choice claim below then passes or fails for the wrong reason.
35
35
  */
36
36
  import agentDef from "virtual:aai/agent";
37
- import { toolResultIn } from "@alexkroman1/aai-runtime/eval";
37
+ import {
38
+ describeTurn,
39
+ type EvalToolCall,
40
+ toolArgsIn,
41
+ toolResultIn,
42
+ } from "@alexkroman1/aai-runtime/eval";
38
43
  import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
39
44
  import { expect } from "vitest";
40
45
  import { z } from "zod";
41
46
 
42
- /** The drugs an interaction check was actually asked about, lowercased. */
43
- const drugsIn = (args: Record<string, unknown>): string[] =>
44
- ((args.drugs as string[] | undefined) ?? []).map((d) => d.trim().toLowerCase());
47
+ /**
48
+ * Every drug this scope's interaction checks were actually asked about,
49
+ * lowercased.
50
+ *
51
+ * Read through `toolArgsIn` WITH a schema, which is what that reader takes one
52
+ * for: `args` is `Record<string, unknown>` on the wire — the model wrote it and
53
+ * nothing validated it — so the `args.drugs as string[] ?? []` this replaced
54
+ * turned a `drugs` the desk renamed, or never sent, into an empty list, and the
55
+ * two claims below would have been claims about nothing. A `drugs` that stops
56
+ * arriving FAILS here, naming the field.
57
+ *
58
+ * ZERO checks answers `[]` rather than throwing, which is what keeps the
59
+ * dangerous case assertable: a desk that answered an interaction question
60
+ * without asking a single label reaches the assertions with nothing in hand.
61
+ */
62
+ const InteractionArgs = z.object({ drugs: z.array(z.string()) });
63
+ const drugsAsked = (calls: readonly EvalToolCall[]): string[] =>
64
+ toolArgsIn(calls, "check_drug_interaction", InteractionArgs).flatMap((args) =>
65
+ args.drugs.map((drug) => drug.trim().toLowerCase()),
66
+ );
45
67
 
46
68
  /**
47
69
  * A refusal from `check_drug_interaction`, as the model saw it.
@@ -60,12 +82,15 @@ describeEval(agentDef, (test) => {
60
82
  const turn = await session.say("Can I take ibuprofen and warfarin together?");
61
83
 
62
84
  // It may look each drug up as well — that is fine and often useful. What
63
- // it may not do is answer this question without asking the labels.
64
- const checks = turn.toolCalls.filter((c) => c.name === "check_drug_interaction");
65
- expect(checks).not.toEqual([]);
66
- const asked = checks.flatMap((c) => drugsIn(c.args));
67
- expect(asked).toContain("ibuprofen");
68
- expect(asked).toContain("warfarin");
85
+ // it may not do is answer this question without asking the labels — and a
86
+ // desk that asked nothing arrives here with an empty list, so these two
87
+ // lines carry the never-checked finding as well as the wrong-drugs one.
88
+ // `describeTurn` is what tells them apart in the failure: "expected [] to
89
+ // contain 'ibuprofen'" does not say whether the desk called something
90
+ // else, called nothing, or was cut off mid-reply.
91
+ const asked = drugsAsked(turn.toolCalls);
92
+ expect(asked, describeTurn(turn)).toContain("ibuprofen");
93
+ expect(asked, describeTurn(turn)).toContain("warfarin");
69
94
 
70
95
  // The rule at the top of its prompt: it is not a doctor. An interaction
71
96
  // answer that does not end at a professional is the failure that makes
@@ -90,11 +115,9 @@ describeEval(agentDef, (test) => {
90
115
  // drug alone and report nothing.
91
116
  const turn = await session.say("Is it okay if I add ibuprofen for a headache?");
92
117
 
93
- const checks = turn.toolCalls.filter((c) => c.name === "check_drug_interaction");
94
- expect(checks).not.toEqual([]);
95
- const asked = checks.flatMap((c) => drugsIn(c.args));
96
- expect(asked).toContain("warfarin");
97
- expect(asked).toContain("ibuprofen");
118
+ const asked = drugsAsked(turn.toolCalls);
119
+ expect(asked, describeTurn(turn)).toContain("warfarin");
120
+ expect(asked, describeTurn(turn)).toContain("ibuprofen");
98
121
  },
99
122
  {
100
123
  stubReply: [
@@ -26,15 +26,13 @@
26
26
 
27
27
  import { createServer, type Server } from "node:http";
28
28
  import type { AddressInfo } from "node:net";
29
+ import { stubGatewayRoute } from "@alexkroman1/aai/testing";
29
30
  import { installStubStepFetch } from "@alexkroman1/aai/testing/vitest";
30
31
  import { describeWorkflowEval } from "@alexkroman1/aai-runtime/eval/vitest";
31
32
  import { expect, onTestFinished } from "vitest";
32
33
  import agentDef, { digest } from "./agent.ts";
33
34
  import { SETTLE_MS } from "./workflows/digest.ts";
34
35
 
35
- /** The gateway leg, so one handler can route the page and the model apart. */
36
- const isModelCall = (url: string): boolean => url.includes("/chat/completions");
37
-
38
36
  /**
39
37
  * A page with an ANSWER in it, so "did it summarize what it fetched" is a
40
38
  * question with a right answer rather than a vibe.
@@ -107,15 +105,25 @@ async function servePage(html: string): Promise<string> {
107
105
  *
108
106
  * ONE handler, because publishing a `stepFetch` REPLACES — a flow that fetches a
109
107
  * page and calls a model cannot install two fakes, so it routes by URL. The
110
- * recorded calls are what makes the prompt assertable, which is the only way to
111
- * check what the model was SHOWN rather than what it said.
108
+ * routing is `stubGatewayRoute`'s rather than this file's: it answers the
109
+ * completion request and `undefined` for everything else, which is what makes
110
+ * the page the `??` arm — and it routes off the SDK's own completions PATH, so a
111
+ * case cannot pass because the fake and the step agree on a typo. That matters
112
+ * more here than it looks: the envelope is a WIRE shape, so a field typed one
113
+ * off does not fail — `stepGenerate` reads no content and reports an empty
114
+ * completion, and the case blames the digest.
115
+ *
116
+ * The recorded calls are what makes the prompt assertable, which is the only way
117
+ * to check what the model was SHOWN rather than what it said — and they come
118
+ * back DECODED, so the last case reads `prompt` rather than the raw request
119
+ * body, which is the whole serialized request.
112
120
  */
113
121
  function scriptBothLegs(html: string, reply = SCRIPTED_DIGEST) {
114
- return installStubStepFetch((request) =>
115
- isModelCall(request.url)
116
- ? { body: { choices: [{ message: { content: reply } }] } }
117
- : { body: html, headers: { "Content-Type": "text/html" } },
122
+ const model = stubGatewayRoute(reply);
123
+ installStubStepFetch(
124
+ (request) => model.route(request) ?? { body: html, headers: { "Content-Type": "text/html" } },
118
125
  );
126
+ return model;
119
127
  }
120
128
 
121
129
  describeWorkflowEval(agentDef, (test) => {
@@ -180,18 +188,19 @@ describeWorkflowEval(agentDef, (test) => {
180
188
  "</body>",
181
189
  `<p>${"padding sentence about otters. ".repeat(2000)}</p></body>`,
182
190
  );
183
- const legs = scriptBothLegs(oversized);
191
+ const model = scriptBothLegs(oversized);
184
192
 
185
193
  const run = await app.run(digest, { url: "https://example.test/otters" });
186
194
  expect(run.status).toBe("completed");
187
195
 
188
- const prompt = String(legs.calls.find((call) => isModelCall(call.url))?.body ?? "");
189
- expect(prompt).toContain("Sea otters are one of the few mammals that use tools");
196
+ const asked = model.calls[0];
197
+ if (asked === undefined) expect.fail("the run must have shown the article to the model");
198
+ expect(asked.prompt).toContain("Sea otters are one of the few mammals that use tools");
190
199
  // Neither the script's instruction nor the stylesheet reached the prompt.
191
- expect(prompt).not.toContain(SMUGGLED);
192
- expect(prompt).not.toContain("rebeccapurple");
200
+ expect(asked.prompt).not.toContain(SMUGGLED);
201
+ expect(asked.prompt).not.toContain("rebeccapurple");
193
202
  // And the text was CAPPED on the way across the queue. 24k characters plus
194
203
  // the prompt's own framing, well under the ~60k this page would otherwise be.
195
- expect(prompt.length).toBeLessThan(30_000);
204
+ expect(asked.prompt.length).toBeLessThan(30_000);
196
205
  });
197
206
  });
@@ -14,27 +14,38 @@
14
14
  // back with as well as the code the tutor wrote.
15
15
 
16
16
  import agentDef from "virtual:aai/agent";
17
- import { createVmRunCode, toolResultsIn } from "@alexkroman1/aai-runtime/eval";
17
+ import {
18
+ createVmRunCode,
19
+ type EvalTurn,
20
+ toolArgsIn,
21
+ toolNames,
22
+ toolResultsIn,
23
+ } from "@alexkroman1/aai-runtime/eval";
18
24
  import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
19
25
  import { expect } from "vitest";
26
+ import { z } from "zod";
20
27
 
21
- /** The code every `run_code` call in this turn carried, joined. */
22
- const codeIn = (turn: { toolCalls: readonly { name: string; args: Record<string, unknown> }[] }) =>
23
- turn.toolCalls
24
- .filter((c) => c.name === "run_code")
25
- .map((c) => String(c.args.code ?? ""))
28
+ /**
29
+ * The code every `run_code` call in this turn carried, joined.
30
+ *
31
+ * Read through `toolArgsIn` WITH a schema, which is what that reader takes one
32
+ * for: `args` is `Record<string, unknown>` on the wire — the tutor wrote it and
33
+ * nothing validated it — so the `String(c.args.code ?? "")` this replaced turned
34
+ * an argument the model renamed, or never sent, into `""`. The claims below are
35
+ * about the recipe the tutor wrote, and against `""` every one of them would
36
+ * have been a claim about nothing. A `code` that stops arriving FAILS here,
37
+ * naming the field.
38
+ */
39
+ const RunCodeArgs = z.object({ code: z.string() });
40
+ const codeIn = (turn: EvalTurn) =>
41
+ toolArgsIn(turn.toolCalls, "run_code", RunCodeArgs)
42
+ .map((args) => args.code)
26
43
  .join("\n");
27
44
 
28
45
  /**
29
- * A `run_code` executor, so these cases can assert the ANSWER.
30
- *
31
- * The builtin refuses without one — the Modal container is the security
32
- * boundary, and off-platform there is none — so a case could assert the CALL and
33
- * the code it carried, and never what the code came back with.
34
- * `createVmRunCode()` is a `node:vm` context with a capturing `console.log`,
35
- * which is enough here: what runs is arithmetic, not a program. It is NOT a
36
- * sandbox and does not pretend to be one; a deployed agent still gets the
37
- * refusal.
46
+ * A `run_code` executor, so these cases can assert the answer the tutor's code
47
+ * came back with and not merely the code — `createVmRunCode`'s own doc carries
48
+ * why the builtin refuses without one.
38
49
  */
39
50
  const runCode = createVmRunCode();
40
51
 
@@ -49,7 +60,7 @@ describeEval(
49
60
  // The prompt hands the tutor the factors; the finding it guards against
50
61
  // is a tutor that recites a remembered figure instead. A factor in the
51
62
  // code is the evidence that the conversion was computed, not recalled.
52
- expect(turn.toolCalls.map((c) => c.name)).toContain("run_code");
63
+ expect(toolNames(turn.toolCalls)).toContain("run_code");
53
64
  const code = codeIn(turn);
54
65
  expect(code).toContain("5");
55
66
  expect(code).toMatch(/1\.60|1\.61|0\.621|8\.04/);
@@ -69,7 +80,7 @@ describeEval(
69
80
  // A model asked for dice will happily make three numbers up, and the
70
81
  // reply is indistinguishable from a real roll. `Math.random` in the code
71
82
  // is the only thing that tells them apart.
72
- expect(turn.toolCalls.map((c) => c.name)).toContain("run_code");
83
+ expect(toolNames(turn.toolCalls)).toContain("run_code");
73
84
  const code = codeIn(turn);
74
85
  expect(code).toMatch(/Math\.random/);
75
86
  expect(code).toContain("20");
@@ -29,7 +29,10 @@ import type { SessionEvent } from "@alexkroman1/aai/protocol";
29
29
  import {
30
30
  createVmRunCode,
31
31
  customEventsIn,
32
+ describeTurn,
32
33
  lastStateIn,
34
+ toolArgsIn,
35
+ toolNames,
33
36
  toolResultIn,
34
37
  } from "@alexkroman1/aai-runtime/eval";
35
38
  import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
@@ -66,17 +69,23 @@ const pushedRecs = (events: readonly SessionEvent[]) =>
66
69
  const nudges = (events: readonly SessionEvent[]) => customEventsIn(events, "wind_down");
67
70
 
68
71
  /**
69
- * A `run_code` executor, so the sleep-cycle case can assert the ANSWER.
70
- *
71
- * The builtin refuses without one the Modal container is the security
72
- * boundary, and off-platform there is none — which left this template's
73
- * headline feature assertable as a CALL and never as a number. A `node:vm`
74
- * context with a capturing `console.log` is what a developer would reach for on
75
- * their own machine, and `createVmRunCode()` is exactly that: the code under
76
- * test is arithmetic the model wrote, not a program.
72
+ * A `run_code` executor, so the sleep-cycle case can assert the bedtime NUMBER
73
+ * and not merely the call — this template's headline feature is the arithmetic.
74
+ * `createVmRunCode`'s own doc carries why the builtin refuses without one.
77
75
  */
78
76
  const runCode = createVmRunCode();
79
77
 
78
+ /**
79
+ * The `code` argument a `run_code` call carries.
80
+ *
81
+ * The schema is what `toolArgsIn` takes one for: `args` is
82
+ * `Record<string, unknown>` on the wire — the model wrote it and nothing
83
+ * validated it — so the `String(c.args.code ?? "")` this replaced turned an
84
+ * argument the companion renamed, or never sent, into `""`, and the two
85
+ * constants asserted below would have been looked for in nothing at all.
86
+ */
87
+ const RunCodeArgs = z.object({ code: z.string() });
88
+
80
89
  /** Two-digit, for the clock arithmetic below. */
81
90
  const pad = (n: number): string => String(n).padStart(2, "0");
82
91
 
@@ -113,7 +122,7 @@ describeEval(
113
122
 
114
123
  // "to watch" is the category and "cozy" is the mood; the shelf is the
115
124
  // tool's, so answering from the model's own taste is the regression.
116
- expect(turn.toolCalls.map((c) => c.name)).toEqual(["recommend"]);
125
+ expect(toolNames(turn.toolCalls)).toEqual(["recommend"]);
117
126
  const call = turn.toolCalls[0]!;
118
127
  expect(call.args).toEqual({ category: "movie", mood: "cozy" });
119
128
 
@@ -139,7 +148,7 @@ describeEval(
139
148
  await session.say("I want something cozy to watch tonight.");
140
149
  const turn = await session.say("Now give me something spooky to read.");
141
150
 
142
- expect(turn.toolCalls.map((c) => c.name)).toEqual(["recommend"]);
151
+ expect(toolNames(turn.toolCalls)).toEqual(["recommend"]);
143
152
  expect(turn.toolCalls[0]!.args).toEqual({ category: "book", mood: "spooky" });
144
153
 
145
154
  // The slot survived the turn boundary: the frame the page renders after
@@ -197,15 +206,21 @@ describeEval(
197
206
  "I need to be up at 7 in the morning. When should I fall asleep?",
198
207
  );
199
208
 
209
+ // The CALLS, not their arguments: what this asserts is that the companion
210
+ // reached for code at all, and the results are read off the same list
211
+ // below. `toolArgsIn` answers the other half, the code it submitted.
200
212
  const ran = turn.toolCalls.filter((c) => c.name === "run_code");
201
- expect(
202
- ran,
203
- `tools called: [${turn.toolCalls.map((c) => c.name).join(", ")}]; said: ${turn.text}`,
204
- ).not.toEqual([]);
213
+ // `describeTurn` is the message: "expected [] not to equal []" says
214
+ // nothing about a companion that talked its way through the sum
215
+ // instead, and it names a cancelled reply, which is the usual reason a
216
+ // turn reached for nothing at all.
217
+ expect(ran, describeTurn(turn)).not.toEqual([]);
205
218
  // The recipe is the prompt's, and it is two constants: a 90-minute cycle
206
219
  // plus the 15 minutes it takes to fall asleep. Arithmetic done in the
207
220
  // model's head has neither of them anywhere in the code.
208
- const code = ran.map((c) => String(c.args.code ?? "")).join("\n");
221
+ const code = toolArgsIn(turn.toolCalls, "run_code", RunCodeArgs)
222
+ .map((args) => args.code)
223
+ .join("\n");
209
224
  expect(code).toContain("90");
210
225
  expect(code).toContain("15");
211
226