@alexkroman1/aai-cli 13.0.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
@@ -16,33 +16,43 @@
16
16
  // live rates API.
17
17
 
18
18
  import agentDef from "virtual:aai/agent";
19
- import { createVmRunCode, toolResultsIn } from "@alexkroman1/aai-runtime/eval";
19
+ import {
20
+ createVmRunCode,
21
+ type EvalTurn,
22
+ toolArgsIn,
23
+ toolResultsIn,
24
+ } from "@alexkroman1/aai-runtime/eval";
20
25
  import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
21
26
  import { expect } from "vitest";
27
+ import { z } from "zod";
22
28
 
23
- type Turn = { toolCalls: readonly { name: string; args: Record<string, unknown> }[] };
29
+ /**
30
+ * The arguments Penny's two builtins carry, as the wire has them.
31
+ *
32
+ * Schemas rather than `String(args.code ?? "")`, which is what `toolArgsIn`
33
+ * takes one for: `args` is `Record<string, unknown>` — the model wrote it and
34
+ * nothing validated it — so an argument Penny renamed, or never sent, used to
35
+ * read as `""`, and the claims below about the code she submitted and the URL
36
+ * she asked for would have been claims about an empty string. An argument that
37
+ * stops arriving FAILS here, naming the field.
38
+ */
39
+ const RunCodeArgs = z.object({ code: z.string() });
40
+ const FetchJsonArgs = z.object({ url: z.string() });
24
41
 
25
42
  /** The code every `run_code` call in this turn carried, joined. */
26
- const codeIn = (turn: Turn) =>
27
- turn.toolCalls
28
- .filter((c) => c.name === "run_code")
29
- .map((c) => String(c.args.code ?? ""))
43
+ const codeIn = (turn: EvalTurn) =>
44
+ toolArgsIn(turn.toolCalls, "run_code", RunCodeArgs)
45
+ .map((args) => args.code)
30
46
  .join("\n");
31
47
 
32
48
  /** Every URL this turn's `fetch_json` calls asked for. */
33
- const fetchedUrls = (turn: Turn) =>
34
- turn.toolCalls.filter((c) => c.name === "fetch_json").map((c) => String(c.args.url ?? ""));
49
+ const fetchedUrls = (turn: EvalTurn) =>
50
+ toolArgsIn(turn.toolCalls, "fetch_json", FetchJsonArgs).map((args) => args.url);
35
51
 
36
52
  /**
37
- * A `run_code` executor, so these cases can assert the ANSWER.
38
- *
39
- * The builtin refuses without one the Modal container is the security
40
- * boundary, and off-platform there is none — so a case could assert the CALL and
41
- * the code it carried, and never what the code came back with.
42
- * `createVmRunCode()` is a `node:vm` context with a capturing `console.log`,
43
- * which is enough here: what runs is arithmetic, not a program. It is NOT a
44
- * sandbox and does not pretend to be one; a deployed agent still gets the
45
- * refusal.
53
+ * A `run_code` executor, so the arithmetic cases can assert the ANSWER and not
54
+ * merely the call — `createVmRunCode`'s own doc carries why the builtin refuses
55
+ * without one. `fetch_json` needs nothing of the sort: it makes a real request.
46
56
  */
47
57
  const runCode = createVmRunCode();
48
58
 
@@ -13,7 +13,7 @@ import type { SessionEvent } from "@alexkroman1/aai/protocol";
13
13
  // SCRIPTED model (its `stubReply`), which still boots this agent, still
14
14
  // resolves `tools/`, and still executes the tool a script names — so a stub run
15
15
  // proves the wiring and proves nothing about what the agent chose.
16
- import { lastStateIn, toolResultIn } from "@alexkroman1/aai-runtime/eval";
16
+ import { lastStateIn, statesIn, toolNames, toolResultIn } from "@alexkroman1/aai-runtime/eval";
17
17
  import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
18
18
  import { expect } from "vitest";
19
19
  import { z } from "zod";
@@ -44,9 +44,14 @@ const ProjectedOrder = z.object({
44
44
  */
45
45
  const lastPushedView = (events: readonly SessionEvent[]) => lastStateIn(events, ProjectedOrder);
46
46
 
47
- /** Every cart the session pushed, in stream order. */
48
- const pushedViews = (events: readonly SessionEvent[]) =>
49
- events.flatMap((e) => (e.type === "state.updated" ? [ProjectedOrder.parse(e.state)] : []));
47
+ /**
48
+ * Every cart the session pushed, in stream order — `statesIn` is `lastStateIn`'s
49
+ * plural half, and takes the schema for the same reason.
50
+ *
51
+ * The SEQUENCE is the stronger claim: not "the cart is not placed now" but "no
52
+ * frame the customer ever saw showed it placed".
53
+ */
54
+ const pushedViews = (events: readonly SessionEvent[]) => statesIn(events, ProjectedOrder);
50
55
 
51
56
  describeEval(agentDef, (test) => {
52
57
  test(
@@ -58,7 +63,7 @@ describeEval(agentDef, (test) => {
58
63
 
59
64
  // One tool, and the right one: quoting a price without adding the pizza,
60
65
  // or adding it twice, are both real findings.
61
- expect(turn.toolCalls.map((c) => c.name)).toEqual(["add_pizza"]);
66
+ expect(toolNames(turn.toolCalls)).toEqual(["add_pizza"]);
62
67
  const call = turn.toolCalls[0]!;
63
68
  const args = call.args as { size: string; crust: string; toppings: string[] };
64
69
  expect(args.size).toBe("large");
@@ -106,7 +111,7 @@ describeEval(agentDef, (test) => {
106
111
  // turn leaves the model with nothing to address.
107
112
  const turn = await session.say("Actually, make that a large.");
108
113
 
109
- expect(turn.toolCalls.map((c) => c.name)).toEqual(["update_pizza"]);
114
+ expect(toolNames(turn.toolCalls)).toEqual(["update_pizza"]);
110
115
  const call = turn.toolCalls[0]!;
111
116
  expect(call.args).toMatchObject({ pizza_id: 1, size: "large" });
112
117
 
@@ -24,7 +24,13 @@
24
24
 
25
25
  /** The def a DEPLOYED agent runs — see `agent.test.ts` on why the glob is here. */
26
26
  import agentDef from "virtual:aai/agent";
27
- import { type EvalSession, lastStateIn } from "@alexkroman1/aai-runtime/eval";
27
+ import {
28
+ describeToolCalls,
29
+ describeTurn,
30
+ type EvalSession,
31
+ lastStateIn,
32
+ toolNames,
33
+ } from "@alexkroman1/aai-runtime/eval";
28
34
  import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
29
35
  import { expect } from "vitest";
30
36
  import { z } from "zod";
@@ -57,8 +63,6 @@ const ProjectedPlan = z.object({
57
63
  */
58
64
  const planState = (session: EvalSession) => lastStateIn(session.events(), ProjectedPlan);
59
65
 
60
- const named = (calls: readonly { name: string }[]): string[] => calls.map((call) => call.name);
61
-
62
66
  describeEval(agentDef, (test) => {
63
67
  test(
64
68
  "the stage the desk reports is the flow's, not a guess at the plan",
@@ -122,7 +126,10 @@ describeEval(agentDef, (test) => {
122
126
  "I want to work out whether it is cheaper to take the train or fly from London to Edinburgh next month.",
123
127
  );
124
128
  const started = session.toolCalls().find((call) => call.name === "start_plan");
125
- expect(started, `tools called: ${named(session.toolCalls()).join(", ")}`).toBeDefined();
129
+ // The whole SESSION's calls, not one turn's: the plan may be started on
130
+ // either utterance, and "expected undefined to be defined" says nothing
131
+ // about a desk that talked instead. `describeToolCalls` is that sentence.
132
+ expect(started, describeToolCalls(session.toolCalls())).toBeDefined();
126
133
  const planned = planState(session);
127
134
  // The tool's own result rides in the message: a planner that FAILED (a
128
135
  // gateway error, a schema the provider would not honour) writes nothing,
@@ -131,8 +138,8 @@ describeEval(agentDef, (test) => {
131
138
  expect(planned?.plan.length ?? 0).toBeGreaterThan(0);
132
139
 
133
140
  const worked = await session.say("Yes, go ahead and start on it.");
134
- const calls = named(worked.toolCalls).filter((name) => name === "work_next_step").length;
135
- expect(calls, `tools called: [${named(worked.toolCalls).join(", ")}]`).toBeGreaterThan(0);
141
+ const calls = toolNames(worked.toolCalls).filter((name) => name === "work_next_step").length;
142
+ expect(calls, describeTurn(worked)).toBeGreaterThan(0);
136
143
 
137
144
  const after = planState(session);
138
145
  const done = after?.done ?? [];
@@ -180,7 +187,7 @@ describeEval(agentDef, (test) => {
180
187
  const revised = turn.toolCalls.find((call) => call.name === "revise_plan");
181
188
  expect(
182
189
  revised,
183
- `tools called: ${named(turn.toolCalls).join(", ")} — the caller changed the objective, ` +
190
+ `${describeTurn(turn)} — the caller changed the objective, ` +
184
191
  "so this is `revise_plan`, not a plan rewritten by hand",
185
192
  ).toBeDefined();
186
193
  expect(revised?.args.instruction).toBeTruthy();
@@ -62,8 +62,15 @@
62
62
  * this file SHIPS — see `agent.test.ts`.
63
63
  */
64
64
  import agentDef from "virtual:aai/agent";
65
+ import { stubGatewayRoute } from "@alexkroman1/aai/testing";
65
66
  import { installStubStepFetch } from "@alexkroman1/aai/testing/vitest";
66
- import type { EvalToolCall, EvalWorkflows } from "@alexkroman1/aai-runtime/eval";
67
+ import {
68
+ describeToolCalls,
69
+ type EvalToolCall,
70
+ type EvalWorkflows,
71
+ toolResultIn,
72
+ toolResultsIn,
73
+ } from "@alexkroman1/aai-runtime/eval";
67
74
  import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
68
75
  import { expect } from "vitest";
69
76
  import { z } from "zod";
@@ -116,17 +123,24 @@ type ScriptedProvider = {
116
123
  * rather than answering an empty 200: a step calling something nobody expected
117
124
  * is a finding, where an empty body reads as a provider that said nothing.
118
125
  *
126
+ * The model leg is `stubGatewayRoute`'s, first, because it is the one leg whose
127
+ * shape this file cannot check: the completion envelope is a WIRE shape, so a
128
+ * field typed one off does not fail — `stepGenerate` reads no content, reports
129
+ * an empty completion, and the case blames the recap. The reader routes off the
130
+ * SDK's own completions PATH and answers `undefined` for everything else, which
131
+ * is what lets it sit in front of the three batch legs below it.
132
+ *
119
133
  * `hold` keeps the FIRST poll pending, and it is the only way to observe a run
120
134
  * that is still going: a durable `sleep` is skipped here, so an unheld run
121
135
  * burns its whole poll loop in milliseconds.
122
136
  */
123
137
  function stubProvider(options: { hold?: boolean; ending?: Ending } = {}): ScriptedProvider {
124
138
  const gate = Promise.withResolvers<void>();
139
+ const model = stubGatewayRoute(RECAP_JSON);
125
140
  let polls = 0;
126
141
  const stub = installStubStepFetch(async (request) => {
127
- if (request.url.includes("/chat/completions")) {
128
- return { body: { choices: [{ message: { content: RECAP_JSON } }] } };
129
- }
142
+ const recapped = model.route(request);
143
+ if (recapped) return recapped;
130
144
  if (request.method === "POST") return { body: { id: TRANSCRIPT_ID, status: "queued" } };
131
145
  // The compensation. A real DELETE removes the transcript from the account,
132
146
  // which is what makes "a failed run leaves nothing behind" a claim rather
@@ -158,21 +172,32 @@ const Cancelled = z.object({ cancelled: z.boolean(), note: z.string() });
158
172
  *
159
173
  * Parsed rather than regexed: a tool result reaches the event stream as a
160
174
  * serialized string, and a shape that stopped matching should fail HERE naming
161
- * the field instead of handing the next assertion `undefined`.
175
+ * the field instead of handing the next assertion `undefined`. That is
176
+ * `toolResultsIn`'s job, and the half it does better than the filter-and-map
177
+ * this was: a call with no result THROWS naming its position, where
178
+ * `one.result !== undefined` dropped it — so a tool that was called and never
179
+ * returned left a shorter list and a case that read the calls it did get.
162
180
  */
163
- function recapStarts(calls: readonly EvalToolCall[]): z.infer<typeof RecapStart>[] {
164
- return calls
165
- .filter((one) => one.name === "request_recap" && one.result !== undefined)
166
- .map((one) => RecapStart.parse(JSON.parse(String(one.result))));
181
+ function recapStarts(calls: readonly EvalToolCall[]): readonly z.infer<typeof RecapStart>[] {
182
+ return toolResultsIn(calls, "request_recap", RecapStart);
167
183
  }
168
184
 
169
- /** The run id the first `request_recap` of this turn reported. */
185
+ /**
186
+ * The run id the FIRST `request_recap` of this turn reported.
187
+ *
188
+ * `recapStarts` rather than `toolResultIn`: a turn is allowed more than one call
189
+ * here — the case below says so in as many words, a desk that asks twice being
190
+ * the model's business — and the singular reader refuses a second one on
191
+ * purpose. What is asserted about the extras is that each was REFUSED with the
192
+ * run this found.
193
+ */
170
194
  function startedRunId(calls: readonly EvalToolCall[]): string {
171
195
  const [first] = recapStarts(calls);
172
196
  if (first === undefined) {
173
- throw new Error(
174
- `the desk called no request_recap: ${calls.map((one) => one.name).join(", ") || "(no tools)"}`,
175
- );
197
+ // `describeToolCalls` is the harness's own sentence for this — including
198
+ // "called no tools", which is the answer a desk that asked a question
199
+ // instead of acting gives and the one an empty list reads as truncation.
200
+ throw new Error(`no request_recap in this turn: ${describeToolCalls(calls)}`);
176
201
  }
177
202
  return first.runId;
178
203
  }
@@ -207,14 +232,19 @@ const START_TURN = [
207
232
  * Not tidiness: the scripted provider is unpublished when the test that
208
233
  * installed it finishes, so a body still mid-flight would make its next request
209
234
  * against whatever the next case publishes — or against the real provider, with
210
- * a real key. A run drained here COMPLETES rather than failing, and the last
211
- * thing it does on the way is delete its own transcript: the retention gate's
212
- * window closes with nobody having answered, which is the safe default. See the
213
- * header, and the case that pins it.
235
+ * a real key. `close()` says so on stderr (`EvalRunAbandoned`) when a case
236
+ * forgets, which is a report rather than a fix the wait is `settleAll`'s and
237
+ * the RELEASE is this template's, because what holds the run in flight is this
238
+ * file's own gate and nothing in the harness can open one.
239
+ *
240
+ * A run drained here COMPLETES rather than failing, and the last thing it does
241
+ * on the way is delete its own transcript: the retention gate's window closes
242
+ * with nobody having answered, which is the safe default. See the header, and
243
+ * the case that pins it.
214
244
  */
215
245
  async function drain(workflows: EvalWorkflows | undefined, provider: ScriptedProvider) {
216
246
  provider.release();
217
- for (const run of await (workflows?.runs() ?? [])) await workflows?.settle(run.runId, recap);
247
+ await workflows?.settleAll();
218
248
  }
219
249
 
220
250
  describeEval(
@@ -328,8 +358,7 @@ describeEval(
328
358
  const runId = startedRunId(started.toolCalls);
329
359
  const turn = await session.say("Forget it — cancel that, please.");
330
360
 
331
- const cancel = turn.toolCalls.find((one) => one.name === "cancel_recap");
332
- const answer = Cancelled.parse(JSON.parse(String(cancel?.result)));
361
+ const answer = toolResultIn(turn.toolCalls, "cancel_recap", Cancelled);
333
362
  expect(answer.cancelled).toBe(true);
334
363
  // The sentence is a documented promise of this template, not a
335
364
  // decoration: cancellation is NOT cooperative here, so the transcript
@@ -29,6 +29,7 @@
29
29
  // journal, no replay, and no per-step retry, so a rate-limited live run FAILS
30
30
  // where a deployed one would have ridden it out. The tier that really resumes a
31
31
  // run is `aai-cli`'s `dev-workflow.scenario.test.ts`.
32
+ import { stubGatewayRoute } from "@alexkroman1/aai/testing";
32
33
  import { installStubStepFetch } from "@alexkroman1/aai/testing/vitest";
33
34
  import { describeWorkflowEval } from "@alexkroman1/aai-runtime/eval/vitest";
34
35
  import { expect } from "vitest";
@@ -52,32 +53,36 @@ const critique = (verdict: "ship" | "revise", score = 8): string =>
52
53
  notes: verdict === "ship" ? [] : ["Say what happens to the handover", "Name the start date"],
53
54
  });
54
55
 
55
- /** One gateway reply, in the envelope `stepGenerate` reads. */
56
- const reply = (content: string) => ({ body: { choices: [{ message: { content } }] } });
57
-
58
56
  /**
59
57
  * Answer the gateway with `contents`, in order, and record what each stage asked.
60
58
  *
61
- * The last reply repeats, matching `stubGateway`'s convention a loop cannot
62
- * know how many calls it will make, and a script that ran out mid-loop would
63
- * fail on the script rather than on the code. `installStubStepFetch` rather than
64
- * `installStubGateway`: `stepGenerate` goes through the published `stepFetch`
65
- * slot, and a published slot BEATS a stubbed global, so stubbing the global here
66
- * would test a path production does not take.
59
+ * `stubGatewayRoute` owns both halves this file used to hand-write. The
60
+ * ENVELOPE, because it is a WIRE shape and a field typed one off does not fail —
61
+ * `stepGenerate` reads no content, reports an empty completion, and the case
62
+ * blames the loop. And the CURSOR: the last reply repeats, because a loop cannot
63
+ * know how many calls it will make, so a script that said one thing forever
64
+ * could only ever drive it into its budget and one that ran out mid-loop would
65
+ * fail on the script rather than on the code. Both of those are now the SDK's
66
+ * one copy rather than this file's second.
67
+ *
68
+ * `installStubStepFetch` rather than `installStubGateway`: `stepGenerate` goes
69
+ * through the published `stepFetch` slot, and a published slot BEATS a stubbed
70
+ * global, so stubbing the global here would test a path production does not
71
+ * take. Anything that is not a completion request THROWS — every step in this
72
+ * body is a model call, so a request the route does not recognise is a finding,
73
+ * and answering it with a reply anyway is how a stage that started dialling
74
+ * something else would pass.
67
75
  */
68
76
  function scriptGateway(contents: readonly string[]) {
69
- let next = 0;
70
- const fetched = installStubStepFetch(() => {
71
- const content = contents.at(Math.min(next, contents.length - 1)) ?? "";
72
- next += 1;
73
- return reply(content);
77
+ const model = stubGatewayRoute(contents);
78
+ installStubStepFetch((request) => {
79
+ const answered = model.route(request);
80
+ if (answered === undefined) {
81
+ throw new Error(`unexpected step request in an eval: ${request.method} ${request.url}`);
82
+ }
83
+ return answered;
74
84
  });
75
- return fetched;
76
- }
77
-
78
- /** Every prompt the gateway was sent, in call order. */
79
- function promptsOf(fetched: ReturnType<typeof scriptGateway>): string[] {
80
- return fetched.calls.map((call) => String(call.body ?? ""));
85
+ return model;
81
86
  }
82
87
 
83
88
  describeWorkflowEval(agentDef, (test) => {
@@ -140,7 +145,7 @@ describeWorkflowEval(agentDef, (test) => {
140
145
  // running, and asking it and then accepting whatever it says is not evidence
141
146
  // about the budget. What this pins is the loop's arithmetic — the half a
142
147
  // live case cannot reach.
143
- const fetched = scriptGateway([
148
+ const model = scriptGateway([
144
149
  DRAFT,
145
150
  critique("revise", 4),
146
151
  `${DRAFT} It starts on the first Monday of the month.`,
@@ -170,12 +175,15 @@ describeWorkflowEval(agentDef, (test) => {
170
175
  ).toBe(true);
171
176
  // One draft plus a critique-and-revise pair per round. A loop that critiqued
172
177
  // twice, or revised the round it shipped, changes this number.
173
- expect(fetched.calls).toHaveLength(1 + 2 * MAX_ROUNDS);
178
+ expect(model.calls).toHaveLength(1 + 2 * MAX_ROUNDS);
174
179
 
175
180
  // `briefBlock` is what keeps the three stages from drifting apart, and this is
176
181
  // the assertion behind that claim: the writer, the critic AND the reviser were
177
- // all shown the same brief and the same must-cover point.
178
- const prompts = promptsOf(fetched);
182
+ // all shown the same brief and the same must-cover point. Read off the
183
+ // recorded `prompt` — the USER message — rather than off the raw request
184
+ // body, which is the whole serialized request and would let a `model` id or a
185
+ // `temperature` satisfy one of these `toContain`s.
186
+ const prompts = model.calls.map((call) => call.prompt);
179
187
  expect(prompts).toHaveLength(1 + 2 * MAX_ROUNDS);
180
188
  for (const prompt of prompts) {
181
189
  expect(prompt).toContain("quokka");
@@ -29,8 +29,9 @@
29
29
  // endpointing, barge-in, whether two sentences merged into one turn.
30
30
 
31
31
  import agentDef from "virtual:aai/agent";
32
+ import { stubGatewayRoute } from "@alexkroman1/aai/testing";
32
33
  import { installStubStepFetch } from "@alexkroman1/aai/testing/vitest";
33
- import type { EvalToolCall, EvalWorkflows } from "@alexkroman1/aai-runtime/eval";
34
+ import { type EvalToolCall, type EvalWorkflows, toolResultIn } from "@alexkroman1/aai-runtime/eval";
34
35
  import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
35
36
  import { expect } from "vitest";
36
37
  import { z } from "zod";
@@ -112,7 +113,17 @@ type ScriptedSteps = {
112
113
  * on the live path, so a live case still measures the agent. Anything that is
113
114
  * not the gateway THROWS rather than answering 200: an unexpected request from
114
115
  * a step is a finding, and a silent empty body would be read as a model that
115
- * said nothing.
116
+ * said nothing. `stubGatewayRoute` is what decides which is which, and it
117
+ * decides on the SDK's own completions PATH — so the script cannot come unstuck
118
+ * from the step by the two agreeing on a typo, and the envelope it answers with
119
+ * is the SDK's rather than this file's. That last part is the one worth having:
120
+ * the envelope is a WIRE shape, so a field typed one off does not fail —
121
+ * `stepGenerate` reads no content, reports an empty completion, and the case
122
+ * blames the run.
123
+ *
124
+ * The CURSOR is the reader's too: the last reply repeats, which is what a stage
125
+ * that legitimately calls the model twice needs and what stops a script running
126
+ * out mid-run and failing on itself.
116
127
  *
117
128
  * `hold` keeps the FIRST answer pending, which is the only way to observe a run
118
129
  * that is still going: a durable `sleep` is skipped here, so an unheld run
@@ -120,15 +131,17 @@ type ScriptedSteps = {
120
131
  */
121
132
  function scriptSteps(options: { hold?: boolean } = {}): ScriptedSteps {
122
133
  const gate = Promise.withResolvers<void>();
123
- let served = 0;
134
+ const model = stubGatewayRoute(MODEL_SCRIPT);
124
135
  const stub = installStubStepFetch(async (request) => {
125
- if (!request.url.includes("/chat/completions")) {
136
+ const answered = model.route(request);
137
+ if (answered === undefined) {
126
138
  throw new Error(`unexpected step request in an eval: ${request.method} ${request.url}`);
127
139
  }
128
- const at = served++;
129
- if (options.hold === true && at === 0) await gate.promise;
130
- const content = MODEL_SCRIPT[Math.min(at, MODEL_SCRIPT.length - 1)];
131
- return { body: { choices: [{ message: { content } }] } };
140
+ // `model.calls` has already recorded this one, so a length of 1 IS the first
141
+ // answer and holding after the route rather than before it keeps the reply
142
+ // this returns the one the script owed that call.
143
+ if (options.hold === true && model.calls.length === 1) await gate.promise;
144
+ return answered;
132
145
  });
133
146
  return { calls: stub.calls, release: () => gate.resolve() };
134
147
  }
@@ -141,20 +154,16 @@ const Started = z.object({
141
154
  });
142
155
 
143
156
  /**
144
- * The run id a `request_research` call reported.
157
+ * The run id the `request_research` call reported.
145
158
  *
146
- * Parsed rather than regexed: a tool result reaches the event stream as a
147
- * serialized string, and a shape that stopped matching should fail HERE naming
148
- * the field instead of handing the next assertion `undefined`.
159
+ * `toolResultIn` rather than a `find` and a parse: a tool result reaches the
160
+ * event stream as a serialized string, and a shape that stopped matching should
161
+ * fail HERE naming the field instead of handing the next assertion `undefined`.
162
+ * It throws for the two other ways this can go wrong as well, each naming what
163
+ * was really called — no such call, and a call that never returned.
149
164
  */
150
165
  function startedRunId(calls: readonly EvalToolCall[]): string {
151
- const call = calls.find((one) => one.name === "request_research");
152
- if (call?.result === undefined) {
153
- throw new Error(
154
- `the desk called no request_research: ${calls.map((one) => one.name).join(", ") || "(no tools)"}`,
155
- );
156
- }
157
- return Started.parse(JSON.parse(call.result)).runId;
166
+ return toolResultIn(calls, "request_research", Started).runId;
158
167
  }
159
168
 
160
169
  /** Every tool call in this turn that READS a run, whichever the model picked. */
@@ -177,12 +186,13 @@ const START_TURN = [
177
186
  * Not tidiness: the scripted `stepFetch` is unpublished when the test that
178
187
  * installed it finishes, so a body still mid-flight would make its next model
179
188
  * call against whatever the next case publishes — or against the real gateway.
189
+ * `close()` reports that on stderr (`EvalRunAbandoned`) rather than fixing it:
190
+ * the wait is `settleAll`'s, and the RELEASE stays here, because what holds the
191
+ * run in flight is this file's own gate and nothing in the harness can open one.
180
192
  */
181
193
  async function drain(workflows: EvalWorkflows | undefined, steps: ScriptedSteps): Promise<void> {
182
194
  steps.release();
183
- for (const run of await (workflows?.runs() ?? [])) {
184
- await workflows?.settle(run.runId, research);
185
- }
195
+ await workflows?.settleAll();
186
196
  }
187
197
 
188
198
  describeEval(
@@ -31,7 +31,7 @@ import type { SessionEvent } from "@alexkroman1/aai/protocol";
31
31
  // What no eval here can see: anything below the audio boundary. Whether a
32
32
  // caller reading an order number in bursts lands as one turn is a property of
33
33
  // endpointing, and these fake speech stages remove it.
34
- import { type EvalSession, type EvalTurn, lastStateIn } from "@alexkroman1/aai-runtime/eval";
34
+ import { describeTurn, lastStateIn, toolNames, turnCalling } from "@alexkroman1/aai-runtime/eval";
35
35
  import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
36
36
  import { expect } from "vitest";
37
37
  import { z } from "zod";
@@ -93,26 +93,6 @@ function statusOf(events: readonly SessionEvent[], orderId: string): string | un
93
93
  const refusalAt = (state: string) =>
94
94
  new RegExp(`Not available yet: this conversation is at [\\\\"]*${state}`);
95
95
 
96
- /**
97
- * Drive a whole call, one caller line at a time, and hand back every turn.
98
- *
99
- * The cases below assert about the turn a MECHANISM fired in rather than about
100
- * turn number two, because how many turns a desk takes to get there is the
101
- * model's business and it really does vary: measured live, this agent reads the
102
- * order back out of `get_order_details` and asks before it stages, so the
103
- * staging call has landed in turn two, three and four across runs. A case
104
- * pinned to a turn index is a flake with a misleading name.
105
- */
106
- async function sayAll(session: EvalSession, lines: readonly string[]): Promise<EvalTurn[]> {
107
- const turns: EvalTurn[] = [];
108
- for (const line of lines) turns.push(await session.say(line));
109
- return turns;
110
- }
111
-
112
- /** The turn a named tool was called in, if any. */
113
- const turnCalling = (turns: readonly EvalTurn[], tool: string) =>
114
- turns.find((t) => t.toolCalls.some((c) => c.name === tool));
115
-
116
96
  /** One line the caller says to identify themselves, and the scripted tool call
117
97
  * that answers it — the first turn of three of these four cases. */
118
98
  const AUTH_TURN = [
@@ -175,7 +155,7 @@ describeEval(retailAgent, (test) => {
175
155
  // turn, and whether it spends one is not something an eval should pin.
176
156
  // Every assertion below is about the turn the staging landed in, so a
177
157
  // later apply cannot affect any of them.
178
- const turns = await sayAll(session, [
158
+ const turns = await session.sayAll([
179
159
  `My email is ${CALLER_EMAIL}.`,
180
160
  "I'd like to cancel my pending order — I ordered it by mistake.",
181
161
  "Yes, please go ahead and cancel it.",
@@ -183,8 +163,13 @@ describeEval(retailAgent, (test) => {
183
163
  "Yes. Cancel it, please.",
184
164
  ]);
185
165
 
166
+ // The turn the staging landed in, whichever it was — measured live it has
167
+ // been turn two, three and four. `turnCalling` throws when no turn staged
168
+ // at all, naming every turn's tool list: a desk that talked through all
169
+ // five without staging is the finding, and "expected undefined to be
170
+ // defined" is not a report of it.
186
171
  const staging = turnCalling(turns, "cancel_pending_order");
187
- const staged = staging?.toolCalls.find((c) => c.name === "cancel_pending_order");
172
+ const staged = staging.toolCalls.find((c) => c.name === "cancel_pending_order");
188
173
  expect(staged?.result).toMatch(/NOTHING HAS CHANGED YET/);
189
174
  // The gate is a POSITION, and this is it moving: the tool reported the
190
175
  // state it landed in, which is the only state `confirm_change` is legal
@@ -192,13 +177,13 @@ describeEval(retailAgent, (test) => {
192
177
  expect(staged?.result).toMatch(/serving\.awaitingConfirmation/);
193
178
  // A change cannot be described and applied in the same turn. This is the
194
179
  // property the prose in the system prompt could never have.
195
- expect(staging?.toolCalls.map((c) => c.name)).not.toContain("confirm_change");
180
+ expect(toolNames(staging.toolCalls)).not.toContain("confirm_change");
196
181
  // And after the turn that staged it, the store really is untouched — read
197
182
  // off the projection the BROWSER was sent in that same turn.
198
- expect(statusOf(staging?.events ?? [], PENDING_ORDER)).toBe("pending");
199
- expect(projection(staging?.events ?? [])?.pending?.kind).toBe("cancel_pending_order");
183
+ expect(statusOf(staging.events, PENDING_ORDER)).toBe("pending");
184
+ expect(projection(staging.events)?.pending?.kind).toBe("cancel_pending_order");
200
185
  // Step 2 of the policy: read it back and ask.
201
- expect(staging?.text).toMatch(/\?/);
186
+ expect(staging.text).toMatch(/\?/);
202
187
  },
203
188
  { stubReply: [...AUTH_TURN, ...STAGE_TURN, "Cancelling it now — one moment."] },
204
189
  );
@@ -210,7 +195,7 @@ describeEval(retailAgent, (test) => {
210
195
  // in is its own business, and saying yes repeatedly is what makes
211
196
  // "exactly once" below a claim about the MECHANISM rather than about the
212
197
  // model's pacing.
213
- await sayAll(session, [
198
+ await session.sayAll([
214
199
  `My email is ${CALLER_EMAIL}.`,
215
200
  "Please cancel my pending order — I ordered it by mistake.",
216
201
  "Yes, that's right, go ahead.",
@@ -231,7 +216,7 @@ describeEval(retailAgent, (test) => {
231
216
  expect(extra.result).toMatch(/Not available yet/);
232
217
  }
233
218
  // And it came after the stage, never instead of it.
234
- const names = session.toolCalls().map((c) => c.name);
219
+ const names = toolNames(session.toolCalls());
235
220
  expect(names.indexOf("cancel_pending_order")).toBeGreaterThanOrEqual(0);
236
221
  expect(names.indexOf("confirm_change")).toBeGreaterThan(
237
222
  names.indexOf("cancel_pending_order"),
@@ -262,11 +247,10 @@ describeEval(retailAgent, (test) => {
262
247
  // Named first, and with a message: a live model that answers the request
263
248
  // with a question instead of the tool leaves `transfer` undefined, and
264
249
  // `.toMatch()` on it reports only "expected a string, got undefined" —
265
- // which says nothing about what the desk actually did.
266
- expect(
267
- transfer,
268
- `tools called: [${handoff.toolCalls.map((c) => c.name).join(", ")}]; said: ${handoff.text}`,
269
- ).toBeDefined();
250
+ // which says nothing about what the desk actually did. `describeTurn` is
251
+ // that sentence, done by the harness: the tools it called and what it
252
+ // said, plus whether the reply was cancelled.
253
+ expect(transfer, describeTurn(handoff)).toBeDefined();
270
254
  // The terminal state is what makes "say nothing else after this" a
271
255
  // property of the agent rather than a line in its prompt: `done` is the
272
256
  // flow saying there is nowhere left to go.