@alexkroman1/aai-cli 13.1.0 → 13.3.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 (92) hide show
  1. package/bin.mjs +1 -1
  2. package/dist/{_agent-BTwZJu4P.mjs → _agent-Clbq3a4h.mjs} +2 -2
  3. package/dist/{_bundler-C31rlcYz.mjs → _bundler-DEkjO1d8.mjs} +3 -3
  4. package/dist/{_config-CmJOFsAP.mjs → _config-ZY2euhdh.mjs} +7 -4
  5. package/dist/_config.d.ts +25 -1
  6. package/dist/{_dev-server-DWvkNoIS.mjs → _dev-server-C13xuGB1.mjs} +13 -13
  7. package/dist/_e2e-test-utils.d.ts +15 -7
  8. package/dist/{_init-BURbdHNM.mjs → _init-Ci4QGjp8.mjs} +58 -10
  9. package/dist/{_output-CKkmWs7i.mjs → _output-DbJt9gsl.mjs} +34 -3
  10. package/dist/_output.d.ts +17 -1
  11. package/dist/{_preflight-Co1G9Jww.mjs → _preflight-B2laWPdU.mjs} +1 -1
  12. package/dist/{_server-common-6e0QI4mq.mjs → _server-common-BV4_CrP3.mjs} +3 -3
  13. package/dist/{_slug-api-CfGQq1Tt.mjs → _slug-api-DlKpj91X.mjs} +3 -3
  14. package/dist/{_studio-B1waXMuR.mjs → _studio-DTi3EdXQ.mjs} +3 -3
  15. package/dist/{_templates-0Zr2z6yA.mjs → _templates-V4l5sZJy.mjs} +2 -2
  16. package/dist/{_typecheck-gate-BXvUNnfs.mjs → _typecheck-gate-sV4N5F93.mjs} +3 -3
  17. package/dist/{_ui-u7T4YooX.mjs → _ui-DCt4qZrk.mjs} +1 -1
  18. package/dist/{_utils-B8QmtFhK.mjs → _utils-Dhd058D9.mjs} +1 -1
  19. package/dist/{_vite-env-D9Qj6KPX.mjs → _vite-env-kejF0p2L.mjs} +1 -1
  20. package/dist/{_workflow-determinism-f9J-5Zlj.mjs → _workflow-determinism-C9l8uC2R.mjs} +1 -1
  21. package/dist/{build-BLlLOrng.mjs → build-CWyFGe-W.mjs} +11 -8
  22. package/dist/cli.d.ts +2 -1
  23. package/dist/cli.mjs +120 -46
  24. package/dist/{client-bundler-j5c46x0G.mjs → client-bundler-CT-_26c5.mjs} +4 -4
  25. package/dist/client-bundler.mjs +1 -1
  26. package/dist/{delete-X_CRgCf6.mjs → delete-RqlQZ6Y_.mjs} +6 -6
  27. package/dist/{deploy-BzZ-r4iM.mjs → deploy-B7f3CK7v.mjs} +13 -13
  28. package/dist/{dev-BDykFvcc.mjs → dev-DamXieW_.mjs} +5 -5
  29. package/dist/{eval-0qEOdm0l.mjs → eval-h3DTVfRw.mjs} +27 -11
  30. package/dist/eval.d.ts +2 -0
  31. package/dist/{init-BtJI306C.mjs → init-D-vFrV8h.mjs} +7 -7
  32. package/dist/{login-DeUETobb.mjs → login-CDiemNmC.mjs} +5 -5
  33. package/dist/{logs-hWnJ66Bl.mjs → logs-DGwPNCzB.mjs} +4 -4
  34. package/dist/project-config.mjs +1 -1
  35. package/dist/scaffold/.env.example +17 -1
  36. package/dist/scaffold/package.json +6 -5
  37. package/dist/secret-V1w7y9-j.mjs +162 -0
  38. package/dist/secret.d.ts +41 -8
  39. package/dist/{studio-BxAS_FQQ.mjs → studio-BpMCfUNx.mjs} +9 -9
  40. package/dist/templates/briefing-desk/agent.test.ts +7 -3
  41. package/dist/templates/call-audit/agent.eval.test.ts +14 -11
  42. package/dist/templates/call-audit/agent.test.ts +34 -7
  43. package/dist/templates/call-audit/client.tsx +14 -17
  44. package/dist/templates/call-audit/workflows/ingest.ts +10 -1
  45. package/dist/templates/code-interpreter/agent.eval.test.ts +27 -17
  46. package/dist/templates/dispatch-center/agent.eval.test.ts +18 -24
  47. package/dist/templates/embedded-assets/agent.eval.test.ts +3 -3
  48. package/dist/templates/embedded-assets/agent.test.ts +7 -1
  49. package/dist/templates/health-assistant/agent.eval.test.ts +38 -15
  50. package/dist/templates/health-assistant/agent.test.ts +8 -4
  51. package/dist/templates/link-digest/agent.eval.test.ts +24 -15
  52. package/dist/templates/link-digest/agent.test.ts +7 -1
  53. package/dist/templates/link-digest/client.tsx +17 -21
  54. package/dist/templates/math-buddy/agent.eval.test.ts +28 -17
  55. package/dist/templates/night-owl/agent.eval.test.ts +30 -15
  56. package/dist/templates/night-owl/agent.test.ts +6 -1
  57. package/dist/templates/personal-finance/agent.eval.test.ts +27 -17
  58. package/dist/templates/pipeline-simple/agent.test.ts +31 -14
  59. package/dist/templates/pizza-ordering/agent.eval.test.ts +11 -6
  60. package/dist/templates/plan-and-execute/agent.eval.test.ts +14 -7
  61. package/dist/templates/podcast-digest/agent.test.ts +13 -3
  62. package/dist/templates/podcast-digest/client.tsx +12 -15
  63. package/dist/templates/recap-workflow/agent.eval.test.ts +49 -20
  64. package/dist/templates/recap-workflow/agent.test.ts +22 -9
  65. package/dist/templates/redline/agent.eval.test.ts +32 -24
  66. package/dist/templates/redline/agent.test.ts +7 -1
  67. package/dist/templates/redline/client.tsx +12 -15
  68. package/dist/templates/research-workflow/agent.eval.test.ts +32 -22
  69. package/dist/templates/research-workflow/agent.test.ts +7 -1
  70. package/dist/templates/retail/agent.eval.test.ts +18 -34
  71. package/dist/templates/retail/registry.test.ts +99 -58
  72. package/dist/templates/simple/agent.test.ts +39 -6
  73. package/dist/templates/spoken-summary/agent.eval.test.ts +25 -16
  74. package/dist/templates/spoken-summary/agent.test.ts +21 -7
  75. package/dist/templates/spoken-summary/client.tsx +14 -18
  76. package/dist/templates/support-line/agent.eval.test.ts +23 -26
  77. package/dist/templates/transcription-workflow/agent.test.ts +26 -8
  78. package/dist/templates/transcription-workflow/client.tsx +19 -26
  79. package/dist/templates/transcription-workflow/workflows/normalize.ts +10 -1
  80. package/dist/templates/transcription-workflow/workflows/sync-api.ts +5 -2
  81. package/dist/templates/transcription-workflow/workflows/transcribe.ts +11 -4
  82. package/dist/templates/travel-concierge/agent.eval.test.ts +37 -56
  83. package/dist/test-guenJUxi.mjs +255 -0
  84. package/dist/test.d.ts +97 -31
  85. package/dist/typecheck.mjs +2 -2
  86. package/dist/worker-bundler.mjs +3 -3
  87. package/dist/{workflow-BBu5oEDw.mjs → workflow-BZyKLkhV.mjs} +5 -5
  88. package/package.json +10 -10
  89. package/dist/bin.d.mts +0 -2
  90. package/dist/secret-B-3T5tvr.mjs +0 -61
  91. package/dist/test-CiLab-AA.mjs +0 -182
  92. package/dist/tsdown.config.d.ts +0 -2
@@ -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(
@@ -106,7 +106,13 @@ function stubWorkflows(runs: WorkflowRunSnapshot[] = []): WorkflowClient {
106
106
 
107
107
  describe("the agent declares its workflow", () => {
108
108
  test("under the name ctx.workflows.start resolves it by", () => {
109
- expect(Object.keys(agentDef.workflows ?? {})).toEqual(["research"]);
109
+ // `toContain` rather than an exact key list: a second workflow is an
110
+ // invited edit and must not redden a test the author did not write. The
111
+ // NAME is still pinned, deliberately — this key is a STRING to everything
112
+ // outside this file (the REST route, `ctx.workflows.get`, a schedule), so
113
+ // renaming it is a runtime 404 rather than a compile error, and nothing
114
+ // else says so.
115
+ expect(Object.keys(agentDef.workflows ?? {})).toContain("research");
110
116
  expect(agentDef.workflows?.research).toBe(research);
111
117
  });
112
118
 
@@ -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.
@@ -1,21 +1,6 @@
1
1
  import { isToolFailure, type ToolContext } from "@alexkroman1/aai";
2
2
  import { createToolContext, deployedAgent } from "@alexkroman1/aai/testing";
3
3
  import { describe, expect, test } from "vitest";
4
- import { callFlow, retailSlot } from "./store.ts";
5
-
6
- /** Tools that legitimately run before the caller is identified — the six
7
- * declaring `when: BEFORE_TRANSFER`. Everything else must refuse. Listed here
8
- * so ADDING an unauthenticated tool is a deliberate edit to this file, not a
9
- * silent gap. */
10
- const PUBLIC_TOOLS = new Set([
11
- "find_user_id_by_email",
12
- "find_user_id_by_name_zip",
13
- "get_product_details",
14
- "get_item_details",
15
- "list_all_product_types",
16
- "transfer_to_human_agents",
17
- ]);
18
-
19
4
  /**
20
5
  * The def a DEPLOYED agent runs, lowered BY HAND — the one place in the
21
6
  * templates that still does.
@@ -27,6 +12,7 @@ const PUBLIC_TOOLS = new Set([
27
12
  * whose runner is not vitest, and so cannot register the plugin.
28
13
  */
29
14
  import authoredAgent from "./agent.ts";
15
+ import { callFlow, gateFor, retailSlot } from "./store.ts";
30
16
  import systemPrompt from "./system-prompt.md?raw";
31
17
 
32
18
  const retailAgent = deployedAgent(authoredAgent, {
@@ -36,6 +22,39 @@ const retailAgent = deployedAgent(authoredAgent, {
36
22
 
37
23
  const registry = Object.entries(retailAgent.tools);
38
24
 
25
+ /**
26
+ * Whether `name`'s gate admits the pre-identification state — i.e. whether it
27
+ * is one of the tools that legitimately runs before the caller is identified.
28
+ *
29
+ * ASKED OF THE REGISTRY, not read off a list here. This used to be a hardcoded
30
+ * six-name set, which meant a tool you add is classified by a file you did not
31
+ * write: declare `when: BEFORE_TRANSFER` on a new catalogue read and the
32
+ * sweeps below would have demanded it refuse. `retailTool` records every gate
33
+ * in `TOOL_GATES`, so the honest question is what the tool itself declared.
34
+ *
35
+ * `undefined` — a tool built with plain `tool()` rather than `retailTool` — is
36
+ * neither: it has no gate at all, and the sweeps skip it. That is the same
37
+ * answer `gateFor` gives the `tool.called` hook, and "a tool this template did
38
+ * not declare is ignored" below is the assertion for it.
39
+ */
40
+ const isPublic = (name: string) => gateFor(name)?.includes("identifying") ?? false;
41
+
42
+ /** Registry entries that went through `retailTool`, so the gate has an opinion. */
43
+ const gatedTools = registry.filter(([name]) => gateFor(name) !== undefined);
44
+
45
+ /** The tools this template SHIPS that run before identification. A pin on the
46
+ * shipped set — one of these quietly losing `BEFORE_TRANSFER` is a policy
47
+ * break — and nothing else reads it, so adding a public tool of your own costs
48
+ * no edit here. */
49
+ const SHIPPED_PUBLIC_TOOLS = [
50
+ "find_user_id_by_email",
51
+ "find_user_id_by_name_zip",
52
+ "get_product_details",
53
+ "get_item_details",
54
+ "list_all_product_types",
55
+ "transfer_to_human_agents",
56
+ ];
57
+
39
58
  // `createToolContext()` rather than a cast: it carries a real slot store (the
40
59
  // same storability check and freeze the deployed one applies), and each call is a
41
60
  // distinct session, which is what these per-tool cases assume.
@@ -81,9 +100,19 @@ async function bodyReachableCtx(name: string): Promise<ToolContext> {
81
100
  return ctx;
82
101
  }
83
102
 
84
- /** Minimal args satisfying each tool's schema. Deliberately plausible-shaped
85
- * but wrong these calls are expected to fail; what is asserted is that they
86
- * still moved the UI. */
103
+ /**
104
+ * Minimal args satisfying each tool's schema. Deliberately plausible-shaped but
105
+ * wrong these calls are expected to fail; what is asserted is that they still
106
+ * moved the UI.
107
+ *
108
+ * **It is also the list of tools this file sweeps, and the list of tools this
109
+ * template ships.** It used to be a second copy of a seventeen-name array
110
+ * asserted with `toEqual`, so adding one tool of your own failed both — the
111
+ * count and the coverage — before it had run once. One source now: a tool with
112
+ * an entry here is driven through every sweep below, and every name here must
113
+ * still be discovered from `tools/`. Add your tool with its arguments and it
114
+ * joins the sweeps; leave it out and it is simply not covered by them.
115
+ */
87
116
  const SAMPLE_ARGS: Record<string, Record<string, unknown>> = {
88
117
  cancel_change: {},
89
118
  cancel_pending_order: { order_id: "#W0000000", reason: "no longer needed" },
@@ -137,56 +166,56 @@ const SAMPLE_ARGS: Record<string, Record<string, unknown>> = {
137
166
  transfer_to_human_agents: { summary: "test" },
138
167
  };
139
168
 
169
+ /** The tools with sample args, i.e. the ones every sweep below can drive. */
170
+ const sweepable = registry.filter(([name]) => name in SAMPLE_ARGS);
171
+
140
172
  describe("tool registry", () => {
141
- test("registers all seventeen tools", () => {
142
- // Fifteen of these are tau2's retail tool set, which this template used to
143
- // hold verbatim. `confirm_change` and `cancel_change` are the two it does
144
- // not have: tau2's tools apply on their first call, and here nothing does —
145
- // see `pending.ts`. Departing from that set is what buys the confirmation
146
- // gate, and it is the reason this list is no longer a fidelity claim.
147
- expect(registry.map(([name]) => name).sort()).toEqual(
148
- [
149
- "cancel_change",
150
- "cancel_pending_order",
151
- "confirm_change",
152
- "exchange_delivered_order_items",
153
- "find_user_id_by_email",
154
- "find_user_id_by_name_zip",
155
- "get_item_details",
156
- "get_order_details",
157
- "get_product_details",
158
- "get_user_details",
159
- "list_all_product_types",
160
- "modify_pending_order_address",
161
- "modify_pending_order_items",
162
- "modify_pending_order_payment",
163
- "modify_user_address",
164
- "return_delivered_order_items",
165
- "transfer_to_human_agents",
166
- ].sort(),
173
+ test("discovers every tool this template ships", () => {
174
+ // `arrayContaining` rather than an exact sorted list. Fifteen of these are
175
+ // tau2's retail tool set, which this template used to hold verbatim;
176
+ // `confirm_change` and `cancel_change` are the two it does not have, since
177
+ // tau2's tools apply on their first call and here nothing does (see
178
+ // `pending.ts`). So the list is not a fidelity claim and never was a
179
+ // COUNT — what it is worth asserting is that discovery still finds each
180
+ // one, because a `tools/` glob that resolves nothing looks exactly like a
181
+ // desk with no tools. Adding a tool of your own passes; losing one of these
182
+ // fails, naming it.
183
+ expect(registry.map(([name]) => name)).toEqual(
184
+ expect.arrayContaining(Object.keys(SAMPLE_ARGS)),
167
185
  );
168
186
  });
169
187
 
170
- // Both sweep the registry, so they assert softly: adding a batch of tools
171
- // should list every one still missing its entry, not just the first.
172
- test("every registered tool has sample args, so the sweeps below cover it", () => {
173
- for (const [name] of registry) {
174
- expect.soft(SAMPLE_ARGS[name], `add SAMPLE_ARGS["${name}"]`).toBeDefined();
188
+ // Asserts softly: a batch of renames should list every name that no longer
189
+ // resolves, not just the first.
190
+ test("every name in SAMPLE_ARGS resolves to a real tool", () => {
191
+ for (const name of Object.keys(SAMPLE_ARGS)) {
192
+ expect.soft(retailAgent.tools[name], `SAMPLE_ARGS["${name}"] names no tool`).toBeDefined();
175
193
  }
176
194
  });
177
195
 
178
196
  test("every tool declares a description the model can act on", () => {
179
197
  for (const [name, def] of registry) {
198
+ // Any tool, yours included — the model picks a tool by its description,
199
+ // so an empty one is invisible to it.
180
200
  expect.soft(def.description, name).toBeTruthy();
181
- expect.soft(def.description.length, name).toBeGreaterThan(40);
201
+ }
202
+ for (const name of Object.keys(SAMPLE_ARGS)) {
203
+ // The shipped ones additionally carry enough of the policy to be chosen
204
+ // correctly, which for this desk means more than a label.
205
+ expect.soft(retailAgent.tools[name]?.description.length, name).toBeGreaterThan(40);
182
206
  }
183
207
  });
184
208
  });
185
209
 
186
210
  describe("the UI-update invariant", () => {
187
- // This is the one that fails if a future tool is built with tool() instead of
211
+ // Over `sweepable` rather than the whole registry: these two REACH a tool's
212
+ // body, so they need arguments its schema accepts, and calling a tool of
213
+ // yours with `{}` would report a crash inside it as a UI regression. Give it
214
+ // a `SAMPLE_ARGS` entry and it is swept like the rest.
215
+ //
216
+ // This is the pair that fails if a future tool is built with tool() instead of
188
217
  // retailTool(): it would work, and the sidebar would sit still through it.
189
- test.each(registry)("%s increments callSeq and logs activity", async (name, def) => {
218
+ test.each(sweepable)("%s increments callSeq and logs activity", async (name, def) => {
190
219
  // In whichever state lets the body run, so the flow gate is not what these
191
220
  // calls are testing: the point is that a tool which reaches its BODY moves
192
221
  // the sidebar. A refused call never reaches one — the hook below is what
@@ -200,7 +229,7 @@ describe("the UI-update invariant", () => {
200
229
  expect(state.activity.at(-1)?.summary).toBeTruthy();
201
230
  });
202
231
 
203
- test.each(registry)("%s logs its own registry key as its name", async (name, def) => {
232
+ test.each(sweepable)("%s logs its own registry key as its name", async (name, def) => {
204
233
  // Catches a copy-paste where the retailTool `name` and the registry key
205
234
  // disagree — the activity feed would then attribute calls to the wrong tool.
206
235
  const ctx = await bodyReachableCtx(name);
@@ -229,7 +258,7 @@ describe("the blocked-call hook", () => {
229
258
  ctx,
230
259
  );
231
260
 
232
- test.each(registry.filter(([name]) => !PUBLIC_TOOLS.has(name)))(
261
+ test.each(gatedTools.filter(([name]) => !isPublic(name)))(
233
262
  "%s records a blocked line when the model tries it too early",
234
263
  (name) => {
235
264
  const ctx = makeCtx();
@@ -245,7 +274,7 @@ describe("the blocked-call hook", () => {
245
274
  },
246
275
  );
247
276
 
248
- test.each(registry.filter(([name]) => PUBLIC_TOOLS.has(name)))(
277
+ test.each(gatedTools.filter(([name]) => isPublic(name)))(
249
278
  "%s is left to the wrapper, because it is going to run",
250
279
  (name) => {
251
280
  const ctx = makeCtx();
@@ -280,7 +309,19 @@ describe("the blocked-call hook", () => {
280
309
  });
281
310
 
282
311
  describe("the authentication gate", () => {
283
- test.each(registry.filter(([name]) => !PUBLIC_TOOLS.has(name)))(
312
+ test("the tools that run before identification are still the shipped six", () => {
313
+ // The one place `SHIPPED_PUBLIC_TOOLS` is read, and the only assertion in
314
+ // this file about WHICH tools are public. The sweeps below classify by
315
+ // `isPublic`, i.e. by what each tool declared, so a public tool of your own
316
+ // needs no edit here — but one of these six quietly losing
317
+ // `when: BEFORE_TRANSFER` would silently make the desk unreachable before a
318
+ // caller is identified, and nothing else would say so.
319
+ expect(registry.map(([name]) => name).filter(isPublic)).toEqual(
320
+ expect.arrayContaining(SHIPPED_PUBLIC_TOOLS),
321
+ );
322
+ });
323
+
324
+ test.each(gatedTools.filter(([name]) => !isPublic(name)))(
284
325
  "%s refuses before the caller is identified",
285
326
  async (name, def) => {
286
327
  const result = await def.execute(SAMPLE_ARGS[name] ?? {}, makeCtx());
@@ -297,7 +338,7 @@ describe("the authentication gate", () => {
297
338
  },
298
339
  );
299
340
 
300
- test.each(registry.filter(([name]) => PUBLIC_TOOLS.has(name)))(
341
+ test.each(sweepable.filter(([name]) => isPublic(name)))(
301
342
  "%s does not require authentication",
302
343
  async (name, def) => {
303
344
  const result = await def.execute(SAMPLE_ARGS[name] ?? {}, makeCtx());
@@ -331,7 +372,7 @@ describe("agent config", () => {
331
372
  });
332
373
 
333
374
  describe("the transfer is terminal", () => {
334
- test.each(registry)("%s refuses once the call is with a human", async (name, def) => {
375
+ test.each(gatedTools)("%s refuses once the call is with a human", async (name, def) => {
335
376
  const ctx = servingCtx();
336
377
  callFlow.send(ctx, { type: "TRANSFERRED" });
337
378
 
@@ -2,17 +2,50 @@ import { toAgentConfig } from "@alexkroman1/aai/manifest";
2
2
  import { describe, expect, test } from "vitest";
3
3
  import agentDef from "./agent.ts";
4
4
 
5
+ /**
6
+ * What a starter's spec may assert.
7
+ *
8
+ * Renaming the agent, giving it a voice, swapping a stage or switching the
9
+ * whole thing to speech-to-speech are the first edits this template invites —
10
+ * and `aai build` runs these tests before it bundles, so an assertion that
11
+ * pins the template's own identity turns the first customization into a build
12
+ * failure in a file the author never wrote. Every test here therefore asserts
13
+ * a property that survives those edits, on the RESOLVED config rather than on
14
+ * the def's empty fields.
15
+ */
5
16
  describe("simple template", () => {
6
17
  test("config passes manifest validation", () => {
7
18
  // Same conversion `aai build`/`aai deploy` run.
8
19
  expect(() => toAgentConfig(agentDef)).not.toThrow();
9
20
  });
10
21
 
11
- test("exports an agent with a name and no explicit providers", () => {
12
- // No provider fields declared: the default all-AssemblyAI pipeline is
13
- // injected at parse time (see `defaultProviders`).
14
- expect(agentDef.name).toBe("Simple Assistant");
15
- expect(agentDef.stt).toBeUndefined();
16
- expect(agentDef.s2s).toBeUndefined();
22
+ test("exports an agent the platform can name", () => {
23
+ // Not the literal: what has to hold is that there IS a name, and that the
24
+ // conversion carries it through `AgentName` refuses a blank one, and the
25
+ // studio lists a deployed agent by exactly this string.
26
+ expect(agentDef.name).toBeTruthy();
27
+ expect(toAgentConfig(agentDef).name).toBe(agentDef.name);
28
+ });
29
+
30
+ test("every stage its mode needs is filled, declared or defaulted", () => {
31
+ // The template's point: with no provider fields declared, the default
32
+ // all-AssemblyAI cascaded pipeline is injected at parse time (see
33
+ // `defaultProviders`) — so an agent that declares nothing still runs.
34
+ // Asserted per MODE so it stays true after a swap: declare `stt`/`llm`/`tts`
35
+ // and the rest still default; declare `s2s` and there is no cascade to fill,
36
+ // which is the one thing that must never happen by fallthrough.
37
+ const config = toAgentConfig(agentDef);
38
+ if (config.mode === "s2s") {
39
+ expect(config.s2s?.kind).toBeTruthy();
40
+ expect(config.stt).toBeUndefined();
41
+ expect(config.tts).toBeUndefined();
42
+ } else if (config.mode === "text") {
43
+ expect(config.llm?.kind).toBeTruthy();
44
+ } else {
45
+ expect(config.mode).toBe("pipeline");
46
+ expect(config.stt?.kind).toBeTruthy();
47
+ expect(config.llm?.kind).toBeTruthy();
48
+ expect(config.tts?.kind).toBeTruthy();
49
+ }
17
50
  });
18
51
  });
@@ -48,6 +48,7 @@
48
48
  // exercised. `run.slept` below is the other half of that admission written as
49
49
  // an assertion. `aai-cli`'s `dev-workflow.scenario.test.ts` is the tier that
50
50
  // really suspends and resumes a run.
51
+ import { stubGatewayRoute } from "@alexkroman1/aai/testing";
51
52
  import {
52
53
  installStubSpeech,
53
54
  installStubTranscribe,
@@ -115,23 +116,30 @@ function publish(bytes: Uint8Array, name: string, type: string) {
115
116
  *
116
117
  * ONE fake, because publishing a `stepFetch` REPLACES — a flow that transcribes
117
118
  * 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.
119
+ * for. BOTH halves are the SDK's own fakes rather than this file's hand-typed
120
+ * wire, and it is the same argument twice: each routes off the SDK's own
121
+ * endpoint constant, so a case cannot pass because the fake and the step agree
122
+ * on a typo. The predicate here used to be `url.includes("llm-gateway")` — a
123
+ * HOST, which the default gateway happens to carry and a `gatewayUrl` pointed
124
+ * anywhere else does not, so the fake would have gone on answering the
125
+ * transcription 404 to a model call it no longer recognised.
126
+ *
127
+ * The reader also hands back DECODED calls, which is why the case below asks
128
+ * `model.calls[0].prompt` what the model was SHOWN: off a raw request body that
129
+ * is the whole serialized request, `model` and `reasoning_effort` included.
121
130
  */
122
131
  function scriptProvider(options: { text?: string; pendingPolls?: number } = {}) {
123
- return installStubTranscribe({
132
+ const model = stubGatewayRoute(JSON.stringify(REPLY));
133
+ const provider = installStubTranscribe({
124
134
  text: options.text ?? TRANSCRIPT,
125
135
  durationSec: 42,
126
136
  // Passed straight through rather than conditionally spread: the option
127
137
  // already admits `undefined`, and `guard-invariants` rule 2 counts the
128
138
  // spread.
129
139
  pendingPolls: options.pendingPolls,
130
- otherwise: (request) =>
131
- request.url.includes("llm-gateway")
132
- ? { body: { choices: [{ message: { content: JSON.stringify(REPLY) } }] } }
133
- : undefined,
140
+ otherwise: (request) => model.route(request),
134
141
  });
142
+ return { provider, model };
135
143
  }
136
144
 
137
145
  describeWorkflowEval(
@@ -144,7 +152,7 @@ describeWorkflowEval(
144
152
  // returned bytes, or an id nothing wrote, or two ids because the synthesis
145
153
  // and the store became two steps.
146
154
  const uploads = publish(new Uint8Array(64), "standup.wav", "audio/wav");
147
- const provider = scriptProvider();
155
+ const { provider } = scriptProvider();
148
156
  const speech = installStubSpeech({ pcmBytes: 96_000 });
149
157
 
150
158
  const run = await app.run(spokenSummary, { recording: UPLOAD_ID });
@@ -207,7 +215,7 @@ describeWorkflowEval(
207
215
  // central prompt decision regressing — synthesize the bullet list and you
208
216
  // get a voice reading "one. two. three." with no connective tissue.
209
217
  publish(new Uint8Array(64), "standup.wav", "audio/wav");
210
- const provider = scriptProvider();
218
+ const { model } = scriptProvider();
211
219
  const speech = installStubSpeech();
212
220
 
213
221
  const run = await app.run(spokenSummary, { recording: UPLOAD_ID, voice: "michael" });
@@ -226,9 +234,10 @@ describeWorkflowEval(
226
234
 
227
235
  // And the model was ASKED for both, over the transcript it was given. A
228
236
  // 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");
237
+ const asked = model.calls[0];
238
+ if (asked === undefined) expect.fail("the run must have asked the model for a summary");
239
+ expect(asked.prompt).toContain("READ ALOUD");
240
+ expect(asked.prompt).toContain("The launch is on for Tuesday the fourth");
232
241
  });
233
242
 
234
243
  test("a recording with no speech stops before the model and the voice", async ({ app }) => {
@@ -237,7 +246,7 @@ describeWorkflowEval(
237
246
  // the run would go on to summarize no words and store half a second of
238
247
  // audio — a green run with an empty product.
239
248
  const uploads = publish(new Uint8Array(64), "silence.wav", "audio/wav");
240
- const provider = scriptProvider({ text: " " });
249
+ const { model } = scriptProvider({ text: " " });
241
250
  const speech = installStubSpeech();
242
251
 
243
252
  const run = await app.run(spokenSummary, { recording: UPLOAD_ID });
@@ -247,7 +256,7 @@ describeWorkflowEval(
247
256
  expect(run.output).toBeUndefined();
248
257
  // Nothing was summarized and nothing was spoken, which is the half that
249
258
  // makes this more than an error-message assertion.
250
- expect(provider.calls.filter((call) => call.leg === "other")).toEqual([]);
259
+ expect(model.calls).toEqual([]);
251
260
  expect(speech.calls).toEqual([]);
252
261
  expect(uploads.writes).toEqual([]);
253
262
  expect(run.reported).not.toContain("Summarizing the transcript.");
@@ -262,7 +271,7 @@ describeWorkflowEval(
262
271
  // them. A loop that re-submitted, or one that spun with no wait, both
263
272
  // produce a correct transcript and a wrong bill.
264
273
  publish(new Uint8Array(64), "standup.wav", "audio/wav");
265
- const provider = scriptProvider({ pendingPolls: 2 });
274
+ const { provider } = scriptProvider({ pendingPolls: 2 });
266
275
  installStubSpeech();
267
276
 
268
277
  const run = await app.run(spokenSummary, { recording: UPLOAD_ID });