@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
@@ -34,7 +34,12 @@ import type { SessionEvent } from "@alexkroman1/aai/protocol";
34
34
  // What no eval here can see: anything below the audio boundary. Whether a
35
35
  // dispatcher reading a callsign in bursts lands as one turn is a property of
36
36
  // endpointing, and these fake speech stages remove it.
37
- import { type EvalSession, type EvalTurn, lastStateIn } from "@alexkroman1/aai-runtime/eval";
37
+ import {
38
+ type EvalSession,
39
+ lastStateIn,
40
+ toolNames,
41
+ turnCalling,
42
+ } from "@alexkroman1/aai-runtime/eval";
38
43
  import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
39
44
  import { expect } from "vitest";
40
45
  import { z } from "zod";
@@ -100,20 +105,6 @@ const boardEntry = (events: readonly SessionEvent[], id: string) =>
100
105
  const refusalAt = (state: string) =>
101
106
  new RegExp(`Not available yet: this conversation is at [\\\\"]*${state}`);
102
107
 
103
- /**
104
- * Drive a whole shift, one dispatcher line at a time, and hand back every turn.
105
- *
106
- * The cases below assert about the turn a MECHANISM fired in rather than about
107
- * turn number two: how many turns a desk takes to get there is the model's
108
- * business and it varies, and a case pinned to a turn index is a flake with a
109
- * misleading name.
110
- */
111
- async function sayAll(session: EvalSession, lines: readonly string[]): Promise<EvalTurn[]> {
112
- const turns: EvalTurn[] = [];
113
- for (const line of lines) turns.push(await session.say(line));
114
- return turns;
115
- }
116
-
117
108
  /** Every call to `tool` across the whole shift. */
118
109
  const callsTo = (session: EvalSession, tool: string) =>
119
110
  session.toolCalls().filter((c) => c.name === tool);
@@ -200,12 +191,15 @@ describeEval(dispatchAgent, (test) => {
200
191
  test(
201
192
  "rolls units on a logged incident and follows them to monitoring",
202
193
  async ({ session }) => {
203
- const turns = await sayAll(session, [THE_CALL, "Dispatch the recommended units now."]);
194
+ const turns = await session.sayAll([THE_CALL, "Dispatch the recommended units now."]);
204
195
 
205
- const dispatching = turns.find((t) =>
206
- t.toolCalls.some((c) => c.name === "resources_dispatch"),
207
- );
208
- const rolled = dispatching?.toolCalls.find((c) => c.name === "resources_dispatch");
196
+ // The turn the dispatch fired in, whichever one that turned out to be:
197
+ // how many turns a desk spends getting there is the model's business, so
198
+ // a case pinned to turn two is a flake with a misleading name. A shift
199
+ // that never dispatched at all fails HERE, with every turn's tool list in
200
+ // the message, rather than as an `undefined` three assertions later.
201
+ const dispatching = turnCalling(turns, "resources_dispatch");
202
+ const rolled = dispatching.toolCalls.find((c) => c.name === "resources_dispatch");
209
203
  // Units really assigned — `dispatched` is empty when every requested
210
204
  // callsign was busy, which is the case the fourth test owns.
211
205
  expect(rolled?.result).toMatch(/"dispatched":\[\{/);
@@ -215,7 +209,7 @@ describeEval(dispatchAgent, (test) => {
215
209
  // The board agrees: the incident is dispatched, not merely triaged.
216
210
  expect(boardEntry(session.events(), FIRST_INCIDENT)?.status).toBe("dispatched");
217
211
  // And the order is the one the desk's flow requires: log, then dispatch.
218
- const names = session.toolCalls().map((c) => c.name);
212
+ const names = toolNames(session.toolCalls());
219
213
  expect(names.indexOf("resources_dispatch")).toBeGreaterThan(names.indexOf("incident_create"));
220
214
  },
221
215
  {
@@ -230,7 +224,7 @@ describeEval(dispatchAgent, (test) => {
230
224
  test(
231
225
  "closing an incident releases the units that were on it",
232
226
  async ({ session }) => {
233
- await sayAll(session, [
227
+ await session.sayAll([
234
228
  THE_CALL,
235
229
  "Dispatch the recommended units, emergency priority.",
236
230
  "Units report the patient is transported and they're clear. Close it out.",
@@ -252,7 +246,7 @@ describeEval(dispatchAgent, (test) => {
252
246
  // The board agrees, which is the half a browser would show.
253
247
  expect(boardEntry(session.events(), FIRST_INCIDENT)?.status).toBe("resolved");
254
248
  // And the shift ran in the order the flow requires.
255
- const names = session.toolCalls().map((c) => c.name);
249
+ const names = toolNames(session.toolCalls());
256
250
  expect(names.indexOf("incident_create")).toBeGreaterThanOrEqual(0);
257
251
  expect(names.indexOf("resources_dispatch")).toBeGreaterThan(names.indexOf("incident_create"));
258
252
  expect(names.lastIndexOf("incident_update_status")).toBeGreaterThan(
@@ -279,7 +273,7 @@ describeEval(dispatchAgent, (test) => {
279
273
  test(
280
274
  "a unit already rolling is not sent to a second call",
281
275
  async ({ session }) => {
282
- await sayAll(session, [
276
+ await session.sayAll([
283
277
  THE_CALL,
284
278
  "Send Medic-1 to Oak Street.",
285
279
  "New call: 12 Pine Lane, chest pains. Log it.",
@@ -25,7 +25,7 @@
25
25
  * tool-choice claim below then passes or fails for the wrong reason.
26
26
  */
27
27
  import agentDef from "virtual:aai/agent";
28
- import { toolResultIn } from "@alexkroman1/aai-runtime/eval";
28
+ import { toolNames, toolResultIn } from "@alexkroman1/aai-runtime/eval";
29
29
  import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
30
30
  import { expect } from "vitest";
31
31
  import { z } from "zod";
@@ -57,7 +57,7 @@ describeEval(agentDef, (test) => {
57
57
  // about voice frameworks in general.
58
58
  const turn = await session.say("Can your agents make HTTP requests?");
59
59
 
60
- expect(turn.toolCalls.map((c) => c.name)).toEqual(["search_knowledge"]);
60
+ expect(toolNames(turn.toolCalls)).toEqual(["search_knowledge"]);
61
61
  const call = turn.toolCalls[0]!;
62
62
  expect(typeof (call.args as { query?: unknown }).query).toBe("string");
63
63
  // The right entry, out of four: the one this question is really about.
@@ -81,7 +81,7 @@ describeEval(agentDef, (test) => {
81
81
  async ({ session }) => {
82
82
  const turn = await session.say("What topics can you help me with?");
83
83
 
84
- expect(turn.toolCalls.map((c) => c.name)).toEqual(["list_topics"]);
84
+ expect(toolNames(turn.toolCalls)).toEqual(["list_topics"]);
85
85
  // Every question in `knowledge.json` and nothing else — the check that
86
86
  // catches an index built from a stale copy of the asset.
87
87
  expect(toolResultIn(turn.toolCalls, "list_topics", z.array(z.string()))).toEqual(
@@ -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
 
@@ -34,14 +34,36 @@
34
34
  * tool-choice claim below then passes or fails for the wrong reason.
35
35
  */
36
36
  import agentDef from "virtual:aai/agent";
37
- import { toolResultIn } from "@alexkroman1/aai-runtime/eval";
37
+ import {
38
+ describeTurn,
39
+ type EvalToolCall,
40
+ toolArgsIn,
41
+ toolResultIn,
42
+ } from "@alexkroman1/aai-runtime/eval";
38
43
  import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
39
44
  import { expect } from "vitest";
40
45
  import { z } from "zod";
41
46
 
42
- /** The drugs an interaction check was actually asked about, lowercased. */
43
- const drugsIn = (args: Record<string, unknown>): string[] =>
44
- ((args.drugs as string[] | undefined) ?? []).map((d) => d.trim().toLowerCase());
47
+ /**
48
+ * Every drug this scope's interaction checks were actually asked about,
49
+ * lowercased.
50
+ *
51
+ * Read through `toolArgsIn` WITH a schema, which is what that reader takes one
52
+ * for: `args` is `Record<string, unknown>` on the wire — the model wrote it and
53
+ * nothing validated it — so the `args.drugs as string[] ?? []` this replaced
54
+ * turned a `drugs` the desk renamed, or never sent, into an empty list, and the
55
+ * two claims below would have been claims about nothing. A `drugs` that stops
56
+ * arriving FAILS here, naming the field.
57
+ *
58
+ * ZERO checks answers `[]` rather than throwing, which is what keeps the
59
+ * dangerous case assertable: a desk that answered an interaction question
60
+ * without asking a single label reaches the assertions with nothing in hand.
61
+ */
62
+ const InteractionArgs = z.object({ drugs: z.array(z.string()) });
63
+ const drugsAsked = (calls: readonly EvalToolCall[]): string[] =>
64
+ toolArgsIn(calls, "check_drug_interaction", InteractionArgs).flatMap((args) =>
65
+ args.drugs.map((drug) => drug.trim().toLowerCase()),
66
+ );
45
67
 
46
68
  /**
47
69
  * A refusal from `check_drug_interaction`, as the model saw it.
@@ -60,12 +82,15 @@ describeEval(agentDef, (test) => {
60
82
  const turn = await session.say("Can I take ibuprofen and warfarin together?");
61
83
 
62
84
  // It may look each drug up as well — that is fine and often useful. What
63
- // it may not do is answer this question without asking the labels.
64
- const checks = turn.toolCalls.filter((c) => c.name === "check_drug_interaction");
65
- expect(checks).not.toEqual([]);
66
- const asked = checks.flatMap((c) => drugsIn(c.args));
67
- expect(asked).toContain("ibuprofen");
68
- expect(asked).toContain("warfarin");
85
+ // it may not do is answer this question without asking the labels — and a
86
+ // desk that asked nothing arrives here with an empty list, so these two
87
+ // lines carry the never-checked finding as well as the wrong-drugs one.
88
+ // `describeTurn` is what tells them apart in the failure: "expected [] to
89
+ // contain 'ibuprofen'" does not say whether the desk called something
90
+ // else, called nothing, or was cut off mid-reply.
91
+ const asked = drugsAsked(turn.toolCalls);
92
+ expect(asked, describeTurn(turn)).toContain("ibuprofen");
93
+ expect(asked, describeTurn(turn)).toContain("warfarin");
69
94
 
70
95
  // The rule at the top of its prompt: it is not a doctor. An interaction
71
96
  // answer that does not end at a professional is the failure that makes
@@ -90,11 +115,9 @@ describeEval(agentDef, (test) => {
90
115
  // drug alone and report nothing.
91
116
  const turn = await session.say("Is it okay if I add ibuprofen for a headache?");
92
117
 
93
- const checks = turn.toolCalls.filter((c) => c.name === "check_drug_interaction");
94
- expect(checks).not.toEqual([]);
95
- const asked = checks.flatMap((c) => drugsIn(c.args));
96
- expect(asked).toContain("warfarin");
97
- expect(asked).toContain("ibuprofen");
118
+ const asked = drugsAsked(turn.toolCalls);
119
+ expect(asked, describeTurn(turn)).toContain("warfarin");
120
+ expect(asked, describeTurn(turn)).toContain("ibuprofen");
98
121
  },
99
122
  {
100
123
  stubReply: [
@@ -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
 
@@ -26,15 +26,13 @@
26
26
 
27
27
  import { createServer, type Server } from "node:http";
28
28
  import type { AddressInfo } from "node:net";
29
+ import { stubGatewayRoute } from "@alexkroman1/aai/testing";
29
30
  import { installStubStepFetch } from "@alexkroman1/aai/testing/vitest";
30
31
  import { describeWorkflowEval } from "@alexkroman1/aai-runtime/eval/vitest";
31
32
  import { expect, onTestFinished } from "vitest";
32
33
  import agentDef, { digest } from "./agent.ts";
33
34
  import { SETTLE_MS } from "./workflows/digest.ts";
34
35
 
35
- /** The gateway leg, so one handler can route the page and the model apart. */
36
- const isModelCall = (url: string): boolean => url.includes("/chat/completions");
37
-
38
36
  /**
39
37
  * A page with an ANSWER in it, so "did it summarize what it fetched" is a
40
38
  * question with a right answer rather than a vibe.
@@ -107,15 +105,25 @@ async function servePage(html: string): Promise<string> {
107
105
  *
108
106
  * ONE handler, because publishing a `stepFetch` REPLACES — a flow that fetches a
109
107
  * page and calls a model cannot install two fakes, so it routes by URL. The
110
- * recorded calls are what makes the prompt assertable, which is the only way to
111
- * check what the model was SHOWN rather than what it said.
108
+ * routing is `stubGatewayRoute`'s rather than this file's: it answers the
109
+ * completion request and `undefined` for everything else, which is what makes
110
+ * the page the `??` arm — and it routes off the SDK's own completions PATH, so a
111
+ * case cannot pass because the fake and the step agree on a typo. That matters
112
+ * more here than it looks: the envelope is a WIRE shape, so a field typed one
113
+ * off does not fail — `stepGenerate` reads no content and reports an empty
114
+ * completion, and the case blames the digest.
115
+ *
116
+ * The recorded calls are what makes the prompt assertable, which is the only way
117
+ * to check what the model was SHOWN rather than what it said — and they come
118
+ * back DECODED, so the last case reads `prompt` rather than the raw request
119
+ * body, which is the whole serialized request.
112
120
  */
113
121
  function scriptBothLegs(html: string, reply = SCRIPTED_DIGEST) {
114
- return installStubStepFetch((request) =>
115
- isModelCall(request.url)
116
- ? { body: { choices: [{ message: { content: reply } }] } }
117
- : { body: html, headers: { "Content-Type": "text/html" } },
122
+ const model = stubGatewayRoute(reply);
123
+ installStubStepFetch(
124
+ (request) => model.route(request) ?? { body: html, headers: { "Content-Type": "text/html" } },
118
125
  );
126
+ return model;
119
127
  }
120
128
 
121
129
  describeWorkflowEval(agentDef, (test) => {
@@ -180,18 +188,19 @@ describeWorkflowEval(agentDef, (test) => {
180
188
  "</body>",
181
189
  `<p>${"padding sentence about otters. ".repeat(2000)}</p></body>`,
182
190
  );
183
- const legs = scriptBothLegs(oversized);
191
+ const model = scriptBothLegs(oversized);
184
192
 
185
193
  const run = await app.run(digest, { url: "https://example.test/otters" });
186
194
  expect(run.status).toBe("completed");
187
195
 
188
- const prompt = String(legs.calls.find((call) => isModelCall(call.url))?.body ?? "");
189
- expect(prompt).toContain("Sea otters are one of the few mammals that use tools");
196
+ const asked = model.calls[0];
197
+ if (asked === undefined) expect.fail("the run must have shown the article to the model");
198
+ expect(asked.prompt).toContain("Sea otters are one of the few mammals that use tools");
190
199
  // Neither the script's instruction nor the stylesheet reached the prompt.
191
- expect(prompt).not.toContain(SMUGGLED);
192
- expect(prompt).not.toContain("rebeccapurple");
200
+ expect(asked.prompt).not.toContain(SMUGGLED);
201
+ expect(asked.prompt).not.toContain("rebeccapurple");
193
202
  // And the text was CAPPED on the way across the queue. 24k characters plus
194
203
  // the prompt's own framing, well under the ~60k this page would otherwise be.
195
- expect(prompt.length).toBeLessThan(30_000);
204
+ expect(asked.prompt.length).toBeLessThan(30_000);
196
205
  });
197
206
  });
@@ -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
@@ -14,27 +14,38 @@
14
14
  // back with as well as the code the tutor wrote.
15
15
 
16
16
  import agentDef from "virtual:aai/agent";
17
- import { createVmRunCode, toolResultsIn } from "@alexkroman1/aai-runtime/eval";
17
+ import {
18
+ createVmRunCode,
19
+ type EvalTurn,
20
+ toolArgsIn,
21
+ toolNames,
22
+ toolResultsIn,
23
+ } from "@alexkroman1/aai-runtime/eval";
18
24
  import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
19
25
  import { expect } from "vitest";
26
+ import { z } from "zod";
20
27
 
21
- /** The code every `run_code` call in this turn carried, joined. */
22
- const codeIn = (turn: { toolCalls: readonly { name: string; args: Record<string, unknown> }[] }) =>
23
- turn.toolCalls
24
- .filter((c) => c.name === "run_code")
25
- .map((c) => String(c.args.code ?? ""))
28
+ /**
29
+ * The code every `run_code` call in this turn carried, joined.
30
+ *
31
+ * Read through `toolArgsIn` WITH a schema, which is what that reader takes one
32
+ * for: `args` is `Record<string, unknown>` on the wire — the tutor wrote it and
33
+ * nothing validated it — so the `String(c.args.code ?? "")` this replaced turned
34
+ * an argument the model renamed, or never sent, into `""`. The claims below are
35
+ * about the recipe the tutor wrote, and against `""` every one of them would
36
+ * have been a claim about nothing. A `code` that stops arriving FAILS here,
37
+ * naming the field.
38
+ */
39
+ const RunCodeArgs = z.object({ code: z.string() });
40
+ const codeIn = (turn: EvalTurn) =>
41
+ toolArgsIn(turn.toolCalls, "run_code", RunCodeArgs)
42
+ .map((args) => args.code)
26
43
  .join("\n");
27
44
 
28
45
  /**
29
- * A `run_code` executor, so these cases can assert the ANSWER.
30
- *
31
- * The builtin refuses without one — the Modal container is the security
32
- * boundary, and off-platform there is none — so a case could assert the CALL and
33
- * the code it carried, and never what the code came back with.
34
- * `createVmRunCode()` is a `node:vm` context with a capturing `console.log`,
35
- * which is enough here: what runs is arithmetic, not a program. It is NOT a
36
- * sandbox and does not pretend to be one; a deployed agent still gets the
37
- * refusal.
46
+ * A `run_code` executor, so these cases can assert the answer the tutor's code
47
+ * came back with and not merely the code — `createVmRunCode`'s own doc carries
48
+ * why the builtin refuses without one.
38
49
  */
39
50
  const runCode = createVmRunCode();
40
51
 
@@ -49,7 +60,7 @@ describeEval(
49
60
  // The prompt hands the tutor the factors; the finding it guards against
50
61
  // is a tutor that recites a remembered figure instead. A factor in the
51
62
  // code is the evidence that the conversion was computed, not recalled.
52
- expect(turn.toolCalls.map((c) => c.name)).toContain("run_code");
63
+ expect(toolNames(turn.toolCalls)).toContain("run_code");
53
64
  const code = codeIn(turn);
54
65
  expect(code).toContain("5");
55
66
  expect(code).toMatch(/1\.60|1\.61|0\.621|8\.04/);
@@ -69,7 +80,7 @@ describeEval(
69
80
  // A model asked for dice will happily make three numbers up, and the
70
81
  // reply is indistinguishable from a real roll. `Math.random` in the code
71
82
  // is the only thing that tells them apart.
72
- expect(turn.toolCalls.map((c) => c.name)).toContain("run_code");
83
+ expect(toolNames(turn.toolCalls)).toContain("run_code");
73
84
  const code = codeIn(turn);
74
85
  expect(code).toMatch(/Math\.random/);
75
86
  expect(code).toContain("20");
@@ -29,7 +29,10 @@ import type { SessionEvent } from "@alexkroman1/aai/protocol";
29
29
  import {
30
30
  createVmRunCode,
31
31
  customEventsIn,
32
+ describeTurn,
32
33
  lastStateIn,
34
+ toolArgsIn,
35
+ toolNames,
33
36
  toolResultIn,
34
37
  } from "@alexkroman1/aai-runtime/eval";
35
38
  import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
@@ -66,17 +69,23 @@ const pushedRecs = (events: readonly SessionEvent[]) =>
66
69
  const nudges = (events: readonly SessionEvent[]) => customEventsIn(events, "wind_down");
67
70
 
68
71
  /**
69
- * A `run_code` executor, so the sleep-cycle case can assert the ANSWER.
70
- *
71
- * The builtin refuses without one the Modal container is the security
72
- * boundary, and off-platform there is none — which left this template's
73
- * headline feature assertable as a CALL and never as a number. A `node:vm`
74
- * context with a capturing `console.log` is what a developer would reach for on
75
- * their own machine, and `createVmRunCode()` is exactly that: the code under
76
- * test is arithmetic the model wrote, not a program.
72
+ * A `run_code` executor, so the sleep-cycle case can assert the bedtime NUMBER
73
+ * and not merely the call — this template's headline feature is the arithmetic.
74
+ * `createVmRunCode`'s own doc carries why the builtin refuses without one.
77
75
  */
78
76
  const runCode = createVmRunCode();
79
77
 
78
+ /**
79
+ * The `code` argument a `run_code` call carries.
80
+ *
81
+ * The schema is what `toolArgsIn` takes one for: `args` is
82
+ * `Record<string, unknown>` on the wire — the model wrote it and nothing
83
+ * validated it — so the `String(c.args.code ?? "")` this replaced turned an
84
+ * argument the companion renamed, or never sent, into `""`, and the two
85
+ * constants asserted below would have been looked for in nothing at all.
86
+ */
87
+ const RunCodeArgs = z.object({ code: z.string() });
88
+
80
89
  /** Two-digit, for the clock arithmetic below. */
81
90
  const pad = (n: number): string => String(n).padStart(2, "0");
82
91
 
@@ -113,7 +122,7 @@ describeEval(
113
122
 
114
123
  // "to watch" is the category and "cozy" is the mood; the shelf is the
115
124
  // tool's, so answering from the model's own taste is the regression.
116
- expect(turn.toolCalls.map((c) => c.name)).toEqual(["recommend"]);
125
+ expect(toolNames(turn.toolCalls)).toEqual(["recommend"]);
117
126
  const call = turn.toolCalls[0]!;
118
127
  expect(call.args).toEqual({ category: "movie", mood: "cozy" });
119
128
 
@@ -139,7 +148,7 @@ describeEval(
139
148
  await session.say("I want something cozy to watch tonight.");
140
149
  const turn = await session.say("Now give me something spooky to read.");
141
150
 
142
- expect(turn.toolCalls.map((c) => c.name)).toEqual(["recommend"]);
151
+ expect(toolNames(turn.toolCalls)).toEqual(["recommend"]);
143
152
  expect(turn.toolCalls[0]!.args).toEqual({ category: "book", mood: "spooky" });
144
153
 
145
154
  // The slot survived the turn boundary: the frame the page renders after
@@ -197,15 +206,21 @@ describeEval(
197
206
  "I need to be up at 7 in the morning. When should I fall asleep?",
198
207
  );
199
208
 
209
+ // The CALLS, not their arguments: what this asserts is that the companion
210
+ // reached for code at all, and the results are read off the same list
211
+ // below. `toolArgsIn` answers the other half, the code it submitted.
200
212
  const ran = turn.toolCalls.filter((c) => c.name === "run_code");
201
- expect(
202
- ran,
203
- `tools called: [${turn.toolCalls.map((c) => c.name).join(", ")}]; said: ${turn.text}`,
204
- ).not.toEqual([]);
213
+ // `describeTurn` is the message: "expected [] not to equal []" says
214
+ // nothing about a companion that talked its way through the sum
215
+ // instead, and it names a cancelled reply, which is the usual reason a
216
+ // turn reached for nothing at all.
217
+ expect(ran, describeTurn(turn)).not.toEqual([]);
205
218
  // The recipe is the prompt's, and it is two constants: a 90-minute cycle
206
219
  // plus the 15 minutes it takes to fall asleep. Arithmetic done in the
207
220
  // model's head has neither of them anywhere in the code.
208
- const code = ran.map((c) => String(c.args.code ?? "")).join("\n");
221
+ const code = toolArgsIn(turn.toolCalls, "run_code", RunCodeArgs)
222
+ .map((args) => args.code)
223
+ .join("\n");
209
224
  expect(code).toContain("90");
210
225
  expect(code).toContain("15");
211
226
 
@@ -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", () => {
@@ -16,33 +16,43 @@
16
16
  // live rates API.
17
17
 
18
18
  import agentDef from "virtual:aai/agent";
19
- import { createVmRunCode, toolResultsIn } from "@alexkroman1/aai-runtime/eval";
19
+ import {
20
+ createVmRunCode,
21
+ type EvalTurn,
22
+ toolArgsIn,
23
+ toolResultsIn,
24
+ } from "@alexkroman1/aai-runtime/eval";
20
25
  import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
21
26
  import { expect } from "vitest";
27
+ import { z } from "zod";
22
28
 
23
- type Turn = { toolCalls: readonly { name: string; args: Record<string, unknown> }[] };
29
+ /**
30
+ * The arguments Penny's two builtins carry, as the wire has them.
31
+ *
32
+ * Schemas rather than `String(args.code ?? "")`, which is what `toolArgsIn`
33
+ * takes one for: `args` is `Record<string, unknown>` — the model wrote it and
34
+ * nothing validated it — so an argument Penny renamed, or never sent, used to
35
+ * read as `""`, and the claims below about the code she submitted and the URL
36
+ * she asked for would have been claims about an empty string. An argument that
37
+ * stops arriving FAILS here, naming the field.
38
+ */
39
+ const RunCodeArgs = z.object({ code: z.string() });
40
+ const FetchJsonArgs = z.object({ url: z.string() });
24
41
 
25
42
  /** The code every `run_code` call in this turn carried, joined. */
26
- const codeIn = (turn: Turn) =>
27
- turn.toolCalls
28
- .filter((c) => c.name === "run_code")
29
- .map((c) => String(c.args.code ?? ""))
43
+ const codeIn = (turn: EvalTurn) =>
44
+ toolArgsIn(turn.toolCalls, "run_code", RunCodeArgs)
45
+ .map((args) => args.code)
30
46
  .join("\n");
31
47
 
32
48
  /** Every URL this turn's `fetch_json` calls asked for. */
33
- const fetchedUrls = (turn: Turn) =>
34
- turn.toolCalls.filter((c) => c.name === "fetch_json").map((c) => String(c.args.url ?? ""));
49
+ const fetchedUrls = (turn: EvalTurn) =>
50
+ toolArgsIn(turn.toolCalls, "fetch_json", FetchJsonArgs).map((args) => args.url);
35
51
 
36
52
  /**
37
- * A `run_code` executor, so these cases can assert the ANSWER.
38
- *
39
- * The builtin refuses without one the Modal container is the security
40
- * boundary, and off-platform there is none — so a case could assert the CALL and
41
- * the code it carried, and never what the code came back with.
42
- * `createVmRunCode()` is a `node:vm` context with a capturing `console.log`,
43
- * which is enough here: what runs is arithmetic, not a program. It is NOT a
44
- * sandbox and does not pretend to be one; a deployed agent still gets the
45
- * refusal.
53
+ * A `run_code` executor, so the arithmetic cases can assert the ANSWER and not
54
+ * merely the call — `createVmRunCode`'s own doc carries why the builtin refuses
55
+ * without one. `fetch_json` needs nothing of the sort: it makes a real request.
46
56
  */
47
57
  const runCode = createVmRunCode();
48
58