@alexkroman1/aai-cli 13.2.0 → 14.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/bin.mjs +1 -1
  2. package/dist/{_agent-BTwZJu4P.mjs → _agent-BzUeqOdj.mjs} +33 -10
  3. package/dist/_agent.d.ts +23 -0
  4. package/dist/{_bundler-C31rlcYz.mjs → _bundler-DolUCMxu.mjs} +9 -8
  5. package/dist/_cli-common.d.ts +45 -6
  6. package/dist/{_config-CmJOFsAP.mjs → _config-DYzC6WMD.mjs} +7 -4
  7. package/dist/_config.d.ts +25 -1
  8. package/dist/_dev-env.d.ts +1 -1
  9. package/dist/{_dev-server-DWvkNoIS.mjs → _dev-server-CSMqF8PN.mjs} +28 -30
  10. package/dist/_dev-vite-config.d.ts +3 -3
  11. package/dist/_e2e-test-utils.d.ts +15 -7
  12. package/dist/{_init-BURbdHNM.mjs → _init-CQ8idAwo.mjs} +64 -30
  13. package/dist/{_output-CKkmWs7i.mjs → _output-DBP9Op_d.mjs} +43 -3
  14. package/dist/_output.d.ts +24 -7
  15. package/dist/{_preflight-Co1G9Jww.mjs → _preflight-CcAR577V.mjs} +3 -3
  16. package/dist/_resource-commands.d.ts +9 -9
  17. package/dist/{_server-common-6e0QI4mq.mjs → _server-common-vILJp3it.mjs} +3 -3
  18. package/dist/{_slug-api-CfGQq1Tt.mjs → _slug-api-CGvc2YSQ.mjs} +19 -10
  19. package/dist/_slug-api.d.ts +19 -0
  20. package/dist/{_studio-B1waXMuR.mjs → _studio-DnR_BqFp.mjs} +9 -5
  21. package/dist/_studio-commands.d.ts +13 -13
  22. package/dist/{_templates-0Zr2z6yA.mjs → _templates-CK4oKoeX.mjs} +3 -3
  23. package/dist/{_typecheck-gate-BXvUNnfs.mjs → _typecheck-gate-U3jcil8L.mjs} +5 -4
  24. package/dist/_typecheck-gate.d.ts +3 -1
  25. package/dist/{_ui-u7T4YooX.mjs → _ui-DCt4qZrk.mjs} +1 -1
  26. package/dist/{_utils-B8QmtFhK.mjs → _utils-D5JGcjiW.mjs} +44 -2
  27. package/dist/_utils.d.ts +35 -0
  28. package/dist/{_vite-env-D9Qj6KPX.mjs → _vite-env-kejF0p2L.mjs} +1 -1
  29. package/dist/{_workflow-determinism-f9J-5Zlj.mjs → _workflow-determinism-CvpqMpdh.mjs} +19 -27
  30. package/dist/_workflow-determinism.d.ts +3 -3
  31. package/dist/build-Mxk8gWvX.mjs +108 -0
  32. package/dist/build.d.ts +5 -0
  33. package/dist/cli.mjs +172 -116
  34. package/dist/{client-bundler-j5c46x0G.mjs → client-bundler-BJgREAh6.mjs} +4 -4
  35. package/dist/client-bundler.mjs +1 -1
  36. package/dist/{delete-X_CRgCf6.mjs → delete-BdFnbnVR.mjs} +6 -6
  37. package/dist/{deploy-BzZ-r4iM.mjs → deploy-uAJ4NukN.mjs} +14 -14
  38. package/dist/{dev-BDykFvcc.mjs → dev-DApPSaE_.mjs} +5 -5
  39. package/dist/{eval-0qEOdm0l.mjs → eval-BK47A_K5.mjs} +27 -11
  40. package/dist/eval.d.ts +2 -0
  41. package/dist/{init-BtJI306C.mjs → init-DukDxECd.mjs} +13 -21
  42. package/dist/{login-DeUETobb.mjs → login-DaLrPOFg.mjs} +6 -8
  43. package/dist/{logs-hWnJ66Bl.mjs → logs-7BRwq0cG.mjs} +22 -15
  44. package/dist/project-config.mjs +1 -1
  45. package/dist/scaffold/.env.example +17 -1
  46. package/dist/scaffold/CLAUDE.md +84 -56
  47. package/dist/scaffold/package.json +6 -5
  48. package/dist/scaffold/server.mjs +129 -25
  49. package/dist/secret-Cjp912yF.mjs +162 -0
  50. package/dist/secret.d.ts +48 -13
  51. package/dist/{studio-BxAS_FQQ.mjs → studio-CpHlNHUZ.mjs} +15 -17
  52. package/dist/templates/briefing-desk/agent.test.ts +7 -3
  53. package/dist/templates/call-audit/agent.test.ts +25 -9
  54. package/dist/templates/call-audit/agent.ts +2 -2
  55. package/dist/templates/call-audit/client.tsx +40 -58
  56. package/dist/templates/call-audit/workflows/audit.ts +7 -5
  57. package/dist/templates/call-audit/workflows/ingest.ts +7 -7
  58. package/dist/templates/call-audit/workflows/media.ts +12 -37
  59. package/dist/templates/call-audit/workflows/summarize.ts +8 -8
  60. package/dist/templates/call-audit/workflows/sync-api.ts +3 -3
  61. package/dist/templates/dispatch-center/agent.test.ts +7 -7
  62. package/dist/templates/dispatch-center/client.tsx +53 -61
  63. package/dist/templates/dispatch-center/shared.ts +0 -8
  64. package/dist/templates/dispatch-center/tools/ops_dashboard.ts +4 -1
  65. package/dist/templates/embedded-assets/agent.eval.test.ts +4 -2
  66. package/dist/templates/embedded-assets/agent.test.ts +7 -1
  67. package/dist/templates/health-assistant/agent.eval.test.ts +4 -2
  68. package/dist/templates/health-assistant/agent.test.ts +8 -4
  69. package/dist/templates/infocom-adventure/client.tsx +40 -36
  70. package/dist/templates/link-digest/agent.test.ts +10 -4
  71. package/dist/templates/link-digest/agent.ts +2 -2
  72. package/dist/templates/link-digest/client.tsx +42 -36
  73. package/dist/templates/link-digest/workflows/digest.ts +13 -16
  74. package/dist/templates/night-owl/agent.eval.test.ts +4 -2
  75. package/dist/templates/night-owl/agent.test.ts +6 -1
  76. package/dist/templates/night-owl/client.tsx +8 -2
  77. package/dist/templates/pipeline-simple/agent.test.ts +33 -16
  78. package/dist/templates/pizza-ordering/client.tsx +5 -4
  79. package/dist/templates/pizza-ordering/shared.ts +3 -8
  80. package/dist/templates/pizza-ordering/tools/add_pizza.ts +3 -2
  81. package/dist/templates/pizza-ordering/tools/place_order.ts +3 -2
  82. package/dist/templates/pizza-ordering/tools/remove_pizza.ts +3 -2
  83. package/dist/templates/pizza-ordering/tools/update_pizza.ts +3 -2
  84. package/dist/templates/pizza-ordering/tools/view_order.ts +3 -2
  85. package/dist/templates/plan-and-execute/agent.test.ts +10 -5
  86. package/dist/templates/plan-and-execute/client.tsx +2 -2
  87. package/dist/templates/podcast-digest/agent.test.ts +33 -14
  88. package/dist/templates/podcast-digest/client.tsx +27 -36
  89. package/dist/templates/podcast-digest/workflows/digest.ts +21 -19
  90. package/dist/templates/podcast-digest/workflows/feeds.ts +61 -33
  91. package/dist/templates/podcast-digest/workflows/slack.ts +7 -7
  92. package/dist/templates/recap-workflow/agent.eval.test.ts +21 -13
  93. package/dist/templates/recap-workflow/agent.test.ts +49 -32
  94. package/dist/templates/recap-workflow/workflows/recap.ts +24 -28
  95. package/dist/templates/redline/agent.eval.test.ts +5 -8
  96. package/dist/templates/redline/agent.test.ts +8 -2
  97. package/dist/templates/redline/client.tsx +47 -48
  98. package/dist/templates/redline/workflows/redline.ts +17 -17
  99. package/dist/templates/research-workflow/agent.eval.test.ts +5 -5
  100. package/dist/templates/research-workflow/agent.test.ts +17 -8
  101. package/dist/templates/research-workflow/workflows/research.ts +28 -30
  102. package/dist/templates/retail/agent.test.ts +17 -15
  103. package/dist/templates/retail/cancel.ts +7 -6
  104. package/dist/templates/retail/client.tsx +39 -58
  105. package/dist/templates/retail/payment.ts +4 -3
  106. package/dist/templates/retail/pending.test.ts +19 -19
  107. package/dist/templates/retail/registry.test.ts +99 -58
  108. package/dist/templates/retail/store.test.ts +5 -5
  109. package/dist/templates/retail/swap.ts +8 -11
  110. package/dist/templates/simple/agent.test.ts +39 -6
  111. package/dist/templates/solo-rpg/agent.eval.test.ts +2 -16
  112. package/dist/templates/solo-rpg/agent.test.ts +12 -12
  113. package/dist/templates/solo-rpg/client.tsx +3 -3
  114. package/dist/templates/spoken-summary/agent.test.ts +21 -12
  115. package/dist/templates/spoken-summary/agent.ts +3 -3
  116. package/dist/templates/spoken-summary/client.tsx +36 -47
  117. package/dist/templates/spoken-summary/workflows/summarize.ts +11 -11
  118. package/dist/templates/spoken-summary/workflows/transcribe.ts +14 -14
  119. package/dist/templates/support-line/client.tsx +4 -3
  120. package/dist/templates/support-line/prompts.ts +0 -2
  121. package/dist/templates/transcription-workflow/agent.test.ts +36 -20
  122. package/dist/templates/transcription-workflow/agent.ts +2 -2
  123. package/dist/templates/transcription-workflow/client.tsx +34 -40
  124. package/dist/templates/transcription-workflow/recover.ts +1 -1
  125. package/dist/templates/transcription-workflow/run-panel.tsx +34 -14
  126. package/dist/templates/transcription-workflow/total-latency.tsx +46 -20
  127. package/dist/templates/transcription-workflow/workflows/batch.ts +15 -15
  128. package/dist/templates/transcription-workflow/workflows/downsample.ts +1 -1
  129. package/dist/templates/transcription-workflow/workflows/normalize.ts +15 -19
  130. package/dist/templates/transcription-workflow/workflows/stream.ts +12 -12
  131. package/dist/templates/transcription-workflow/workflows/sync-api.ts +3 -3
  132. package/dist/templates/transcription-workflow/workflows/transcribe.ts +23 -34
  133. package/dist/templates/travel-concierge/agent.eval.test.ts +2 -2
  134. package/dist/templates/travel-concierge/agent.test.ts +15 -7
  135. package/dist/templates/travel-concierge/client.tsx +2 -2
  136. package/dist/templates/travel-concierge/shared.ts +10 -14
  137. package/dist/templates/travel-concierge/tools/lookup_booking.ts +4 -3
  138. package/dist/templates/travel-concierge/tools/search_car_rentals.ts +3 -2
  139. package/dist/templates/travel-concierge/tools/search_excursions.ts +3 -2
  140. package/dist/templates/travel-concierge/tools/search_flights.ts +3 -2
  141. package/dist/templates/travel-concierge/tools/search_hotels.ts +3 -2
  142. package/dist/test-D032V2cA.mjs +242 -0
  143. package/dist/test.d.ts +97 -31
  144. package/dist/typecheck.mjs +2 -2
  145. package/dist/worker-bundler.mjs +3 -3
  146. package/dist/{workflow-BBu5oEDw.mjs → workflow-CVioyO3V.mjs} +8 -7
  147. package/package.json +10 -10
  148. package/dist/bin.d.mts +0 -2
  149. package/dist/build-BLlLOrng.mjs +0 -66
  150. package/dist/secret-B-3T5tvr.mjs +0 -61
  151. package/dist/test-CiLab-AA.mjs +0 -182
  152. package/dist/tsdown.config.d.ts +0 -2
@@ -1,6 +1,6 @@
1
1
  import type { ToolContext } from "@alexkroman1/aai";
2
2
  import { isToolFailure } from "@alexkroman1/aai";
3
- import { createToolContext, ok, okPosition } from "@alexkroman1/aai/testing";
3
+ import { createToolContext, expectDialogOk, expectToolOk } from "@alexkroman1/aai/testing";
4
4
  import { describe, expect, test } from "vitest";
5
5
  import type { StagedResult } from "./pending.ts";
6
6
  import { retailSlot } from "./store.ts";
@@ -17,7 +17,7 @@ import transferToHumanAgents from "./tools/transfer_to_human_agents.ts";
17
17
  * one delivered order (#W4316152), which is every shape these cases need. */
18
18
  async function aaravCtx(): Promise<ToolContext> {
19
19
  const ctx = createToolContext();
20
- ok(await findUserIdByEmail.execute({ email: "aarav.anderson9752@example.com" }, ctx));
20
+ expectToolOk(await findUserIdByEmail.execute({ email: "aarav.anderson9752@example.com" }, ctx));
21
21
  return ctx;
22
22
  }
23
23
 
@@ -29,7 +29,7 @@ describe("staging", () => {
29
29
  const ctx = await aaravCtx();
30
30
  const before = structuredClone(retailSlot.get(ctx).store.orders["#W9300146"]);
31
31
 
32
- const staged = okPosition<StagedResult>(await stageCancel(ctx));
32
+ const staged = expectDialogOk<StagedResult>(await stageCancel(ctx));
33
33
 
34
34
  expect(staged.state).toBe("serving.awaitingConfirmation");
35
35
  expect(staged.result.staged).toBe("cancel_pending_order");
@@ -43,7 +43,7 @@ describe("staging", () => {
43
43
 
44
44
  test("the readback names the order, the items, the amount and where the money goes", async () => {
45
45
  const ctx = await aaravCtx();
46
- const staged = ok<StagedResult>(await stageCancel(ctx));
46
+ const staged = expectToolOk<StagedResult>(await stageCancel(ctx));
47
47
  expect(staged.read_back).toContain("#W9300146");
48
48
  expect(staged.read_back).toContain("153.23");
49
49
  expect(staged.read_back).toContain("gift_card_7245904");
@@ -52,14 +52,14 @@ describe("staging", () => {
52
52
 
53
53
  test("the result says out loud that nothing has happened", async () => {
54
54
  const ctx = await aaravCtx();
55
- const staged = ok<StagedResult>(await stageCancel(ctx));
55
+ const staged = expectToolOk<StagedResult>(await stageCancel(ctx));
56
56
  expect(staged.message).toContain("NOTHING HAS CHANGED YET");
57
57
  expect(staged.message).toContain("confirm_change");
58
58
  });
59
59
 
60
60
  test("a second stage is refused, and the refusal names the one already waiting", async () => {
61
61
  const ctx = await aaravCtx();
62
- ok(await stageCancel(ctx));
62
+ expectToolOk(await stageCancel(ctx));
63
63
 
64
64
  // A DIFFERENT change, so this is not a repeat of the first call.
65
65
  const second = await modifyUserAddress.execute(
@@ -89,9 +89,9 @@ describe("staging", () => {
89
89
  describe("confirming", () => {
90
90
  test("confirm_change applies the staged change and returns to helping", async () => {
91
91
  const ctx = await aaravCtx();
92
- ok(await stageCancel(ctx));
92
+ expectToolOk(await stageCancel(ctx));
93
93
 
94
- const done = okPosition<{ confirmed: string; status: string }>(
94
+ const done = expectDialogOk<{ confirmed: string; status: string }>(
95
95
  await confirmChange.execute({}, ctx),
96
96
  );
97
97
  expect(done.state).toBe("serving.helping");
@@ -114,8 +114,8 @@ describe("confirming", () => {
114
114
 
115
115
  test("confirming twice is refused — the second call has nothing staged", async () => {
116
116
  const ctx = await aaravCtx();
117
- ok(await stageCancel(ctx));
118
- ok(await confirmChange.execute({}, ctx));
117
+ expectToolOk(await stageCancel(ctx));
118
+ expectToolOk(await confirmChange.execute({}, ctx));
119
119
 
120
120
  const again = await confirmChange.execute({}, ctx);
121
121
  expect(isToolFailure(again)).toBe(true);
@@ -130,9 +130,9 @@ describe("confirming", () => {
130
130
  describe("cancelling a staged change", () => {
131
131
  test("cancel_change drops it, changes nothing, and frees the call", async () => {
132
132
  const ctx = await aaravCtx();
133
- ok(await stageCancel(ctx));
133
+ expectToolOk(await stageCancel(ctx));
134
134
 
135
- const dropped = okPosition<{ dropped: string | null; message: string }>(
135
+ const dropped = expectDialogOk<{ dropped: string | null; message: string }>(
136
136
  await cancelChange.execute({}, ctx),
137
137
  );
138
138
  expect(dropped.state).toBe("serving.helping");
@@ -144,12 +144,12 @@ describe("cancelling a staged change", () => {
144
144
 
145
145
  test("a corrected change can be staged straight after", async () => {
146
146
  const ctx = await aaravCtx();
147
- ok(await stageCancel(ctx));
148
- ok(await cancelChange.execute({}, ctx));
147
+ expectToolOk(await stageCancel(ctx));
148
+ expectToolOk(await cancelChange.execute({}, ctx));
149
149
 
150
150
  // The caller changed their mind about which order — the very thing
151
151
  // `cancel_change` exists for.
152
- const restaged = ok<StagedResult>(
152
+ const restaged = expectToolOk<StagedResult>(
153
153
  await returnDeliveredOrderItems.execute(
154
154
  {
155
155
  order_id: "#W4316152",
@@ -166,9 +166,9 @@ describe("cancelling a staged change", () => {
166
166
  describe("what stays legal while a change waits", () => {
167
167
  test("a read is still answerable — 'what was the total again?'", async () => {
168
168
  const ctx = await aaravCtx();
169
- ok(await stageCancel(ctx));
169
+ expectToolOk(await stageCancel(ctx));
170
170
  // `when: "serving"` matches both children, which is what makes this work.
171
- const read = okPosition<{ order_id: string }>(
171
+ const read = expectDialogOk<{ order_id: string }>(
172
172
  await getOrderDetails.execute({ order_id: "#W9300146" }, ctx),
173
173
  );
174
174
  expect(read.result.order_id).toBe("#W9300146");
@@ -178,9 +178,9 @@ describe("what stays legal while a change waits", () => {
178
178
 
179
179
  test("a caller can still ask for a human, and everything refuses afterwards", async () => {
180
180
  const ctx = await aaravCtx();
181
- ok(await stageCancel(ctx));
181
+ expectToolOk(await stageCancel(ctx));
182
182
 
183
- const transferred = okPosition(
183
+ const transferred = expectDialogOk(
184
184
  await transferToHumanAgents.execute({ summary: "wants a human" }, ctx),
185
185
  );
186
186
  // `TRANSFERRED` is declared on the `serving` PARENT, which is what lets it
@@ -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
 
@@ -1,5 +1,5 @@
1
1
  import { isToolFailure, type ToolContext } from "@alexkroman1/aai";
2
- import { createToolContext, ok, okPosition } from "@alexkroman1/aai/testing";
2
+ import { createToolContext, expectDialogOk, expectToolOk } from "@alexkroman1/aai/testing";
3
3
  import { describe, expect, test } from "vitest";
4
4
  import { z } from "zod";
5
5
  import {
@@ -237,16 +237,16 @@ describe("retailTool", () => {
237
237
  test("runs once the flow says serving", async () => {
238
238
  const ctx = makeCtx();
239
239
  serve(ctx);
240
- expect(ok(await gated.execute({}, ctx))).toEqual({ ok: true });
240
+ expect(expectToolOk(await gated.execute({}, ctx))).toEqual({ ok: true });
241
241
  });
242
242
 
243
243
  test("the result carries the position the call landed in", async () => {
244
244
  const ctx = makeCtx();
245
245
  serve(ctx);
246
- // `okPosition` rather than a cast to `{ instruction?: string }`: it keeps
246
+ // `expectDialogOk` rather than a cast to `{ instruction?: string }`: it keeps
247
247
  // the envelope this test is about, and a refusal fails here naming what the
248
248
  // flow said instead of reading `undefined` off a field nobody assigned.
249
- const answered = okPosition<{ ok: boolean }>(await gated.execute({}, ctx));
249
+ const answered = expectDialogOk<{ ok: boolean }>(await gated.execute({}, ctx));
250
250
  expect(answered).toMatchObject({ state: "serving.helping", done: false });
251
251
  expect(answered.instruction).toMatch(/one identified customer/);
252
252
  });
@@ -307,7 +307,7 @@ describe("the call flow", () => {
307
307
  summary: () => "ran",
308
308
  execute: () => ({ ok: true }),
309
309
  });
310
- expect(ok(await anywhere.execute({}, ctx))).toEqual({ ok: true });
310
+ expect(expectToolOk(await anywhere.execute({}, ctx))).toEqual({ ok: true });
311
311
 
312
312
  const at = callFlow.send(ctx, { type: "TRANSFERRED" });
313
313
  expect(at.state).toBe("transferred");
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { isToolFailure, type ToolFailure } from "@alexkroman1/aai";
10
+ import { formatMoney } from "@alexkroman1/aai/utils";
10
11
  import { resolveOrder } from "./resolve.ts";
11
12
  import type { Order, OrderItem, RetailState, User, Variant } from "./shared.ts";
12
13
  import {
@@ -48,7 +49,6 @@ export interface SwapLine {
48
49
  fromOptions: Record<string, string>;
49
50
  fromPrice: number;
50
51
  toItemId: string;
51
- toName: string;
52
52
  toOptions: Record<string, string>;
53
53
  toPrice: number;
54
54
  }
@@ -146,9 +146,6 @@ export function toSwapLines(plan: SwapPlan): SwapLine[] {
146
146
  fromOptions: { ...pair.item.options },
147
147
  fromPrice: pair.item.price,
148
148
  toItemId: pair.newVariant.item_id,
149
- // Same product by construction — a swap can only reach another option of
150
- // it — so the name is the item's own and the OPTIONS are what differ.
151
- toName: pair.item.name,
152
149
  toOptions: { ...pair.newVariant.options },
153
150
  toPrice: pair.newVariant.price,
154
151
  }));
@@ -163,7 +160,7 @@ export function assertCanCoverDiff(user: User, methodId: string, diff: number):
163
160
  if (isToolFailure(method)) return method;
164
161
  if (isGiftCard(method) && method.balance < diff) {
165
162
  return {
166
- error: `Gift card ${methodId}'s balance ($${method.balance.toFixed(2)}) does not cover the $${diff.toFixed(2)} difference. Ask for another payment method.`,
163
+ error: `Gift card ${methodId}'s balance (${formatMoney(method.balance)}) does not cover the ${formatMoney(diff)} difference. Ask for another payment method.`,
167
164
  };
168
165
  }
169
166
  return null;
@@ -190,8 +187,8 @@ function describeLine(line: SwapLine): string {
190
187
  }
191
188
 
192
189
  function describeDiff(diff: number, methodId: string): string {
193
- if (diff > 0) return `$${diff.toFixed(2)} charged to ${methodId}`;
194
- if (diff < 0) return `$${Math.abs(diff).toFixed(2)} refunded to ${methodId}`;
190
+ if (diff > 0) return `${formatMoney(diff)} charged to ${methodId}`;
191
+ if (diff < 0) return `${formatMoney(Math.abs(diff))} refunded to ${methodId}`;
195
192
  return "no price difference";
196
193
  }
197
194
 
@@ -275,8 +272,8 @@ export function applyModifyItems(state: RetailState, plan: ModifyItemsPlan) {
275
272
  })),
276
273
  message:
277
274
  plan.diff > 0
278
- ? `Done. $${plan.diff.toFixed(2)} was charged to ${plan.paymentMethodId}. This order can no longer be modified or cancelled.`
279
- : `Done. $${Math.abs(plan.diff).toFixed(2)} is being refunded to ${plan.paymentMethodId}. This order can no longer be modified or cancelled.`,
275
+ ? `Done. ${formatMoney(plan.diff)} was charged to ${plan.paymentMethodId}. This order can no longer be modified or cancelled.`
276
+ : `Done. ${formatMoney(Math.abs(plan.diff))} is being refunded to ${plan.paymentMethodId}. This order can no longer be modified or cancelled.`,
280
277
  };
281
278
  }
282
279
 
@@ -358,7 +355,7 @@ export function applyExchange(state: RetailState, plan: ExchangePlan) {
358
355
  })),
359
356
  message:
360
357
  plan.diff > 0
361
- ? `Exchange requested on ${plan.orderId}. $${plan.diff.toFixed(2)} will be charged to ${plan.paymentMethodId}. An email with return instructions is on its way.`
362
- : `Exchange requested on ${plan.orderId}. $${Math.abs(plan.diff).toFixed(2)} will be refunded to ${plan.paymentMethodId}. An email with return instructions is on its way.`,
358
+ ? `Exchange requested on ${plan.orderId}. ${formatMoney(plan.diff)} will be charged to ${plan.paymentMethodId}. An email with return instructions is on its way.`
359
+ : `Exchange requested on ${plan.orderId}. ${formatMoney(Math.abs(plan.diff))} will be refunded to ${plan.paymentMethodId}. An email with return instructions is on its way.`,
363
360
  };
364
361
  }
@@ -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
  });
@@ -33,7 +33,7 @@
33
33
  * deployed.
34
34
  */
35
35
  import agentDef from "virtual:aai/agent";
36
- import { type EvalTurn, toolResultIn } from "@alexkroman1/aai-runtime/eval";
36
+ import { type EvalTurn, toolResultIn, toolResultsIn } from "@alexkroman1/aai-runtime/eval";
37
37
  import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
38
38
  import { expect } from "vitest";
39
39
  import { z } from "zod";
@@ -82,20 +82,6 @@ const Refusal = z.object({ error: z.string() });
82
82
  const answerOf = <T>(turn: EvalTurn, name: string, schema: z.ZodType<T>): T =>
83
83
  toolResultIn(turn.toolCalls, name, schema);
84
84
 
85
- /**
86
- * What EVERY call to `name` answered on this turn, in call order.
87
- *
88
- * The plural form is for a claim about calls that may legitimately not have
89
- * happened — a refusal the narrator may have pre-empted by not calling at all.
90
- * `toolResultIn` over a ONE-CALL list per call: the name is that call's own, so
91
- * the reader's "no such call" and "two calls" throws are unreachable and what
92
- * is left is the parse, the schema, and the "never completed" failure.
93
- */
94
- const answersOf = <T>(turn: EvalTurn, name: string, schema: z.ZodType<T>): T[] =>
95
- turn.toolCalls
96
- .filter((c) => c.name === name)
97
- .map((call) => toolResultIn([call], call.name, schema));
98
-
99
85
  /**
100
86
  * Every field `setup_character` requires, for the SCRIPTED runs.
101
87
  *
@@ -267,7 +253,7 @@ describeEval(agentDef, (test) => {
267
253
  expect(answerOf(down, "update_state", Settled).state).toBe("gameOver");
268
254
 
269
255
  const after = await session.say("I refuse to die. Roll to fight on.");
270
- const attempts = answersOf(after, "action_roll", Refusal);
256
+ const attempts = toolResultsIn(after.toolCalls, "action_roll", Refusal);
271
257
  // The gate is only OBSERVABLE if something calls the gated tool, and the
272
258
  // script is what guarantees that — hence the exact count. `gameOver` was
273
259
  // once a flag nothing acted on, so a player with both tracks empty could