@alexkroman1/aai-cli 13.1.0 → 13.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/bin.mjs +1 -1
  2. package/dist/{_agent-BTwZJu4P.mjs → _agent-Clbq3a4h.mjs} +2 -2
  3. package/dist/{_bundler-C31rlcYz.mjs → _bundler-DEkjO1d8.mjs} +3 -3
  4. package/dist/{_config-CmJOFsAP.mjs → _config-ZY2euhdh.mjs} +7 -4
  5. package/dist/_config.d.ts +25 -1
  6. package/dist/{_dev-server-DWvkNoIS.mjs → _dev-server-C13xuGB1.mjs} +13 -13
  7. package/dist/_e2e-test-utils.d.ts +15 -7
  8. package/dist/{_init-BURbdHNM.mjs → _init-Ci4QGjp8.mjs} +58 -10
  9. package/dist/{_output-CKkmWs7i.mjs → _output-DbJt9gsl.mjs} +34 -3
  10. package/dist/_output.d.ts +17 -1
  11. package/dist/{_preflight-Co1G9Jww.mjs → _preflight-B2laWPdU.mjs} +1 -1
  12. package/dist/{_server-common-6e0QI4mq.mjs → _server-common-BV4_CrP3.mjs} +3 -3
  13. package/dist/{_slug-api-CfGQq1Tt.mjs → _slug-api-DlKpj91X.mjs} +3 -3
  14. package/dist/{_studio-B1waXMuR.mjs → _studio-DTi3EdXQ.mjs} +3 -3
  15. package/dist/{_templates-0Zr2z6yA.mjs → _templates-V4l5sZJy.mjs} +2 -2
  16. package/dist/{_typecheck-gate-BXvUNnfs.mjs → _typecheck-gate-sV4N5F93.mjs} +3 -3
  17. package/dist/{_ui-u7T4YooX.mjs → _ui-DCt4qZrk.mjs} +1 -1
  18. package/dist/{_utils-B8QmtFhK.mjs → _utils-Dhd058D9.mjs} +1 -1
  19. package/dist/{_vite-env-D9Qj6KPX.mjs → _vite-env-kejF0p2L.mjs} +1 -1
  20. package/dist/{_workflow-determinism-f9J-5Zlj.mjs → _workflow-determinism-C9l8uC2R.mjs} +1 -1
  21. package/dist/{build-BLlLOrng.mjs → build-CWyFGe-W.mjs} +11 -8
  22. package/dist/cli.d.ts +2 -1
  23. package/dist/cli.mjs +120 -46
  24. package/dist/{client-bundler-j5c46x0G.mjs → client-bundler-CT-_26c5.mjs} +4 -4
  25. package/dist/client-bundler.mjs +1 -1
  26. package/dist/{delete-X_CRgCf6.mjs → delete-RqlQZ6Y_.mjs} +6 -6
  27. package/dist/{deploy-BzZ-r4iM.mjs → deploy-B7f3CK7v.mjs} +13 -13
  28. package/dist/{dev-BDykFvcc.mjs → dev-DamXieW_.mjs} +5 -5
  29. package/dist/{eval-0qEOdm0l.mjs → eval-h3DTVfRw.mjs} +27 -11
  30. package/dist/eval.d.ts +2 -0
  31. package/dist/{init-BtJI306C.mjs → init-D-vFrV8h.mjs} +7 -7
  32. package/dist/{login-DeUETobb.mjs → login-CDiemNmC.mjs} +5 -5
  33. package/dist/{logs-hWnJ66Bl.mjs → logs-DGwPNCzB.mjs} +4 -4
  34. package/dist/project-config.mjs +1 -1
  35. package/dist/scaffold/.env.example +17 -1
  36. package/dist/scaffold/package.json +6 -5
  37. package/dist/secret-V1w7y9-j.mjs +162 -0
  38. package/dist/secret.d.ts +41 -8
  39. package/dist/{studio-BxAS_FQQ.mjs → studio-BpMCfUNx.mjs} +9 -9
  40. package/dist/templates/briefing-desk/agent.test.ts +7 -3
  41. package/dist/templates/call-audit/agent.eval.test.ts +14 -11
  42. package/dist/templates/call-audit/agent.test.ts +34 -7
  43. package/dist/templates/call-audit/client.tsx +14 -17
  44. package/dist/templates/call-audit/workflows/ingest.ts +10 -1
  45. package/dist/templates/code-interpreter/agent.eval.test.ts +27 -17
  46. package/dist/templates/dispatch-center/agent.eval.test.ts +18 -24
  47. package/dist/templates/embedded-assets/agent.eval.test.ts +3 -3
  48. package/dist/templates/embedded-assets/agent.test.ts +7 -1
  49. package/dist/templates/health-assistant/agent.eval.test.ts +38 -15
  50. package/dist/templates/health-assistant/agent.test.ts +8 -4
  51. package/dist/templates/link-digest/agent.eval.test.ts +24 -15
  52. package/dist/templates/link-digest/agent.test.ts +7 -1
  53. package/dist/templates/link-digest/client.tsx +17 -21
  54. package/dist/templates/math-buddy/agent.eval.test.ts +28 -17
  55. package/dist/templates/night-owl/agent.eval.test.ts +30 -15
  56. package/dist/templates/night-owl/agent.test.ts +6 -1
  57. package/dist/templates/personal-finance/agent.eval.test.ts +27 -17
  58. package/dist/templates/pipeline-simple/agent.test.ts +31 -14
  59. package/dist/templates/pizza-ordering/agent.eval.test.ts +11 -6
  60. package/dist/templates/plan-and-execute/agent.eval.test.ts +14 -7
  61. package/dist/templates/podcast-digest/agent.test.ts +13 -3
  62. package/dist/templates/podcast-digest/client.tsx +12 -15
  63. package/dist/templates/recap-workflow/agent.eval.test.ts +49 -20
  64. package/dist/templates/recap-workflow/agent.test.ts +22 -9
  65. package/dist/templates/redline/agent.eval.test.ts +32 -24
  66. package/dist/templates/redline/agent.test.ts +7 -1
  67. package/dist/templates/redline/client.tsx +12 -15
  68. package/dist/templates/research-workflow/agent.eval.test.ts +32 -22
  69. package/dist/templates/research-workflow/agent.test.ts +7 -1
  70. package/dist/templates/retail/agent.eval.test.ts +18 -34
  71. package/dist/templates/retail/registry.test.ts +99 -58
  72. package/dist/templates/simple/agent.test.ts +39 -6
  73. package/dist/templates/spoken-summary/agent.eval.test.ts +25 -16
  74. package/dist/templates/spoken-summary/agent.test.ts +21 -7
  75. package/dist/templates/spoken-summary/client.tsx +14 -18
  76. package/dist/templates/support-line/agent.eval.test.ts +23 -26
  77. package/dist/templates/transcription-workflow/agent.test.ts +26 -8
  78. package/dist/templates/transcription-workflow/client.tsx +19 -26
  79. package/dist/templates/transcription-workflow/workflows/normalize.ts +10 -1
  80. package/dist/templates/transcription-workflow/workflows/sync-api.ts +5 -2
  81. package/dist/templates/transcription-workflow/workflows/transcribe.ts +11 -4
  82. package/dist/templates/travel-concierge/agent.eval.test.ts +37 -56
  83. package/dist/test-guenJUxi.mjs +255 -0
  84. package/dist/test.d.ts +97 -31
  85. package/dist/typecheck.mjs +2 -2
  86. package/dist/worker-bundler.mjs +3 -3
  87. package/dist/{workflow-BBu5oEDw.mjs → workflow-BZyKLkhV.mjs} +5 -5
  88. package/package.json +10 -10
  89. package/dist/bin.d.mts +0 -2
  90. package/dist/secret-B-3T5tvr.mjs +0 -61
  91. package/dist/test-CiLab-AA.mjs +0 -182
  92. package/dist/tsdown.config.d.ts +0 -2
@@ -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.1.0",
18
- "@alexkroman1/aai-runtime": "^13.1.0",
19
- "@alexkroman1/aai-ui": "^13.1.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.1.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", () => {
@@ -52,6 +52,7 @@
52
52
  // the tier that really resumes a run.
53
53
  import { spawnSync } from "node:child_process";
54
54
  import { encodeWav } from "@alexkroman1/aai/step";
55
+ import { stubGatewayRoute } from "@alexkroman1/aai/testing";
55
56
  import { installStubTranscribe, installStubUploads } from "@alexkroman1/aai/testing/vitest";
56
57
  import { describeWorkflowEval } from "@alexkroman1/aai-runtime/eval/vitest";
57
58
  import { describe, expect, test } from "vitest";
@@ -211,19 +212,21 @@ function publish(bytes: Uint8Array, name: string, type: string) {
211
212
  * Answer the sync transcription endpoint and the model in memory.
212
213
  *
213
214
  * ONE fake, because publishing a `stepFetch` REPLACES — a flow that transcribes
214
- * AND calls a model cannot install two, which is what `otherwise` is for. The
215
- * transcription half is the SDK's own fake rather than this file's hand-typed
216
- * wire: it routes off the SDK's endpoint constants, so a case cannot pass
217
- * because the fake and the step agree on a typo.
215
+ * AND calls a model cannot install two, which is what `otherwise` is for. BOTH
216
+ * halves are the SDK's own fakes rather than this file's hand-typed wire, and
217
+ * for the same reason: each routes off the SDK's own endpoint constant, so a
218
+ * case cannot pass because the fake and the step agree on a typo. The gateway
219
+ * envelope is the half where that matters most — it is a WIRE shape, so getting
220
+ * a field wrong does not fail: `stepGenerate` reads no content, reports an empty
221
+ * completion, and the case blames the desk.
222
+ *
223
+ * `route` answers `undefined` for anything that is not a completion request, so
224
+ * it drops straight into `otherwise` and the transcription legs still reach the
225
+ * fake below it.
218
226
  */
219
227
  function scriptProvider(text: readonly string[]) {
220
- return installStubTranscribe({
221
- text,
222
- otherwise: (request) =>
223
- request.url.includes("/chat/completions")
224
- ? { body: { choices: [{ message: { content: JSON.stringify(REPLY) } }] } }
225
- : undefined,
226
- });
228
+ const model = stubGatewayRoute(JSON.stringify(REPLY));
229
+ return installStubTranscribe({ text, otherwise: (request) => model.route(request) });
227
230
  }
228
231
 
229
232
  describeWorkflowEvalWithFfmpeg(
@@ -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", () => {
@@ -928,9 +939,21 @@ describe("the body's step policy", () => {
928
939
  *
929
940
  * The block above drives the body through `createWorkflowCtx` with
930
941
  * `runSteps: false` and a journaled result per step, which is what makes it
931
- * affordable: `ingestRecording` runs ffmpeg, and this repo's test environment
932
- * has none — the `ingestRecording` and `narrate` specs above assert the
933
- * `FatalError` that produces, unconditionally.
942
+ * affordable: `ingestRecording` shells out to the ffmpeg toolchain, which this
943
+ * tier cannot usefully feed — the `ingestRecording` and `narrate` specs above
944
+ * assert the `FatalError` that produces, unconditionally.
945
+ *
946
+ * **The run fails here whether or not ffmpeg is installed, and the two failures
947
+ * do not say the same thing** — which is what the assertion below has to be
948
+ * written against. With no binary on `PATH` the step reports the missing
949
+ * toolchain as an instruction (the template's own doc promises that rather than
950
+ * `spawn ffmpeg ENOENT`); WITH one, the step gets a step further and `ffprobe`
951
+ * rejects the 2 KB stub above with `ffprobe exited with code 1`. This docblock
952
+ * used to state "this repo's test environment has none" as a premise, and the
953
+ * spec below matched `/ffmpeg/i` on the strength of it — so the test passed only
954
+ * on a machine where ffmpeg was ABSENT, and a developer with Homebrew's ffmpeg
955
+ * saw a red suite on a clean checkout of `main`. CI has no ffmpeg, so nothing
956
+ * caught it.
934
957
  *
935
958
  * So a whole run of this desk is not reachable here and this file does not
936
959
  * pretend otherwise; `aai-cli`'s `dev-workflow.scenario.test.ts` is the tier
@@ -953,7 +976,11 @@ describe("the run is DURABLE, as far as ffmpeg allows", () => {
953
976
  const run = await runWorkflow(audit, { recording: UPLOAD_ID }, { name: "audit" });
954
977
 
955
978
  expect(run.status).toBe("failed");
956
- expect(run.error).toMatch(/ffmpeg/i);
979
+ // Either member of the toolchain, for the reason this block's doc gives: the
980
+ // step names `ffmpeg` when the binary is missing and `ffprobe` when it is
981
+ // present, and this spec is about neither. What it is about is the two
982
+ // assertions below — the ENGINE honouring `FatalError`.
983
+ expect(run.error).toMatch(/ff(mpeg|probe)/i);
957
984
  const ingest = run.steps.find((step) => step.name === "ingestRecording");
958
985
  // The whole point of `FatalError`: `maxAttempts: 6` is the budget this call
959
986
  // site asks for, and a failure that cannot change must not spend it. A step
@@ -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
@@ -143,7 +143,16 @@ export async function ingestRecording(uploadId: string): Promise<Ingested> {
143
143
  const normalized = join(dir, "audio.pcm");
144
144
  const silenceLog = join(dir, "silence.txt");
145
145
 
146
- await readUploadToFile(uploadId, source, { size: stored.size });
146
+ // NO `size`, though `stored.size` is right there — and that is the whole
147
+ // difference between this copy being one window at a time and being
148
+ // `STEP_FILE_READ_CONCURRENCY` of them. Passing `size` means "I am judging
149
+ // completeness myself", which is what a body polling a still-arriving
150
+ // upload needs and is the opposite of what happened above: this step has
151
+ // already called `requireCompleteUpload`, so the file IS whole and the
152
+ // windows may land in any order. Omitting it lets `readUploadToFile`
153
+ // establish that for itself and fan out. The cost is one metadata round
154
+ // trip, against the dozens of window reads it overlaps.
155
+ await readUploadToFile(uploadId, source);
147
156
 
148
157
  // What it WAS, for the progress log and the page. Worth one ffprobe: "41
149
158
  // minutes of aac" explains the shape of the run, where "the recording" leaves
@@ -24,27 +24,37 @@
24
24
  // Coda reached for code, and what the code came back with.
25
25
 
26
26
  import agentDef from "virtual:aai/agent";
27
- import { createVmRunCode, toolResultsIn } from "@alexkroman1/aai-runtime/eval";
27
+ import {
28
+ createVmRunCode,
29
+ type EvalTurn,
30
+ toolArgsIn,
31
+ toolNames,
32
+ toolResultsIn,
33
+ } from "@alexkroman1/aai-runtime/eval";
28
34
  import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
29
35
  import { expect } from "vitest";
36
+ import { z } from "zod";
30
37
 
31
- /** The code every `run_code` call in this turn carried, joined. */
32
- const codeIn = (turn: { toolCalls: readonly { name: string; args: Record<string, unknown> }[] }) =>
33
- turn.toolCalls
34
- .filter((c) => c.name === "run_code")
35
- .map((c) => String(c.args.code ?? ""))
38
+ /**
39
+ * The code every `run_code` call in this turn carried, joined.
40
+ *
41
+ * Read through `toolArgsIn` WITH a schema, which is what that reader takes one
42
+ * for: `args` is `Record<string, unknown>` on the wire — the model wrote it and
43
+ * nothing validated it — so the `String(c.args.code ?? "")` this replaced turned
44
+ * an argument Coda renamed, or never sent, into `""`, and every claim below about
45
+ * the code she wrote would have been a claim about an empty string. A `code`
46
+ * that stops arriving FAILS here, naming the field.
47
+ */
48
+ const RunCodeArgs = z.object({ code: z.string() });
49
+ const codeIn = (turn: EvalTurn) =>
50
+ toolArgsIn(turn.toolCalls, "run_code", RunCodeArgs)
51
+ .map((args) => args.code)
36
52
  .join("\n");
37
53
 
38
54
  /**
39
- * A `run_code` executor, so these cases can assert the ANSWER.
40
- *
41
- * The builtin refuses without one the Modal container is the security
42
- * boundary, and off-platform there is none — so a case could assert the CALL and
43
- * the code it carried, and never what the code came back with.
44
- * `createVmRunCode()` is a `node:vm` context with a capturing `console.log`,
45
- * which is enough here: what runs is arithmetic, not a program. It is NOT a
46
- * sandbox and does not pretend to be one; a deployed agent still gets the
47
- * refusal.
55
+ * A `run_code` executor, so these cases can assert the ANSWER and not merely the
56
+ * call — `createVmRunCode`'s own doc carries why the builtin refuses without one
57
+ * and why a `node:vm` context is the right thing to hand it here.
48
58
  */
49
59
  const runCode = createVmRunCode();
50
60
 
@@ -59,7 +69,7 @@ describeEval(
59
69
  // The template's CRITICAL RULE, and the whole reason it declares
60
70
  // run_code: a model that answers this one directly has regressed, and it
61
71
  // is the easiest question in the file to answer wrongly with confidence.
62
- expect(turn.toolCalls.map((c) => c.name)).toContain("run_code");
72
+ expect(toolNames(turn.toolCalls)).toContain("run_code");
63
73
  const code = codeIn(turn);
64
74
  expect(code).toContain("127");
65
75
  expect(code).toContain("849");
@@ -90,7 +100,7 @@ describeEval(
90
100
  // The prompt lists this exact question under "you MUST use code for".
91
101
  // It is the case a narrower reading of the rule ("code is for maths")
92
102
  // silently drops.
93
- expect(turn.toolCalls.map((c) => c.name)).toContain("run_code");
103
+ expect(toolNames(turn.toolCalls)).toContain("run_code");
94
104
  expect(codeIn(turn)).toMatch(/Date|2000/);
95
105
  // The code RAN rather than being refused — but the ANSWER is
96
106
  // deliberately not asserted here, and the reason is worth knowing before