@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
@@ -4,7 +4,23 @@
4
4
  # For production, use `aai secret put <NAME>` to set the same keys on the server.
5
5
  # In both cases, access secrets in agent code via ctx.env.MY_KEY.
6
6
  #
7
- # Add any secrets your agent needs below:
7
+ # --- The one key the default voice pipeline needs --------------------------
8
+ #
9
+ # `aai dev` runs speech-to-text, the LLM gateway and text-to-speech on a single
10
+ # AssemblyAI key, so this is the credential a session cannot start without.
11
+ # Any ONE of these is enough, and the first two need no aai account:
12
+ #
13
+ # 1. Paste your key after the `=` below.
14
+ # 2. Or export it in your shell: export ASSEMBLYAI_API_KEY=<your key>
15
+ # 3. Or run `aai login`, and `aai dev` will use your account's key.
16
+ #
17
+ # Get a key at https://www.assemblyai.com/dashboard.
18
+ #
19
+ # Leaving it blank is safe: a declared-but-empty value is dropped rather than
20
+ # handed to a provider, so options 2 and 3 still work with this line as it is.
21
+ ASSEMBLYAI_API_KEY=
22
+
23
+ # Add any other secrets your agent needs below:
8
24
 
9
25
  # Required only if your agent enables the `web_search` builtin
10
26
  # (free key: https://brave.com/search/api/):
@@ -9,14 +9,15 @@
9
9
  "prestart": "aai build --skip-tests",
10
10
  "start": "node server.mjs",
11
11
  "build": "aai build",
12
- "test": "aai test",
12
+ "test": "vitest run --exclude \"**/*.eval.test.*\"",
13
+ "test:agent": "aai test",
13
14
  "eval": "aai eval",
14
15
  "publish:agent": "aai publish"
15
16
  },
16
17
  "dependencies": {
17
- "@alexkroman1/aai": "^13.2.0",
18
- "@alexkroman1/aai-runtime": "^13.2.0",
19
- "@alexkroman1/aai-ui": "^13.2.0",
18
+ "@alexkroman1/aai": "^13.3.0",
19
+ "@alexkroman1/aai-runtime": "^13.3.0",
20
+ "@alexkroman1/aai-ui": "^13.3.0",
20
21
  "react": "^19.2.8",
21
22
  "react-dom": "^19.2.8",
22
23
  "tailwindcss": "^4.0.0",
@@ -24,7 +25,7 @@
24
25
  "zod": "^4.5.4"
25
26
  },
26
27
  "devDependencies": {
27
- "@alexkroman1/aai-cli": "^13.2.0",
28
+ "@alexkroman1/aai-cli": "^13.3.0",
28
29
  "@tailwindcss/vite": "^4.3.3",
29
30
  "@types/node": "^26.4.1",
30
31
  "@types/react": "^19.2.18",
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env node
2
+ import { a as ok, n as fail, t as CliError } from "./_output-DbJt9gsl.mjs";
3
+ import { n as log, s as unwrapCancel } from "./_ui-DCt4qZrk.mjs";
4
+ import { f as checkedResponse, p as isStringArray } from "./_studio-DTi3EdXQ.mjs";
5
+ import { t as secretRequest } from "./_slug-api-DlKpj91X.mjs";
6
+ import { isRecord } from "@alexkroman1/aai/utils";
7
+ import * as p from "@clack/prompts";
8
+ import pTimeout from "p-timeout";
9
+ //#region src/secret.ts
10
+ /**
11
+ * The one `no_input` failure for `secret put`, shared by every path that can
12
+ * reach it — the stdin read that came back empty, the refusal when stdin is a
13
+ * terminal and JSON mode cannot prompt, and the prompt the user dismissed —
14
+ * so the three cannot drift.
15
+ *
16
+ * It is built here rather than kept as a fixed tuple so the hint can name the
17
+ * secret AND both ways of supplying a value: that sentence is the contract,
18
+ * and until this commit it appeared nowhere — not in `--help`, not in the
19
+ * scaffolded README (which said it prompts), and not in the failure itself,
20
+ * which said only "Pipe secret value to stdin".
21
+ */
22
+ function noInput(name, detail) {
23
+ return {
24
+ code: "no_input",
25
+ message: `No value provided for ${name} (${detail})`,
26
+ hint: `Pipe the value in — \`printf %s "$VALUE" | aai secret put ${name}\` — or run the command on a terminal to be prompted for it.`
27
+ };
28
+ }
29
+ /**
30
+ * How long stdin may stay SILENT before `secret put` gives up on it.
31
+ *
32
+ * It bounds only the FIRST byte — a producer that has started is then given
33
+ * as long as it likes, because a secret usually comes out of something slow
34
+ * (`pass show`, `op read`, a KMS call) and cutting it off mid-value would be
35
+ * worse than waiting.
36
+ *
37
+ * What the bound is for is stdin with no producer behind it at all: an
38
+ * inherited-but-idle pipe, which is what an agent harness, a CI step, a
39
+ * supervisor and an editor task all hand a child. Reading to EOF is correct
40
+ * and the EOF was never coming, so the command printed NOTHING and blocked
41
+ * forever — on the only documented way to get a credential into production.
42
+ * A bounded wait that ends in a sentence naming both working forms is the
43
+ * whole difference between "unusable" and "I typed it wrong".
44
+ */
45
+ const STDIN_FIRST_BYTE_MS = 1e4;
46
+ /**
47
+ * Read stdin to EOF, giving up if nothing arrives at all — `undefined` for
48
+ * that case, distinct from `""` for a producer that sent nothing and closed.
49
+ *
50
+ * Hand-rolled rather than `text(process.stdin)` because the deadline has to
51
+ * apply to the first chunk and NOT to the whole read; `p-timeout`'s
52
+ * `message: false` resolves `undefined` instead of rejecting, so there is no
53
+ * timer to clear and no losing branch to reject late.
54
+ */
55
+ async function readStdinToEnd(stdin, firstByteMs) {
56
+ const chunks = [];
57
+ const firstByte = Promise.withResolvers();
58
+ const ended = Promise.withResolvers();
59
+ const onData = (chunk) => {
60
+ chunks.push(chunk);
61
+ firstByte.resolve(true);
62
+ };
63
+ const onEnd = () => {
64
+ firstByte.resolve(true);
65
+ ended.resolve();
66
+ };
67
+ const onError = (err) => {
68
+ firstByte.reject(err);
69
+ ended.reject(err);
70
+ };
71
+ stdin.on("data", onData).once("end", onEnd).once("error", onError);
72
+ try {
73
+ if (!await pTimeout(firstByte.promise, {
74
+ milliseconds: firstByteMs,
75
+ message: false
76
+ })) return void 0;
77
+ await ended.promise;
78
+ return Buffer.concat(chunks).toString("utf8").trim();
79
+ } finally {
80
+ stdin.off("data", onData).off("end", onEnd).off("error", onError);
81
+ stdin.pause();
82
+ }
83
+ }
84
+ /**
85
+ * The value for `secret put`, from whichever source is actually available —
86
+ * or `undefined` to mean "prompt", which {@link executeSecretPut} does.
87
+ *
88
+ * Keyed off STDIN, deliberately, and never off the output mode. A stdin read
89
+ * resolves at EOF and a terminal never sends one, so keying the read off
90
+ * `mode === "json"` — which is decided by STDOUT — made `aai secret put NAME`
91
+ * print NOTHING and block forever in every context where stdout is not a
92
+ * terminal but stdin still is: a CI step, `| tee`, an agent harness, an
93
+ * editor task. That is the worst failure a CLI can have on the only
94
+ * documented way to get a credential into production, and it is why each of
95
+ * the three cases below is answered at once:
96
+ *
97
+ * - stdin is a pipe or a file: read it to EOF — the documented contract —
98
+ * bounded on the FIRST byte so an idle inherited pipe cannot block the
99
+ * command forever (see {@link STDIN_FIRST_BYTE_MS}). An empty read is
100
+ * refused rather than sent to the server.
101
+ * - stdin is a terminal, human output: `undefined`, so the executor prompts.
102
+ * - stdin is a terminal, JSON output: refused immediately. JSON mode promises
103
+ * exactly one result line on stdout and cannot spend it on a prompt — so it
104
+ * says which two forms do work instead of waiting for an EOF that a person
105
+ * sitting at a terminal has no reason to send.
106
+ */
107
+ async function resolveSecretValue(name, mode, io = {}) {
108
+ if (!(io.stdinIsTTY ?? Boolean(process.stdin.isTTY))) {
109
+ const firstByteMs = io.firstByteMs ?? STDIN_FIRST_BYTE_MS;
110
+ const piped = await readStdinToEnd(io.stdin ?? process.stdin, firstByteMs);
111
+ if (piped) return piped;
112
+ const { code, message, hint } = noInput(name, piped === void 0 ? `nothing arrived on stdin within ${Math.round(firstByteMs / 1e3)}s` : "stdin was empty");
113
+ throw new CliError(code, message, hint);
114
+ }
115
+ if (mode === "json") {
116
+ const { code, message, hint } = noInput(name, "stdin is a terminal and --json cannot prompt");
117
+ throw new CliError(code, message, hint);
118
+ }
119
+ }
120
+ /**
121
+ * Execute secret put. If `value` is provided, use it directly (the stdin
122
+ * path). If not, prompt for it — masked — which is what
123
+ * {@link resolveSecretValue} returning `undefined` asks for.
124
+ */
125
+ async function executeSecretPut(cwd, name, value, server) {
126
+ let secretValue = value;
127
+ if (!secretValue) {
128
+ const result = unwrapCancel(await p.password({ message: `Enter value for ${name}` }));
129
+ if (!result) {
130
+ const { code, message, hint } = noInput(name, "the prompt came back empty");
131
+ return fail(code, message, hint);
132
+ }
133
+ secretValue = result;
134
+ }
135
+ const { target } = await secretRequest(cwd, "", {
136
+ method: "PUT",
137
+ body: { [name]: secretValue },
138
+ action: "secret"
139
+ }, server);
140
+ log.success(`Set ${name} for ${target}`);
141
+ return ok({ name });
142
+ }
143
+ async function executeSecretDelete(cwd, name, server) {
144
+ const { target } = await secretRequest(cwd, `/${encodeURIComponent(name)}`, {
145
+ method: "DELETE",
146
+ action: "secret"
147
+ }, server);
148
+ log.success(`Deleted ${name} from ${target}`);
149
+ return ok({ name });
150
+ }
151
+ async function executeSecretList(cwd, server) {
152
+ const { data, target } = await secretRequest(cwd, "", { action: "secret" }, server);
153
+ const { vars } = checkedResponse(data, (value) => isRecord(value) && isStringArray(value.vars), `the secret list for ${target}`);
154
+ if (vars.length === 0) log.info("No secrets set. Use `aai secret put <name>` to add one.");
155
+ else {
156
+ log.message(`${vars.length} secret${vars.length === 1 ? "" : "s"}:`);
157
+ for (const v of vars) log.message(` ${v}`);
158
+ }
159
+ return ok({ secrets: vars });
160
+ }
161
+ //#endregion
162
+ export { executeSecretDelete, executeSecretList, executeSecretPut, resolveSecretValue };
package/dist/secret.d.ts CHANGED
@@ -1,11 +1,43 @@
1
- import { type CommandResult } from "./_output.ts";
1
+ import type { Readable } from "node:stream";
2
+ import { type CommandResult, type OutputMode } from "./_output.ts";
2
3
  /**
3
- * The one `no_input` failure for `secret put`, shared by the JSON-mode stdin
4
- * path (cli.ts) and the TTY prompt path below so the two can't drift.
4
+ * The environment {@link resolveSecretValue} branches on, as a seam a spec can
5
+ * supply a `Readable` rather than a fake reader function, so a test drives
6
+ * the real wait (the part that blocked) and needs no cast to stand in for
7
+ * `process.stdin`.
5
8
  */
6
- export declare const NO_INPUT: readonly ["no_input", "No value provided", "Pipe secret value to stdin"];
7
- /** Read secret value from stdin (for non-TTY / piped input). */
8
- export declare function readStdin(): Promise<string>;
9
+ export type SecretValueIo = {
10
+ /** Defaults to `process.stdin`. */
11
+ stdin?: Readable | undefined;
12
+ /** Defaults to whether the real stdin is a terminal. */
13
+ stdinIsTTY?: boolean | undefined;
14
+ /** Defaults to {@link STDIN_FIRST_BYTE_MS}. */
15
+ firstByteMs?: number | undefined;
16
+ };
17
+ /**
18
+ * The value for `secret put`, from whichever source is actually available —
19
+ * or `undefined` to mean "prompt", which {@link executeSecretPut} does.
20
+ *
21
+ * Keyed off STDIN, deliberately, and never off the output mode. A stdin read
22
+ * resolves at EOF and a terminal never sends one, so keying the read off
23
+ * `mode === "json"` — which is decided by STDOUT — made `aai secret put NAME`
24
+ * print NOTHING and block forever in every context where stdout is not a
25
+ * terminal but stdin still is: a CI step, `| tee`, an agent harness, an
26
+ * editor task. That is the worst failure a CLI can have on the only
27
+ * documented way to get a credential into production, and it is why each of
28
+ * the three cases below is answered at once:
29
+ *
30
+ * - stdin is a pipe or a file: read it to EOF — the documented contract —
31
+ * bounded on the FIRST byte so an idle inherited pipe cannot block the
32
+ * command forever (see {@link STDIN_FIRST_BYTE_MS}). An empty read is
33
+ * refused rather than sent to the server.
34
+ * - stdin is a terminal, human output: `undefined`, so the executor prompts.
35
+ * - stdin is a terminal, JSON output: refused immediately. JSON mode promises
36
+ * exactly one result line on stdout and cannot spend it on a prompt — so it
37
+ * says which two forms do work instead of waiting for an EOF that a person
38
+ * sitting at a terminal has no reason to send.
39
+ */
40
+ export declare function resolveSecretValue(name: string, mode: OutputMode, io?: SecretValueIo): Promise<string | undefined>;
9
41
  type SecretPutData = {
10
42
  name: string;
11
43
  };
@@ -16,8 +48,9 @@ type SecretListData = {
16
48
  secrets: string[];
17
49
  };
18
50
  /**
19
- * Execute secret put. If `value` is provided, use it directly (non-TTY path).
20
- * If not provided, prompt interactively (TTY path).
51
+ * Execute secret put. If `value` is provided, use it directly (the stdin
52
+ * path). If not, prompt for it — masked — which is what
53
+ * {@link resolveSecretValue} returning `undefined` asks for.
21
54
  */
22
55
  export declare function executeSecretPut(cwd: string, name: string, value: string | undefined, server: string | undefined): Promise<CommandResult<SecretPutData>>;
23
56
  export declare function executeSecretDelete(cwd: string, name: string, server: string | undefined): Promise<CommandResult<SecretDeleteData>>;
@@ -1,17 +1,17 @@
1
1
  #!/usr/bin/env node
2
- import { a as ok, t as CliError } from "./_output-CKkmWs7i.mjs";
3
- import { n as log, t as fmtUrl } from "./_ui-u7T4YooX.mjs";
4
- import { s as updateProjectConfig } from "./_config-CmJOFsAP.mjs";
5
- import { t as resolveServerEnv } from "./_server-common-6e0QI4mq.mjs";
6
- import { a as resolveDeployTarget } from "./_agent-BTwZJu4P.mjs";
7
- import { layerScaffold } from "./_templates-0Zr2z6yA.mjs";
8
- import { a as publishStudioProject, c as studioProjectUrl, f as checkedResponse, i as projectNameFromDir, n as fetchStudioProject, o as pushStudioSource, r as listStudioProjects, s as studioProjectApiUrl, t as collectSourceFiles, u as apiRequest } from "./_studio-B1waXMuR.mjs";
2
+ import { a as ok, t as CliError } from "./_output-DbJt9gsl.mjs";
3
+ import { n as log, t as fmtUrl } from "./_ui-DCt4qZrk.mjs";
4
+ import { s as updateProjectConfig } from "./_config-ZY2euhdh.mjs";
5
+ import { t as resolveServerEnv } from "./_server-common-BV4_CrP3.mjs";
6
+ import { a as resolveDeployTarget } from "./_agent-Clbq3a4h.mjs";
7
+ import { layerScaffold } from "./_templates-V4l5sZJy.mjs";
8
+ import { a as publishStudioProject, c as studioProjectUrl, f as checkedResponse, i as projectNameFromDir, n as fetchStudioProject, o as pushStudioSource, r as listStudioProjects, s as studioProjectApiUrl, t as collectSourceFiles, u as apiRequest } from "./_studio-DTi3EdXQ.mjs";
9
9
  import { existsSync } from "node:fs";
10
10
  import path from "node:path";
11
11
  import { isRecord, omitUndefined } from "@alexkroman1/aai/utils";
12
12
  import { mkdir, readdir, writeFile } from "node:fs/promises";
13
13
  import { isPathInside } from "@alexkroman1/aai-runtime/internal";
14
- //#region studio.ts
14
+ //#region src/studio.ts
15
15
  /**
16
16
  * The studio-workspace commands: `aai list`, `aai pull`, `aai push`,
17
17
  * `aai publish`.
@@ -164,7 +164,7 @@ async function syncEnvSecrets(cwd, serverUrl, apiKey, project) {
164
164
  }
165
165
  async function executePublish(opts) {
166
166
  if (!opts.skipTypecheck) {
167
- const { assertTypechecks } = await import("./_typecheck-gate-BXvUNnfs.mjs");
167
+ const { assertTypechecks } = await import("./_typecheck-gate-sV4N5F93.mjs");
168
168
  await assertTypechecks(opts.cwd);
169
169
  }
170
170
  const pushed = await pushProject(opts);
@@ -63,10 +63,14 @@ function desk(
63
63
 
64
64
  describe("the desk itself", () => {
65
65
  test("has no web tools of its own — everything goes through a subagent", () => {
66
- expect(authoredAgent.builtinTools ?? []).toEqual([]);
67
- // And the researcher does, which is the split the template exists to show.
66
+ // Stated as the claim the test's name makes, rather than as "no builtins at
67
+ // all": giving the desk `run_code` is a reasonable edit, and it does not
68
+ // put the web in front of the desk.
69
+ expect(authoredAgent.builtinTools ?? []).not.toContain("web_search");
70
+ expect(authoredAgent.builtinTools ?? []).not.toContain("visit_webpage");
71
+ // And the subagents do, which is the split the template exists to show.
68
72
  expect(researcher.builtinTools).toContain("web_search");
69
- expect(factChecker.builtinTools).toEqual(["web_search"]);
73
+ expect(factChecker.builtinTools).toContain("web_search");
70
74
  });
71
75
 
72
76
  test("gives the checker a tighter budget than the researcher", () => {
@@ -182,15 +182,26 @@ describe("the declaration", () => {
182
182
  test("is a workflow app with the one workflow the page starts by name", () => {
183
183
  // The page calls `api.start("audit", …)`, so a rename here is a runtime 400
184
184
  // rather than a compile error. This is what pins it.
185
- expect(Object.keys(agentDef.workflows ?? {})).toEqual(["audit"]);
185
+ // `toContain` rather than an exact key list: adding a second workflow is an
186
+ // invited edit and must not redden a test the author did not write. The
187
+ // NAME is still pinned, deliberately — the page starts a run by this
188
+ // string, so renaming the key is a runtime 400 rather than a compile
189
+ // error, and this pin is the only thing that says so. Rename it here and
190
+ // in `client.tsx` together.
191
+ expect(Object.keys(agentDef.workflows ?? {})).toContain("audit");
192
+ expect(agentDef.workflows?.audit).toBe(audit);
186
193
  });
187
194
 
188
- test("declares no providers and exactly the one credential its steps read", () => {
195
+ test("declares the credential its steps read, so a deploy checks for it", () => {
189
196
  // A workflow app has no session, so nothing else in its config could name one
190
197
  // — and one AssemblyAI key covers transcription, the model and the voice.
191
198
  // Note what is NOT here: ffmpeg. `requiredEnv` checks the environment, and a
192
199
  // binary on `PATH` is not an environment variable.
193
- expect(agentDef.requiredEnv).toEqual(["ASSEMBLYAI_API_KEY"]);
200
+ //
201
+ // `toContain` rather than an exact list: a step of your own that reads a
202
+ // second credential belongs in `requiredEnv` beside this one, and declaring
203
+ // it must not fail this test.
204
+ expect(agentDef.requiredEnv).toContain("ASSEMBLYAI_API_KEY");
194
205
  });
195
206
 
196
207
  test("takes the recording as an UPLOAD, which is what makes the form a file picker", () => {
@@ -30,15 +30,16 @@
30
30
  * cutting and auditing. On this template that is the most expensive orphaning
31
31
  * in `templates/`: the recording is already stored, so the work is paid for, and
32
32
  * a page with no handle on it invites somebody to upload a 700 MB call a second
33
- * time and run the whole pipeline again. `key` is the handle that survives a
34
- * reload and `recover: true` is what reads it back.
33
+ * time and run the whole pipeline again. The handle that survives a reload is a
34
+ * correlation KEY, and this desk passes none: `useWorkflowSubmit` mints one,
35
+ * records every run under it, and asks for that key's newest run as it mounts.
35
36
  *
36
- * The upload half of a reload is already the SDK's: `useWorkflowSubmit`
37
- * remembers the id it minted, so picking the same file again sends only the
38
- * windows that did not land. What it needs from the page is the same LIFETIME
39
- * on both halves, which is the first reason this desk takes `useRunKey()`'s
40
- * default `sessionStorage`, the same store the upload recall uses. A handle
41
- * that outlived it would promise a return the other half cannot keep.
37
+ * The upload half of a reload is the same hook's: it remembers the id it
38
+ * minted, so picking the same file again sends only the windows that did not
39
+ * land. Both halves therefore have the same LIFETIME `sessionStorage`, one
40
+ * store which is the first reason this desk wants the default rather than a
41
+ * key of its own. A handle that outlived it would promise a return the other
42
+ * half cannot keep.
42
43
  *
43
44
  * ## Why the artifact being shareable does NOT make the key shareable
44
45
  *
@@ -61,7 +62,7 @@
61
62
  * needs none of it — the page renders the audit and offers `Download audit.mp3`,
62
63
  * so a person sends the file and the findings deliberately, to exactly who they
63
64
  * meant. A shareable key would trade a deliberate send for an accidental one, on
64
- * the most sensitive input any template here accepts. It stays in
65
+ * the most sensitive input any template here accepts. The default key stays in
65
66
  * `sessionStorage`, which covers the reload this section is about and dies with
66
67
  * the tab.
67
68
  *
@@ -81,7 +82,6 @@ import {
81
82
  SubmitButton,
82
83
  UploadProgressBar,
83
84
  useDownloadUrl,
84
- useRunKey,
85
85
  useWorkflowSubmit,
86
86
  WorkflowFields,
87
87
  WorkflowProgress,
@@ -146,20 +146,17 @@ function Findings({ title, items }: { title: string; items: string[] }) {
146
146
  }
147
147
 
148
148
  export function App() {
149
- // This tab's handle on its own audits, in the store the upload recall uses —
150
- // see the module doc for why a `?key=` is the wrong trade on this desk.
151
- const key = useRunKey();
152
149
  // Did THIS load start the run? A reload cannot have, and that is the only way
153
150
  // the page can tell "auditing what you just uploaded" from "picking up where
154
151
  // you left off" — the hook reports the run, not who asked for it.
155
152
  const [startedHere, setStartedHere] = useState(false);
156
153
  // The generic is what makes `run.status === "completed"` narrow to a TYPED
157
- // `run.output` instead of `unknown`. Neither half of the recovery is useful
158
- // alone: without `key` there is nothing to find the run by, and without
159
- // `recover` the key is only ever written.
154
+ // `run.output` instead of `unknown`. The reload — both halves of it is the
155
+ // hook's own doing; see the module doc for why the key it mints is the one
156
+ // this desk wants.
160
157
  const { submitForm, run, pending, upload, pauseUpload, resumeUpload, error } = useWorkflowSubmit<
161
158
  typeof audit
162
- >(WORKFLOW, { api, key, recover: true });
159
+ >(WORKFLOW, { api });
163
160
  const output = run?.status === "completed" ? run.output : undefined;
164
161
  // `useDownloadUrl` is the SDK's: the byte route takes the agent's bearer, so the
165
162
  // bytes have to be FETCHED and handed to the element as an object URL — and the
@@ -30,7 +30,13 @@ describe("embedded-assets template", () => {
30
30
  // `agent()` takes no `tools` field: a file in `tools/` IS the tool, and
31
31
  // nothing imports it. Discovery is what puts it in front of the model, so a
32
32
  // template whose tools are never resolved ships a model with no tools.
33
- expect(Object.keys(agentDef.tools ?? {}).sort()).toEqual(["list_topics", "search_knowledge"]);
33
+ // `arrayContaining` rather than an exact list: adding a file to `tools/` is
34
+ // the edit this template most invites, and an exact list would redden on
35
+ // it. Losing one of these two is still a failure — that is the half worth
36
+ // asserting.
37
+ expect(Object.keys(agentDef.tools ?? {})).toEqual(
38
+ expect.arrayContaining(["list_topics", "search_knowledge"]),
39
+ );
34
40
  });
35
41
  });
36
42
 
@@ -57,10 +57,14 @@ describe("health-assistant template", () => {
57
57
  test("both tools are discovered from tools/", () => {
58
58
  // `agent()` takes no `tools` field: a file in `tools/` IS the tool. A
59
59
  // template whose tools are never resolved ships a model with none.
60
- expect(Object.keys(agentDef.tools ?? {}).sort()).toEqual([
61
- "check_drug_interaction",
62
- "medication_lookup",
63
- ]);
60
+ // `arrayContaining` rather than an exact list: a tool you add is the edit
61
+ // this template invites, and it must not redden a test you did not write.
62
+ // Losing one of these two still fails, which is the regression worth
63
+ // catching — discovery silently finding nothing looks exactly like a
64
+ // template with no tools.
65
+ expect(Object.keys(agentDef.tools ?? {})).toEqual(
66
+ expect.arrayContaining(["check_drug_interaction", "medication_lookup"]),
67
+ );
64
68
  });
65
69
  });
66
70
 
@@ -51,7 +51,13 @@ describe("the agent declares itself a workflow app", () => {
51
51
  test("under the name the page starts a run by", () => {
52
52
  // `api.start("digest", …)` in client.tsx names this key. Nothing else
53
53
  // records it, so a rename here is a 400 there rather than a compile error.
54
- expect(Object.keys(agentDef.workflows ?? {})).toEqual(["digest"]);
54
+ // `toContain` rather than an exact key list: adding a second workflow is an
55
+ // invited edit and must not redden a test the author did not write. The
56
+ // NAME is still pinned, deliberately — the page starts a run by this
57
+ // string, so renaming the key is a runtime 400 rather than a compile
58
+ // error, and this pin is the only thing that says so. Rename it here and
59
+ // in `client.tsx` together.
60
+ expect(Object.keys(agentDef.workflows ?? {})).toContain("digest");
55
61
  expect(agentDef.workflows?.digest).toBe(digest);
56
62
  });
57
63
  });
@@ -19,16 +19,18 @@
19
19
  * page used to promise "the run continues without it" and then had no way back
20
20
  * to the run it was promising about.
21
21
  *
22
- * `key` is the handle that survives, and `recover` is what reads it back. Two
23
- * lines of wiring, one decision:
22
+ * A correlation KEY is the handle that survives, and this page writes none of
23
+ * it: `useWorkflowSubmit` mints an opaque per-page key into `sessionStorage`,
24
+ * records every run under it, and asks `find("digest", key)` as it mounts —
25
+ * so a reload lands back on the same headline, the same progress log and the
26
+ * same buttons. Six templates used to write those two options each, which is
27
+ * what made it the default.
24
28
  *
25
- * - **The key is OPAQUE and lives in `sessionStorage`.** It names nothing about
26
- * the person or the link `useRunKey()` is the SDK's, and its module argues
27
- * what that rules out (a key derived from the URL being digested, a `?key=`
28
- * parameter) and why. Six templates had written those twenty lines each.
29
- * - **`recover: true`** makes the hook ask `find("digest", key)` as it mounts
30
- * and follow whatever run comes back, so a reload lands back on the same
31
- * headline, the same progress log and the same buttons.
29
+ * What the page can still say is which key: `useRunKey({ storage: "local" })`
30
+ * for a run meant to outlive the tab (`podcast-digest`), an ACCOUNT's own id
31
+ * for an app with logins, `recover: false` for a form that must always open
32
+ * empty. `use-run-key.ts` argues what a key may not be — derived from the URL
33
+ * being digested, or carried in a `?key=` parameter.
32
34
  *
33
35
  * Deployed, this needs the correlation-key index, which is a `DATABASE_URL`
34
36
  * away — `agent.ts` says what happens without one (the runs are still durable;
@@ -55,14 +57,14 @@
55
57
  * Progress also REPLAYS — chunks are retained with the run — so a reload mid-run
56
58
  * catches up rather than starting from whatever arrives next. That only pays off
57
59
  * because the reload can name its run again: `<WorkflowProgress runId>` is handed
58
- * `run?.runId`, so before `recover` a refresh replayed a log for nobody.
60
+ * `run?.runId`, so before the recovery a refresh replayed a log for nobody.
59
61
  * `lines={1}` is
60
62
  * what narrows it to the newest line, because on a page this small that is the
61
63
  * whole of what a status wants; `transcription-workflow` renders the full log,
62
64
  * where a fan-out makes the history worth seeing.
63
65
  */
64
66
 
65
- import { page, useRunKey, useWorkflowSubmit, WorkflowProgress } from "@alexkroman1/aai-ui";
67
+ import { page, useWorkflowSubmit, WorkflowProgress } from "@alexkroman1/aai-ui";
66
68
  import "@alexkroman1/aai-ui/styles.css";
67
69
  // ERASED at build time, so naming the agent's own type costs the browser bundle
68
70
  // nothing — and it is what stops this file restating a shape `workflows/
@@ -90,9 +92,6 @@ function pendingNote(startedHere: boolean, found: boolean): string {
90
92
 
91
93
  export function App() {
92
94
  const [url, setUrl] = useState("");
93
- // This tab's handle on its own runs — minted once and remembered, which is
94
- // what a later load produces to find the run again.
95
- const key = useRunKey();
96
95
  // Did THIS load start the run? A reload cannot have, and that is the only way
97
96
  // the page can tell "working on what you just submitted" from "picking up
98
97
  // where you left off" — the hook reports the run, not who asked for it.
@@ -102,13 +101,10 @@ export function App() {
102
101
  // rejected input, which is better copy than anything this page could write, and
103
102
  // `wake` is bound to whatever run the hook is following — the whole reason this
104
103
  // page no longer holds a `createWorkflowApi()` of its own.
105
- const { submit, run, pending, error, wake } = useWorkflowSubmit<typeof digest>("digest", {
106
- // Recorded with the run, and read back on the next load. Neither half is
107
- // useful alone: without the key there is nothing to find the run by, and
108
- // without `recover` the key is only ever written.
109
- key,
110
- recover: true,
111
- });
104
+ // No `key` and no `recover`: this tab's handle on its own runs is minted and
105
+ // remembered by the hook, and read back as it mounts. See the module doc for
106
+ // what a page says when it wants a different one.
107
+ const { submit, run, pending, error, wake } = useWorkflowSubmit<typeof digest>("digest");
112
108
 
113
109
  // `submit()` resolves as soon as the run exists — deliberately not when it
114
110
  // finishes. That is the whole mechanism: the digest sleeps for a while, and
@@ -32,7 +32,12 @@ describe("night-owl template", () => {
32
32
  });
33
33
 
34
34
  test("recommend is discovered from tools/", () => {
35
- expect(Object.keys(agentDef.tools ?? {})).toEqual(["recommend"]);
35
+ // `toContain` rather than an exact list: a file in `tools/` IS a tool, so
36
+ // adding one is the edit this template most invites, and an exact list
37
+ // would make that edit fail a test the author never wrote. What has to hold
38
+ // is that discovery ran at all — a template whose `tools/` is never
39
+ // resolved ships a model with no tools.
40
+ expect(Object.keys(agentDef.tools ?? {})).toContain("recommend");
36
41
  });
37
42
 
38
43
  test("the projection an untouched session pushes is an empty log", () => {
@@ -13,25 +13,42 @@ describe("pipeline-simple template", () => {
13
13
  expect(() => toAgentConfig(agentDef)).not.toThrow();
14
14
  });
15
15
 
16
- test("declares only the LLM stage on the def", () => {
17
- // The template's point: swap one stage, leave the rest unset. The
18
- // unset stages are filled with the AssemblyAI defaults at parse time.
19
- expect(agentDef.name).toBe("pipeline-simple");
20
- expect(agentDef.llm).toBeDefined();
21
- expect(agentDef.stt).toBeUndefined();
22
- expect(agentDef.tts).toBeUndefined();
16
+ test("exports an agent the platform can name", () => {
17
+ // Not the literal. `aai init <project>` scaffolds this file verbatim, so a
18
+ // pinned name is a test about the TEMPLATE inside somebody else's project —
19
+ // and renaming the agent is the first thing they will do. What has to hold
20
+ // is that there is a name and the conversion carries it through.
21
+ expect(agentDef.name).toBeTruthy();
22
+ expect(toAgentConfig(agentDef).name).toBe(agentDef.name);
23
23
  });
24
24
 
25
- test("LLM descriptor is Anthropic", () => {
26
- expect(agentDef.llm?.kind).toBe("anthropic");
25
+ test("declares at least one stage itself, which is what makes it a pipeline", () => {
26
+ // WHICH stage is deliberately not asserted: `agent.ts` invites you to move
27
+ // the declaration ("Declare only the stage you want elsewhere"), so naming
28
+ // one here would redden on the edit the comment above it asks for.
29
+ const declared = (["stt", "llm", "tts"] as const).filter(
30
+ (stage) => agentDef[stage] !== undefined,
31
+ );
32
+ expect(declared.length).toBeGreaterThan(0);
27
33
  });
28
34
 
29
- test("unset stages fill to AssemblyAI in the deployable config", () => {
35
+ test("a stage you declare survives, and every stage you leave unset defaults", () => {
36
+ // The template's whole subject, stated so it survives a swap: the config a
37
+ // deploy carries agrees with the def wherever the def has an opinion, and
38
+ // fills the AssemblyAI default wherever it does not.
30
39
  const config = toAgentConfig(agentDef);
31
- expect(config.mode).toBe("pipeline");
32
- expect(config.stt?.kind).toBe("assemblyai");
33
- expect(config.tts?.kind).toBe("assemblyai");
34
- expect(config.llm?.kind).toBe("anthropic");
40
+ if (config.mode !== "pipeline") {
41
+ // Switched the def to `s2s`? Then there is no cascade to fill — S2S
42
+ // REPLACES the pipeline rather than joining it, which the last describe
43
+ // below is the worked example for.
44
+ expect(config.mode).toBe("s2s");
45
+ expect(config.stt).toBeUndefined();
46
+ expect(config.tts).toBeUndefined();
47
+ return;
48
+ }
49
+ for (const stage of ["stt", "llm", "tts"] as const) {
50
+ expect(config[stage]?.kind, stage).toBe(agentDef[stage]?.kind ?? "assemblyai");
51
+ }
35
52
  });
36
53
  });
37
54