@alexkroman1/aai-cli 13.2.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 (70) 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.test.ts +14 -3
  42. package/dist/templates/call-audit/client.tsx +14 -17
  43. package/dist/templates/embedded-assets/agent.test.ts +7 -1
  44. package/dist/templates/health-assistant/agent.test.ts +8 -4
  45. package/dist/templates/link-digest/agent.test.ts +7 -1
  46. package/dist/templates/link-digest/client.tsx +17 -21
  47. package/dist/templates/night-owl/agent.test.ts +6 -1
  48. package/dist/templates/pipeline-simple/agent.test.ts +31 -14
  49. package/dist/templates/podcast-digest/agent.test.ts +13 -3
  50. package/dist/templates/podcast-digest/client.tsx +12 -15
  51. package/dist/templates/recap-workflow/agent.test.ts +22 -9
  52. package/dist/templates/redline/agent.test.ts +7 -1
  53. package/dist/templates/redline/client.tsx +12 -15
  54. package/dist/templates/research-workflow/agent.test.ts +7 -1
  55. package/dist/templates/retail/registry.test.ts +99 -58
  56. package/dist/templates/simple/agent.test.ts +39 -6
  57. package/dist/templates/spoken-summary/agent.test.ts +12 -3
  58. package/dist/templates/spoken-summary/client.tsx +14 -18
  59. package/dist/templates/transcription-workflow/agent.test.ts +16 -8
  60. package/dist/templates/transcription-workflow/client.tsx +19 -26
  61. package/dist/test-guenJUxi.mjs +255 -0
  62. package/dist/test.d.ts +97 -31
  63. package/dist/typecheck.mjs +2 -2
  64. package/dist/worker-bundler.mjs +3 -3
  65. package/dist/{workflow-BBu5oEDw.mjs → workflow-BZyKLkhV.mjs} +5 -5
  66. package/package.json +10 -10
  67. package/dist/bin.d.mts +0 -2
  68. package/dist/secret-B-3T5tvr.mjs +0 -61
  69. package/dist/test-CiLab-AA.mjs +0 -182
  70. package/dist/tsdown.config.d.ts +0 -2
@@ -103,15 +103,25 @@ const slackInput = (slackWebhookUrl: string) => ({
103
103
 
104
104
  describe("the declaration", () => {
105
105
  test("is a workflow app with one workflow and a static page", () => {
106
- expect(agentDef.name).toBe("Podcast Digest");
107
- expect(Object.keys(agentDef.workflows ?? {})).toEqual(["dailyDigest"]);
106
+ // Not the agent's name: renaming it is an invited edit, and pinning the
107
+ // literal here would fail a build in somebody else's project.
108
+ expect(agentDef.name).toBeTruthy();
109
+ // `toContain` rather than an exact key list: adding 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 — the page starts a run by this
112
+ // string, so renaming the key is a runtime 400 rather than a compile
113
+ // error, and this pin is the only thing that says so. Rename it here and
114
+ // in `client.tsx` together.
115
+ expect(Object.keys(agentDef.workflows ?? {})).toContain("dailyDigest");
108
116
  expect(agentDef.workflows?.dailyDigest).toBe(dailyDigest);
109
117
  });
110
118
 
111
119
  test("names the one credential its steps read", () => {
112
120
  // A workflow app declares no providers, so nothing else in the config can
113
121
  // name this — which is what makes a deploy able to check for it.
114
- expect(agentDef.requiredEnv).toEqual(["ASSEMBLYAI_API_KEY"]);
122
+ // `toContain`, so a step of your own that reads a second credential can be
123
+ // declared beside this one without failing a test you did not write.
124
+ expect(agentDef.requiredEnv).toContain("ASSEMBLYAI_API_KEY");
115
125
  });
116
126
  });
117
127
 
@@ -17,12 +17,13 @@
17
17
  * minutes, this one loses the only Stop button a thirty-digest commitment has,
18
18
  * and the run is invisible from then on to everything but `curl`.
19
19
  *
20
- * `key` is the handle that survives and `recover: true` is what reads it back,
21
- * so a later load lands on the same count, the same newest line, and the same
22
- * Wake and Cancel buttons bound to the same run.
20
+ * A correlation KEY is the handle that survives, and `useWorkflowSubmit` looks
21
+ * that key's newest run up as it mounts, so a later load lands on the same
22
+ * count, the same newest line, and the same Wake and Cancel buttons bound to
23
+ * the same run.
23
24
  *
24
- * **The key is `useRunKey({ storage: "local" })`, and this is the one workflow
25
- * app that passes that option.** Its siblings take the default,
25
+ * **This is the one workflow app that passes a `key` of its own, and it is
26
+ * `useRunKey({ storage: "local" })`.** Its siblings let the hook mint one into
26
27
  * `sessionStorage`, which dies with the tab and covers exactly the interruption
27
28
  * they have — a reload, a same-tab navigation, a crashed tab. A schedule
28
29
  * outlives all of that by design: closing the browser on Tuesday and coming
@@ -42,9 +43,9 @@
42
43
  * URL would carry a credential into a lookup token — which is why the
43
44
  * platform stopped writing keys to the operator's log.
44
45
  *
45
- * A real app with accounts passes the account's own id instead, and then the
46
- * schedule follows the person to a new device — a promise only a login can
47
- * keep.
46
+ * A real app with accounts passes the account's own id as `key` instead, and
47
+ * then the schedule follows the person to a new device — a promise only a login
48
+ * can keep.
48
49
  *
49
50
  * ## The form is DECLARED, not written
50
51
  *
@@ -135,15 +136,11 @@ export function App() {
135
136
  // `run.output` instead of `unknown`. `error` is the agent's own sentence for a
136
137
  // rejected input — better copy than anything this page could write, and the
137
138
  // reason there is no `try`/`catch` here.
139
+ // The key REPLACES the tab-scoped one the hook would mint; the lookup that
140
+ // reads it back on the next load happens either way.
138
141
  const { submitForm, run, pending, error, wake, cancel } = useWorkflowSubmit<typeof dailyDigest>(
139
142
  WORKFLOW,
140
- {
141
- // Recorded with the run, and read back on the next load. Neither half is
142
- // useful alone: without the key there is nothing to find the run by, and
143
- // without `recover` the key is only ever written.
144
- key,
145
- recover: true,
146
- },
143
+ { key },
147
144
  );
148
145
 
149
146
  return (
@@ -116,7 +116,13 @@ function finishedOutput(over: { kept?: boolean; answered?: boolean } = {}) {
116
116
 
117
117
  describe("the agent declares its workflow", () => {
118
118
  test("under the name ctx.workflows.start resolves it by", () => {
119
- expect(Object.keys(agentDef.workflows ?? {})).toEqual(["recap"]);
119
+ // `toContain` rather than an exact key list: a second workflow is an
120
+ // invited edit and must not redden a test the author did not write. The
121
+ // NAME is still pinned, deliberately — this key is a STRING to everything
122
+ // outside this file (the REST route, `ctx.workflows.get`, a schedule), so
123
+ // renaming it is a runtime 404 rather than a compile error, and nothing
124
+ // else says so.
125
+ expect(Object.keys(agentDef.workflows ?? {})).toContain("recap");
120
126
  expect(agentDef.workflows?.recap).toBe(recap);
121
127
  });
122
128
 
@@ -143,14 +149,21 @@ describe("the agent declares its workflow", () => {
143
149
  });
144
150
 
145
151
  test("discovers every tool in tools/, by file name", () => {
146
- // Discovered, not declared: every name here is a file in `tools/`.
147
- expect(Object.keys(agentDef.tools).sort()).toEqual([
148
- "cancel_recap",
149
- "keep_transcript",
150
- "recap_progress",
151
- "recap_status",
152
- "request_recap",
153
- ]);
152
+ // Discovered, not declared: every name here is a file in `tools/`. Asserted
153
+ // with `arrayContaining`, because dropping a file into `tools/` is the
154
+ // cheapest edit this template invites and an exact list would turn it into
155
+ // a failing test in somebody else's project. What still fails is a tool
156
+ // going MISSING — which is what a broken discovery looks like, and it looks
157
+ // identical to a template that never had tools.
158
+ expect(Object.keys(agentDef.tools)).toEqual(
159
+ expect.arrayContaining([
160
+ "cancel_recap",
161
+ "keep_transcript",
162
+ "recap_progress",
163
+ "recap_status",
164
+ "request_recap",
165
+ ]),
166
+ );
154
167
  });
155
168
  });
156
169
 
@@ -46,7 +46,13 @@ describe("the agent declares itself a workflow app", () => {
46
46
  test("under the name the page starts a run by", () => {
47
47
  // `useWorkflowSubmit("redline")` in client.tsx names this key. Nothing else
48
48
  // records it, so a rename here is a 400 there rather than a compile error.
49
- expect(Object.keys(agentDef.workflows ?? {})).toEqual(["redline"]);
49
+ // `toContain` rather than an exact key list: adding a second workflow is an
50
+ // invited edit and must not redden a test the author did not write. The
51
+ // NAME is still pinned, deliberately — the page starts a run by this
52
+ // string, so renaming the key is a runtime 400 rather than a compile
53
+ // error, and this pin is the only thing that says so. Rename it here and
54
+ // in `client.tsx` together.
55
+ expect(Object.keys(agentDef.workflows ?? {})).toContain("redline");
50
56
  expect(agentDef.workflows?.redline).toBe(redline);
51
57
  });
52
58
 
@@ -31,11 +31,13 @@
31
31
  * in React state — so a refresh lost it while the loop carried on writing,
32
32
  * grading and revising without anywhere to report to. On a desk whose whole
33
33
  * subject is a loop that runs several long-form model calls, that is the one
34
- * failure worth two lines of wiring: `key` is the handle that survives a reload
35
- * and `recover` is what reads it back, so the draft, the critique trail and the
36
- * Clear button are all there again.
34
+ * failure the hook now covers on its own: `useWorkflowSubmit` records every run
35
+ * under a correlation KEY it mints for this page and asks for that key's newest
36
+ * run as it mounts, so the draft, the critique trail and the Clear button are
37
+ * all there again with nothing written here.
37
38
  *
38
- * **The key is opaque and lives in `sessionStorage`, and the brief is why.** A
39
+ * **The key it mints is opaque and lives in `sessionStorage`, and the brief is
40
+ * why this page wants exactly that one.** A
39
41
  * `?key=` parameter in the page's own URL would survive more — a new tab, a
40
42
  * bookmark, a link sent to the person who asked for the piece — and that is
41
43
  * exactly what it must not do here. There is no per-user filtering behind
@@ -48,7 +50,9 @@
48
50
  *
49
51
  * Deriving the key from the brief is worse again: two people briefing the same
50
52
  * thing would recover each other's runs, and the key would then carry what they
51
- * typed into a lookup token the platform deliberately stopped logging.
53
+ * typed into a lookup token the platform deliberately stopped logging. Both are
54
+ * things a page could still ask for by passing its own `key`, and this one has
55
+ * no reason to.
52
56
  */
53
57
 
54
58
  import "@alexkroman1/aai-ui/styles.css";
@@ -60,7 +64,6 @@ import {
60
64
  page,
61
65
  SubmitButton,
62
66
  TextAreaField,
63
- useRunKey,
64
67
  useWorkflowSubmit,
65
68
  WORKFLOW_STATUS_LABELS,
66
69
  WorkflowFields,
@@ -121,19 +124,13 @@ export function toInput(values: FormValues): WorkflowInputOf<typeof redline> {
121
124
  }
122
125
 
123
126
  function RedlineDesk() {
124
- // This tab's handle on its own drafts — opaque, short, and remembered for the
125
- // next load, which is what `recover` produces to find the run again.
126
- const key = useRunKey();
127
127
  // Did THIS load start the run? A reload cannot have, and that is the only way
128
128
  // the page can tell "writing what you just briefed" from "picking up where
129
129
  // you left off" — the hook reports the run, not who asked for it.
130
130
  const [startedHere, setStartedHere] = useState(false);
131
- const { submit, run, pending, error, reset } = useWorkflowSubmit<typeof redline>(WORKFLOW, {
132
- // Neither half is useful alone: without the key there is nothing to find
133
- // the run by, and without `recover` the key is only ever written.
134
- key,
135
- recover: true,
136
- });
131
+ // The reload is covered by the hook's own key see the module doc for why
132
+ // this desk wants the tab-scoped one it mints rather than a key of its own.
133
+ const { submit, run, pending, error, reset } = useWorkflowSubmit<typeof redline>(WORKFLOW);
137
134
 
138
135
  return (
139
136
  <main className="mx-auto flex max-w-2xl flex-col gap-8 p-8">
@@ -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
 
@@ -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
  });
@@ -55,13 +55,22 @@ describe("the declaration", () => {
55
55
  test("is a workflow app with the one workflow the page starts by name", () => {
56
56
  // The page calls `api.start("spokenSummary", …)`, so a rename here is a
57
57
  // runtime 400 rather than a compile error. This is what pins it.
58
- expect(Object.keys(agentDef.workflows ?? {})).toEqual(["spokenSummary"]);
58
+ // `toContain` rather than an exact key list: adding a second workflow is an
59
+ // invited edit and must not redden a test the author did not write. The
60
+ // NAME is still pinned, deliberately — the page starts a run by this
61
+ // string, so renaming the key is a runtime 400 rather than a compile
62
+ // error, and this pin is the only thing that says so. Rename it here and
63
+ // in `client.tsx` together.
64
+ expect(Object.keys(agentDef.workflows ?? {})).toContain("spokenSummary");
65
+ expect(agentDef.workflows?.spokenSummary).toBe(spokenSummary);
59
66
  });
60
67
 
61
- test("declares no providers and exactly the one credential its steps read", () => {
68
+ test("declares the credential its steps read, so a deploy checks for it", () => {
62
69
  // A workflow app has no session, so nothing else in its config could name
63
70
  // one — and one AssemblyAI key covers transcription, the model and the voice.
64
- expect(agentDef.requiredEnv).toEqual(["ASSEMBLYAI_API_KEY"]);
71
+ // `toContain`, so adding a step that reads a second credential and
72
+ // declaring it here does not fail this test.
73
+ expect(agentDef.requiredEnv).toContain("ASSEMBLYAI_API_KEY");
65
74
  });
66
75
 
67
76
  test("takes the recording as an UPLOAD, which is what makes the form a file picker", () => {
@@ -37,14 +37,15 @@
37
37
  * in React state — so a refresh lost it while the recording carried on being
38
38
  * transcribed, summarized and spoken. The bytes were already stored, so the
39
39
  * expensive half had happened; what an empty form invites is somebody uploading
40
- * the recording again and paying for all four legs twice. `key` is the handle
41
- * that survives a reload, `recover: true` is what reads it back, and the upload
42
- * half is already the SDK's picking the same file again sends only the windows
43
- * that did not land.
40
+ * the recording again and paying for all four legs twice. Both halves are the
41
+ * SDK's now and this page writes neither: `useWorkflowSubmit` records the run
42
+ * under a correlation KEY it keeps for this page and asks for it again on the
43
+ * next load, and picking the same file again sends only the windows that did
44
+ * not land.
44
45
  *
45
- * **The key is `useRunKey()`** — opaque, and in `sessionStorage`, which is the
46
- * same lifetime as that upload recall, so both halves of a reload make the same
47
- * promise. What that rules out is a `?key=` parameter, and it is worth being
46
+ * **That key is `useRunKey()`'s** — opaque, and in `sessionStorage`, which is
47
+ * the same lifetime as the upload recall, so both halves of a reload make the
48
+ * same promise. What it rules out is a `?key=` parameter, and it is worth being
48
49
  * plain about the trade because this template exists to produce something
49
50
  * sendable: a summary you can listen to is exactly the sort of thing somebody
50
51
  * forwards, and a URL is how people forward things. But there is no per-user
@@ -57,9 +58,9 @@
57
58
  * the recording would be worse again: two people summarizing one file would
58
59
  * recover each other's runs.
59
60
  *
60
- * A real app with accounts passes the account's own id instead, and then a
61
- * summary follows the person to another device — a promise only a login can
62
- * keep.
61
+ * A real app with accounts passes the account's own id as `key` instead, and
62
+ * then a summary follows the person to another device — a promise only a login
63
+ * can keep.
63
64
  */
64
65
 
65
66
  import "@alexkroman1/aai-ui/styles.css";
@@ -74,7 +75,6 @@ import {
74
75
  SubmitButton,
75
76
  UploadProgressBar,
76
77
  useDownloadUrl,
77
- useRunKey,
78
78
  useWorkflowSubmit,
79
79
  WorkflowFields,
80
80
  WorkflowProgress,
@@ -128,20 +128,16 @@ function captionsUrl(text: string, durationMs: number): string {
128
128
  }
129
129
 
130
130
  export function App() {
131
- // This tab's handle on its own summaries, in the store the upload recall uses
132
- // — see the module doc for why a `?key=` is the wrong trade here.
133
- const key = useRunKey();
134
131
  // Did THIS load start the run? A reload cannot have, and that is the only way
135
132
  // the page can tell "working on what you just sent" from "picking up where
136
133
  // you left off" — the hook reports the run, not who asked for it.
137
134
  const [startedHere, setStartedHere] = useState(false);
138
135
  // The generic is what makes `run.status === "completed"` narrow to a TYPED
139
- // `run.output` instead of `unknown`. Neither half of the recovery is useful
140
- // alone: without `key` there is nothing to find the run by, and without
141
- // `recover` the key is only ever written.
136
+ // `run.output` instead of `unknown`. The reload is the hook's own doing — see
137
+ // the module doc for why the key it mints is the right one for this page.
142
138
  const { submitForm, run, pending, upload, pauseUpload, resumeUpload, error } = useWorkflowSubmit<
143
139
  typeof spokenSummary
144
- >(WORKFLOW, { api, key, recover: true });
140
+ >(WORKFLOW, { api });
145
141
  const output = run?.status === "completed" ? run.output : undefined;
146
142
  // `useDownloadUrl` is the SDK's: the byte route takes the agent's bearer, so the
147
143
  // bytes have to be FETCHED and handed to the element as an object URL — and the