@alexkroman1/aai-cli 6.10.1 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/dist/{_agent-DpH2pBJd.mjs → _agent-BTwZJu4P.mjs} +2 -2
  2. package/dist/{_bundler-C2BDwU7r.mjs → _bundler-B4RqNF5Z.mjs} +2 -2
  3. package/dist/_dev-env.d.ts +22 -2
  4. package/dist/{_dev-server-LLLWnFBs.mjs → _dev-server-CiKFH9qw.mjs} +38 -47
  5. package/dist/_dev-server.d.ts +0 -17
  6. package/dist/{_init-C57bWAQ8.mjs → _init-CE4HKdgV.mjs} +3 -2
  7. package/dist/{_preflight-BtfaYtbE.mjs → _preflight-Co1G9Jww.mjs} +1 -1
  8. package/dist/_preflight.d.ts +1 -1
  9. package/dist/{_slug-api-BxrsJXVI.mjs → _slug-api-nRFaBEPJ.mjs} +2 -2
  10. package/dist/{_studio-U3jhKlA3.mjs → _studio-DR5yzZ1T.mjs} +2 -1
  11. package/dist/{_templates-Dxb_P2Wz.mjs → _templates-FI3xPEMj.mjs} +1 -1
  12. package/dist/_workflow-scan.d.ts +108 -0
  13. package/dist/{build-Dfza2pRI.mjs → build-6jfaakgc.mjs} +5 -2
  14. package/dist/cli.mjs +30 -25
  15. package/dist/{client-bundler-BEIqgOtd.mjs → client-bundler-CWnG42cU.mjs} +2 -1
  16. package/dist/client-bundler.mjs +1 -1
  17. package/dist/{delete-vbpjGzqs.mjs → delete-Dl_LSfvI.mjs} +3 -3
  18. package/dist/{deploy-B8lubiRT.mjs → deploy-DvYeD8ia.mjs} +5 -5
  19. package/dist/{dev-Uq5ujP8a.mjs → dev-B59JIfWO.mjs} +1 -1
  20. package/dist/{eject-31gjtaHF.mjs → eject-C9WJyyr2.mjs} +1 -1
  21. package/dist/{init-l6xfU4xX.mjs → init-CpJVjEg0.mjs} +3 -3
  22. package/dist/{login-C71-qz8F.mjs → login-BBuM1sxH.mjs} +3 -4
  23. package/dist/{logs-BWVGSjLR.mjs → logs-9q-psa4q.mjs} +2 -2
  24. package/dist/project-config.mjs +1 -1
  25. package/dist/scaffold/CLAUDE.md +93 -14
  26. package/dist/scaffold/package.json +5 -3
  27. package/dist/scaffold/server.mjs +13 -4
  28. package/dist/scaffold/vite.config.ts +1 -1
  29. package/dist/{secret-BuMuFR4B.mjs → secret-CVvSLIDV.mjs} +2 -2
  30. package/dist/{storage-DzRZ-eCw.mjs → storage-BvUrnvM3.mjs} +2 -2
  31. package/dist/{studio-BCNUpDgP.mjs → studio--MUV0cid.mjs} +4 -4
  32. package/dist/templates/call-audit/agent.test.ts +818 -0
  33. package/dist/templates/call-audit/agent.ts +171 -0
  34. package/dist/templates/call-audit/client.tsx +198 -0
  35. package/dist/templates/call-audit/workflows/audit.ts +298 -0
  36. package/dist/templates/call-audit/workflows/ingest.ts +251 -0
  37. package/dist/templates/call-audit/workflows/media.ts +637 -0
  38. package/dist/templates/call-audit/workflows/summarize.ts +201 -0
  39. package/dist/templates/call-audit/workflows/sync-api.ts +44 -0
  40. package/dist/templates/dispatch-center/agent.test.ts +149 -25
  41. package/dist/templates/dispatch-center/client.tsx +239 -129
  42. package/dist/templates/dispatch-center/shared.ts +99 -1
  43. package/dist/templates/dispatch-center/system-prompt.md +3 -1
  44. package/dist/templates/dispatch-center/tools/incident_add_note.ts +16 -13
  45. package/dist/templates/dispatch-center/tools/incident_create.ts +19 -1
  46. package/dist/templates/dispatch-center/tools/incident_escalate.ts +68 -59
  47. package/dist/templates/dispatch-center/tools/incident_triage.ts +57 -43
  48. package/dist/templates/dispatch-center/tools/incident_update_status.ts +49 -40
  49. package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +8 -2
  50. package/dist/templates/dispatch-center/tools/resources_dispatch.ts +69 -52
  51. package/dist/templates/dispatch-center/tools/resources_update_status.ts +45 -41
  52. package/dist/templates/embedded-assets/agent.test.ts +17 -14
  53. package/dist/templates/health-assistant/agent.test.ts +22 -4
  54. package/dist/templates/infocom-adventure/agent.test.ts +4 -6
  55. package/dist/templates/infocom-adventure/client.tsx +246 -164
  56. package/dist/templates/link-digest/agent.test.ts +24 -19
  57. package/dist/templates/link-digest/client.tsx +47 -61
  58. package/dist/templates/link-digest/workflows/digest.ts +19 -31
  59. package/dist/templates/night-owl/agent.test.ts +70 -19
  60. package/dist/templates/night-owl/agent.ts +5 -0
  61. package/dist/templates/night-owl/client.tsx +56 -56
  62. package/dist/templates/night-owl/shared.ts +24 -0
  63. package/dist/templates/night-owl/tools/recommend.ts +18 -6
  64. package/dist/templates/pizza-ordering/agent.test.ts +27 -27
  65. package/dist/templates/pizza-ordering/client.tsx +9 -26
  66. package/dist/templates/plan-and-execute/agent.test.ts +99 -58
  67. package/dist/templates/plan-and-execute/agent.ts +1 -1
  68. package/dist/templates/plan-and-execute/client.tsx +12 -15
  69. package/dist/templates/plan-and-execute/shared.ts +71 -2
  70. package/dist/templates/plan-and-execute/tools/plan_status.ts +18 -6
  71. package/dist/templates/plan-and-execute/tools/revise_plan.ts +18 -10
  72. package/dist/templates/plan-and-execute/tools/start_plan.ts +13 -2
  73. package/dist/templates/plan-and-execute/tools/work_next_step.ts +70 -22
  74. package/dist/templates/podcast-digest/agent.test.ts +746 -0
  75. package/dist/templates/podcast-digest/agent.ts +139 -0
  76. package/dist/templates/podcast-digest/client.tsx +154 -0
  77. package/dist/templates/podcast-digest/workflows/digest.ts +411 -0
  78. package/dist/templates/podcast-digest/workflows/feeds.ts +507 -0
  79. package/dist/templates/podcast-digest/workflows/slack.ts +209 -0
  80. package/dist/templates/recap-workflow/agent.test.ts +107 -96
  81. package/dist/templates/recap-workflow/tools/recap_progress.ts +9 -12
  82. package/dist/templates/recap-workflow/tools/recap_status.ts +8 -9
  83. package/dist/templates/recap-workflow/tools/request_recap.ts +2 -1
  84. package/dist/templates/recap-workflow/workflows/recap.ts +48 -41
  85. package/dist/templates/redline/agent.test.ts +17 -15
  86. package/dist/templates/redline/client.tsx +12 -12
  87. package/dist/templates/redline/workflows/redline.ts +19 -31
  88. package/dist/templates/research-workflow/agent.test.ts +60 -59
  89. package/dist/templates/research-workflow/tools/research_progress.ts +9 -12
  90. package/dist/templates/research-workflow/tools/research_status.ts +9 -11
  91. package/dist/templates/research-workflow/workflows/research.ts +44 -61
  92. package/dist/templates/retail/agent.test.ts +26 -23
  93. package/dist/templates/retail/client.tsx +226 -117
  94. package/dist/templates/retail/registry.test.ts +38 -6
  95. package/dist/templates/retail/store.test.ts +82 -15
  96. package/dist/templates/retail/store.ts +174 -47
  97. package/dist/templates/retail/system-prompt.md +11 -2
  98. package/dist/templates/retail/tools/cancel_pending_order.ts +2 -2
  99. package/dist/templates/retail/tools/exchange_delivered_order_items.ts +2 -2
  100. package/dist/templates/retail/tools/find_user_id_by_email.ts +8 -5
  101. package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +4 -5
  102. package/dist/templates/retail/tools/get_item_details.ts +3 -4
  103. package/dist/templates/retail/tools/get_order_details.ts +3 -4
  104. package/dist/templates/retail/tools/get_product_details.ts +3 -4
  105. package/dist/templates/retail/tools/get_user_details.ts +2 -2
  106. package/dist/templates/retail/tools/list_all_product_types.ts +5 -5
  107. package/dist/templates/retail/tools/modify_pending_order_address.ts +2 -2
  108. package/dist/templates/retail/tools/modify_pending_order_items.ts +2 -3
  109. package/dist/templates/retail/tools/modify_pending_order_payment.ts +2 -2
  110. package/dist/templates/retail/tools/modify_user_address.ts +2 -2
  111. package/dist/templates/retail/tools/return_delivered_order_items.ts +2 -2
  112. package/dist/templates/retail/tools/transfer_to_human_agents.ts +8 -4
  113. package/dist/templates/solo-rpg/agent.test.ts +227 -48
  114. package/dist/templates/solo-rpg/client.tsx +38 -37
  115. package/dist/templates/solo-rpg/shared.ts +145 -19
  116. package/dist/templates/solo-rpg/system-prompt.md +3 -2
  117. package/dist/templates/solo-rpg/tools/action_roll.ts +82 -63
  118. package/dist/templates/solo-rpg/tools/burn_momentum.ts +85 -50
  119. package/dist/templates/solo-rpg/tools/check_state.ts +24 -7
  120. package/dist/templates/solo-rpg/tools/load_game.ts +13 -1
  121. package/dist/templates/solo-rpg/tools/save_game.ts +16 -7
  122. package/dist/templates/solo-rpg/tools/setup_character.ts +22 -2
  123. package/dist/templates/solo-rpg/tools/update_state.ts +113 -100
  124. package/dist/templates/spoken-summary/agent.test.ts +314 -0
  125. package/dist/templates/spoken-summary/agent.ts +155 -0
  126. package/dist/templates/spoken-summary/client.tsx +185 -0
  127. package/dist/templates/spoken-summary/workflows/summarize.ts +237 -0
  128. package/dist/templates/spoken-summary/workflows/transcribe.ts +138 -0
  129. package/dist/templates/support-line/agent.test.ts +11 -16
  130. package/dist/templates/support-line/agent.ts +1 -1
  131. package/dist/templates/support-line/client.tsx +9 -9
  132. package/dist/templates/support-line/nodes.ts +100 -0
  133. package/dist/templates/support-line/procedure.ts +407 -0
  134. package/dist/templates/support-line/tools/answer_question.ts +17 -4
  135. package/dist/templates/transcription-workflow/agent.test.ts +246 -123
  136. package/dist/templates/transcription-workflow/agent.ts +21 -7
  137. package/dist/templates/transcription-workflow/client.tsx +17 -42
  138. package/dist/templates/transcription-workflow/workflows/batch.ts +79 -180
  139. package/dist/templates/transcription-workflow/workflows/normalize.ts +247 -0
  140. package/dist/templates/transcription-workflow/workflows/stitch.ts +0 -11
  141. package/dist/templates/transcription-workflow/workflows/stream.ts +10 -9
  142. package/dist/templates/transcription-workflow/workflows/sync-api.ts +26 -94
  143. package/dist/templates/transcription-workflow/workflows/transcribe.ts +55 -40
  144. package/dist/templates/transcription-workflow/workflows/wav.ts +31 -31
  145. package/dist/templates/travel-concierge/agent.test.ts +64 -33
  146. package/dist/templates/travel-concierge/client.tsx +11 -23
  147. package/dist/templates/travel-concierge/routing.ts +34 -15
  148. package/dist/templates/travel-concierge/shared.ts +70 -3
  149. package/dist/templates/travel-concierge/tools/book_car_rental.ts +2 -2
  150. package/dist/templates/travel-concierge/tools/book_excursion.ts +2 -2
  151. package/dist/templates/travel-concierge/tools/book_hotel.ts +2 -2
  152. package/dist/templates/travel-concierge/tools/cancel_ticket.ts +2 -2
  153. package/dist/templates/travel-concierge/tools/update_ticket.ts +2 -2
  154. package/dist/{worker-bundler-CGD4r8Kc.mjs → worker-bundler-COxnqstQ.mjs} +221 -3
  155. package/dist/worker-bundler.mjs +1 -1
  156. package/dist/{workflow-CFpxOFfQ.mjs → workflow-D2AQf2Pl.mjs} +28 -1
  157. package/dist/workflow-bundler.d.ts +6 -1
  158. package/dist/workflow.d.ts +1 -1
  159. package/package.json +5 -4
  160. package/dist/templates/support-line/graph.ts +0 -224
  161. package/dist/{_config-D_s09e7g.mjs → _config-CmJOFsAP.mjs} +1 -1
  162. /package/dist/templates/plan-and-execute/{graph.ts → procedure.ts} +0 -0
@@ -20,18 +20,19 @@
20
20
  * builds a project and runs one.
21
21
  */
22
22
 
23
- import type { ToolContext, WorkflowClient, WorkflowRunSnapshot } from "@alexkroman1/aai";
23
+ import type { WorkflowClient } from "@alexkroman1/aai";
24
24
  import {
25
- createProgressStream,
26
25
  createRunSnapshot,
27
- createStubWorkflows,
28
26
  createToolContext,
29
- runTool,
27
+ parseSchemaInput,
30
28
  type StubGatewayCall,
29
+ schemaInputIssues,
30
+ toolRunner,
31
31
  withDiscoveredTools,
32
32
  } from "@alexkroman1/aai/testing";
33
- import { installStubGateway as stubGateway } from "@alexkroman1/aai/testing/vitest";
33
+ import { mockWorkflows, installStubGateway as stubGateway } from "@alexkroman1/aai/testing/vitest";
34
34
  import { visitWebpage, webSearch } from "@alexkroman1/aai/tools";
35
+ import type { WorkflowRunSnapshot } from "@alexkroman1/aai/workflow-api";
35
36
  import { beforeEach, describe, expect, test, vi } from "vitest";
36
37
  import { FatalError, RetryableError } from "workflow";
37
38
  import authoredAgent from "./agent.ts";
@@ -75,37 +76,34 @@ const agentDef = withDiscoveredTools(
75
76
  import.meta.glob("./tools/*.ts", { eager: true }),
76
77
  );
77
78
 
78
- /** Every tool here is driven through the agent's own table, by the name the model calls. */
79
- const run = (name: string, args: Record<string, unknown>, ctx: ToolContext): Promise<unknown> =>
80
- runTool(agentDef, name, args, ctx);
79
+ /**
80
+ * Every tool here is driven through the agent's own table, by the name the model
81
+ * calls.
82
+ *
83
+ * The second parameter is args-or-context, which is `runTool`'s own shape: three
84
+ * of this desk's four tools take no arguments, and the `{}` those calls were
85
+ * obliged to pass sat between the two values a reader cares about.
86
+ */
87
+ const run = toolRunner(agentDef);
81
88
 
82
89
  /**
83
90
  * A `ctx.workflows` that records `start` and answers `find` from a fixture.
84
91
  *
85
92
  * Returned WITHOUT a cast, which is the property worth keeping: a cast would
86
93
  * also stop reporting the day `WorkflowClient` grows a method, and this stub is
87
- * how the template's tools reach the client at all. `createStubWorkflows` is
88
- * what keeps that affordable — it fills in the methods this desk does not
89
- * drive, so the day the client does grow one, only the tests that use it change.
94
+ * how the template's tools reach the client at all. `mockWorkflows`
95
+ * (`@alexkroman1/aai/testing/vitest`) is what keeps that affordable — a `vi.fn`
96
+ * per method over one `runs` list, filling in what this desk does not drive, so
97
+ * the day the client does grow a method only the tests using it change.
98
+ * `stream`/`streamTail` are left rejecting on purpose: `research_progress` reads
99
+ * progress through `lastLine`, and composing those two by hand is the hazard
100
+ * `lastLine` exists to remove.
90
101
  */
91
102
  function stubWorkflows(runs: WorkflowRunSnapshot[] = []): WorkflowClient {
92
- return createStubWorkflows({
93
- start: vi.fn(async () => "wrun_stub"),
94
- get: vi.fn(async () => runs[0]),
95
- find: vi.fn(async () => runs),
96
- recent: vi.fn(async () => runs),
97
- cancel: vi.fn(async () => true),
98
- wakeUp: vi.fn(async () => 0),
99
- // A tail of 0 means "one line written", which is the case the tools read.
100
- // The `-1` case is overridden per test, because it is the one that decides
101
- // whether the stream is opened at all.
102
- streamTail: vi.fn(async () => 0),
103
- stream: vi.fn(async () => createProgressStream([])),
104
- // Name only: `WorkflowDef.description` is optional, so passing it through
105
- // would mean handing `description: undefined` to a field that does not
106
- // accept it. Nothing here reads the description anyway.
107
- listing: () => [{ name: "research" }],
108
- });
103
+ // Name only: `WorkflowSummary.description` is optional, so passing this
104
+ // desk's through would mean handing `description: undefined` to a field that
105
+ // does not accept it. Nothing here reads the description anyway.
106
+ return mockWorkflows({ runs, names: ["research"] });
109
107
  }
110
108
 
111
109
  describe("the agent declares its workflow", () => {
@@ -115,13 +113,16 @@ describe("the agent declares its workflow", () => {
115
113
  });
116
114
 
117
115
  test("with an input schema, so a bad topic fails at the call site", async () => {
118
- const result = await research.input?.["~standard"].validate({
119
- topic: "otters",
120
- requestedBy: "s",
121
- });
122
- expect(result?.issues).toBeUndefined();
123
- const bad = await research.input?.["~standard"].validate({ topic: "no", requestedBy: "s" });
124
- expect(bad?.issues).toBeDefined();
116
+ // `parseSchemaInput` / `schemaInputIssues` rather than a reach through
117
+ // `["~standard"].validate`: that is the vendor WIRE contract, and whether it
118
+ // answers synchronously or with a promise is the vendor's business — a
119
+ // missing `await` there leaves `.issues` undefined and the refusing half
120
+ // passes for the wrong reason.
121
+ const parsed = await parseSchemaInput(research.input, { topic: "otters", requestedBy: "s" });
122
+ expect(parsed).toMatchObject({ topic: "otters" });
123
+ expect(
124
+ await schemaInputIssues(research.input, { topic: "no", requestedBy: "s" }),
125
+ ).toBeDefined();
125
126
  });
126
127
  });
127
128
 
@@ -184,7 +185,7 @@ describe("request_research", () => {
184
185
  describe("research_status", () => {
185
186
  test("says nothing was started when the key has no runs", async () => {
186
187
  const ctx = createToolContext({ workflows: stubWorkflows([]) });
187
- const result = await run("research_status", {}, ctx);
188
+ const result = await run("research_status", ctx);
188
189
  expect(result).toMatchObject({ runs: [], note: "Nothing started yet." });
189
190
  });
190
191
 
@@ -197,7 +198,7 @@ describe("research_status", () => {
197
198
  }),
198
199
  ];
199
200
  const ctx = createToolContext({ workflows: stubWorkflows(runs) });
200
- const result = (await run("research_status", {}, ctx)) as { runs: string[] };
201
+ const result = (await run("research_status", ctx)) as { runs: string[] };
201
202
  expect(result.runs[0]).toContain("Otters use tools.");
202
203
  expect(result.runs[0]).toContain("3 sources");
203
204
  });
@@ -206,7 +207,7 @@ describe("research_status", () => {
206
207
  const ctx = createToolContext({
207
208
  workflows: stubWorkflows([createRunSnapshot({ workflow: "research", status: "running" })]),
208
209
  });
209
- const result = (await run("research_status", {}, ctx)) as { runs: string[] };
210
+ const result = (await run("research_status", ctx)) as { runs: string[] };
210
211
  expect(result.runs[0]).toContain("Still working on it.");
211
212
  });
212
213
 
@@ -215,14 +216,14 @@ describe("research_status", () => {
215
216
  createRunSnapshot({ workflow: "research", status: "failed", error: "model unavailable" }),
216
217
  ];
217
218
  const ctx = createToolContext({ workflows: stubWorkflows(runs) });
218
- const result = (await run("research_status", {}, ctx)) as { runs: string[] };
219
+ const result = (await run("research_status", ctx)) as { runs: string[] };
219
220
  expect(result.runs[0]).toContain("model unavailable");
220
221
  });
221
222
 
222
223
  test("bounds how many past runs it reads aloud", async () => {
223
224
  const workflows = stubWorkflows([]);
224
225
  const ctx = createToolContext({ workflows });
225
- await run("research_status", {}, ctx);
226
+ await run("research_status", ctx);
226
227
  // A voice reply cannot be a list of twenty runs.
227
228
  expect(workflows.find).toHaveBeenCalledWith(research, ctx.sessionId, { limit: 3 });
228
229
  });
@@ -233,40 +234,40 @@ describe("research_progress", () => {
233
234
  const workflows = stubWorkflows([
234
235
  createRunSnapshot({ workflow: "research", status: "running" }),
235
236
  ]);
236
- vi.mocked(workflows.stream).mockResolvedValue(createProgressStream(["Found 3 sources."]));
237
- const ctx = createToolContext({ workflows });
238
- const result = await run("research_progress", {}, ctx);
237
+ vi.mocked(workflows.lastLine).mockResolvedValue("Found 3 sources.");
238
+ const result = await run("research_progress", createToolContext({ workflows }));
239
239
  expect(result).toMatchObject({ progress: "Found 3 sources." });
240
240
  });
241
241
 
242
242
  test("asks for the LAST line, not the whole log", async () => {
243
- // A voice reply cannot recite every line the run has written.
243
+ // A voice reply cannot recite every line the run has written. `lastLine` is
244
+ // the whole request — the bound that keeps an empty channel from hanging
245
+ // belongs to the method, so nothing here composes `streamTail` and `stream`.
244
246
  const workflows = stubWorkflows([
245
247
  createRunSnapshot({ workflow: "research", status: "running" }),
246
248
  ]);
247
- vi.mocked(workflows.stream).mockResolvedValue(createProgressStream(["a"]));
248
- await run("research_progress", {}, createToolContext({ workflows }));
249
- expect(workflows.stream).toHaveBeenCalledWith("wrun_1", { startIndex: -1 });
249
+ vi.mocked(workflows.lastLine).mockResolvedValue("a");
250
+ await run("research_progress", createToolContext({ workflows }));
251
+ expect(workflows.lastLine).toHaveBeenCalledWith("wrun_1");
250
252
  });
251
253
 
252
- test("a run that has written nothing yet says so WITHOUT opening the stream", async () => {
253
- // Not a shortcut: an empty progress channel is never closed, so reading one
254
- // would wait for a line that arrives whenever the next step writes i.e.
255
- // the tool hangs instead of answering. The tail is how that is known.
254
+ test("a run that has written nothing yet says so", async () => {
255
+ // `lastLine` resolves `undefined` for an empty channel, and this is the arm
256
+ // the tool branches on. That an empty channel does not HANGit is never
257
+ // closed, so a stream opened on one waits for a line that may never come —
258
+ // is `lastLine`'s own guarantee now, and `aai`'s to test.
256
259
  const workflows = stubWorkflows([
257
260
  createRunSnapshot({ workflow: "research", status: "running" }),
258
261
  ]);
259
- vi.mocked(workflows.streamTail).mockResolvedValue(-1);
260
- const result = await run("research_progress", {}, createToolContext({ workflows }));
262
+ const result = await run("research_progress", createToolContext({ workflows }));
261
263
  expect(result).toMatchObject({ note: "Started, nothing to report yet." });
262
- expect(workflows.stream).not.toHaveBeenCalled();
263
264
  });
264
265
 
265
266
  test("says nothing was started when the key has no runs", async () => {
266
267
  const workflows = stubWorkflows([]);
267
- const result = await run("research_progress", {}, createToolContext({ workflows }));
268
+ const result = await run("research_progress", createToolContext({ workflows }));
268
269
  expect(result).toMatchObject({ note: "Nothing started yet." });
269
- expect(workflows.stream).not.toHaveBeenCalled();
270
+ expect(workflows.lastLine).not.toHaveBeenCalled();
270
271
  });
271
272
  });
272
273
 
@@ -276,7 +277,7 @@ describe("file_it_now", () => {
276
277
  createRunSnapshot({ workflow: "research", status: "running" }),
277
278
  ]);
278
279
  vi.mocked(workflows.wakeUp).mockResolvedValue(1);
279
- const result = await run("file_it_now", {}, createToolContext({ workflows }));
280
+ const result = await run("file_it_now", createToolContext({ workflows }));
280
281
  expect(workflows.wakeUp).toHaveBeenCalledWith("wrun_1");
281
282
  expect(result).toMatchObject({ filed: true });
282
283
  });
@@ -288,13 +289,13 @@ describe("file_it_now", () => {
288
289
  createRunSnapshot({ workflow: "research", status: "running" }),
289
290
  ]);
290
291
  vi.mocked(workflows.wakeUp).mockResolvedValue(0);
291
- const result = await run("file_it_now", {}, createToolContext({ workflows }));
292
+ const result = await run("file_it_now", createToolContext({ workflows }));
292
293
  expect(result).toMatchObject({ filed: false });
293
294
  });
294
295
 
295
296
  test("says nothing was started when the key has no runs", async () => {
296
297
  const workflows = stubWorkflows([]);
297
- const result = await run("file_it_now", {}, createToolContext({ workflows }));
298
+ const result = await run("file_it_now", createToolContext({ workflows }));
298
299
  expect(result).toMatchObject({ note: "Nothing started yet." });
299
300
  expect(workflows.wakeUp).not.toHaveBeenCalled();
300
301
  });
@@ -10,17 +10,14 @@ export default tool({
10
10
  // has WRITTEN (`getWritable()` in `workflows/research.ts`). Between "still
11
11
  // working on it" and a finished summary there is otherwise nothing to say.
12
12
  //
13
- // `streamTail` FIRST, and not as an optimization: a progress channel is
14
- // never closed — no step knows it is the last one — so reading a stream
15
- // with nothing in it waits forever rather than ending. `-1` is "nothing
16
- // written yet", and it is the only safe way to learn that.
17
- if ((await ctx.workflows.streamTail(latest.runId)) < 0) {
18
- return { note: "Started, nothing to report yet." };
19
- }
20
- // A negative `startIndex` reads from the END, which is what a voice reply
21
- // wants the last line, not a recital of the whole log.
22
- const stream = await ctx.workflows.stream(latest.runId, { startIndex: -1 });
23
- for await (const line of stream) return { progress: String(line) };
24
- return { note: "Started, nothing to report yet." };
13
+ // `lastLine` rather than `streamTail` + `stream` composed here: a progress
14
+ // channel is never closed — no step knows it is the last one — so a stream
15
+ // opened on a run that has written nothing waits forever, which down a phone
16
+ // is a turn that stops with no error and nothing in a log. The bound that
17
+ // prevents it belongs to the method now, and `undefined` is "nothing yet".
18
+ const line = await ctx.workflows.lastLine(latest.runId);
19
+ return line === undefined
20
+ ? { note: "Started, nothing to report yet." }
21
+ : { progress: String(line) };
25
22
  },
26
23
  });
@@ -1,9 +1,6 @@
1
- import {
2
- isTerminal,
3
- tool,
4
- type WorkflowOutputOf,
5
- type WorkflowRunSnapshot,
6
- } from "@alexkroman1/aai";
1
+ import { tool } from "@alexkroman1/aai";
2
+ import { plural } from "@alexkroman1/aai/utils";
3
+ import { isTerminal, type WorkflowRunOf } from "@alexkroman1/aai/workflow-api";
7
4
  import { research } from "../shared.ts";
8
5
 
9
6
  /** How many past runs the status tool will look at. Newest first. */
@@ -12,17 +9,18 @@ const RECENT_RUNS = 3;
12
9
  /**
13
10
  * One line a voice agent can read aloud about a run.
14
11
  *
15
- * `WorkflowOutputOf` is what names the output type — the same helper a page uses
16
- * to type `run.output`, and the reason this signature does not have to reach
17
- * past the declaration into the body's own return type.
12
+ * `WorkflowRunOf` is the snapshot with its output already typed — the
13
+ * `WorkflowRunSnapshot<WorkflowOutputOf<typeof research>>` this file used to
14
+ * compose by hand, which cost a three-name import for one type. Still the
15
+ * discriminated union, so `isTerminal` below narrows exactly as it did.
18
16
  */
19
- function describeRun(run: WorkflowRunSnapshot<WorkflowOutputOf<typeof research>>): string {
17
+ function describeRun(run: WorkflowRunOf<typeof research>): string {
20
18
  // `isTerminal` narrows to the three finished statuses, which is what makes
21
19
  // `run.output` and `run.error` reachable without a cast.
22
20
  if (!isTerminal(run)) return "Still working on it.";
23
21
  switch (run.status) {
24
22
  case "completed":
25
- return `Done: ${run.output.summary} (${run.output.sources} sources)`;
23
+ return `Done: ${run.output.summary} (${run.output.sources} ${plural(run.output.sources, "source")})`;
26
24
  case "failed":
27
25
  return `That one failed: ${run.error}`;
28
26
  default:
@@ -35,7 +35,7 @@
35
35
  *
36
36
  * ## Every stage REPORTS, and the report goes two places
37
37
  *
38
- * `report()` (`@alexkroman1/aai/utils`) writes to the run's own stream — which
38
+ * `report()` (`@alexkroman1/aai/step`) writes to the run's own stream — which
39
39
  * `research_progress` reads back down the phone and a page renders — and to the
40
40
  * server log, with the attempt number appended past the first. A pass that is
41
41
  * retrying and one that is working print the same sentences otherwise.
@@ -49,16 +49,10 @@
49
49
  * researcher CONCLUDED, which is exactly what the step returns.
50
50
  */
51
51
 
52
- import { throwStepError } from "@alexkroman1/aai/step-errors";
52
+ import { mapConcurrent, report } from "@alexkroman1/aai/step";
53
+ import { stepGenerateClassified, stepGenerateJsonClassified } from "@alexkroman1/aai/step-errors";
53
54
  import { visitWebpage, webSearch } from "@alexkroman1/aai/tools";
54
- import {
55
- errorMessage,
56
- isToolFailure,
57
- mapInBatches,
58
- report,
59
- stepGenerate,
60
- stepGenerateJson,
61
- } from "@alexkroman1/aai/utils";
55
+ import { errorMessage, isToolFailure, plural } from "@alexkroman1/aai/utils";
62
56
  import { sleep } from "workflow";
63
57
  import { z } from "zod";
64
58
  import {
@@ -108,9 +102,9 @@ export type Source = { title: string; url: string };
108
102
 
109
103
  // ---- What each stage's model call has to come back as ------------------------
110
104
  //
111
- // `stepGenerateJson` validates against these, so a reply that missed is a plain
112
- // throw and therefore a retry — where the hand-rolled `askJson<T>()` this
113
- // replaces returned a value the compiler believed and nothing checked. They are
105
+ // `stepGenerateJsonClassified` validates against these, so a reply that missed
106
+ // is a plain throw and therefore a retry — where the hand-rolled `askJson<T>()`
107
+ // this replaces returned a value the compiler believed and nothing checked. They are
114
108
  // deliberately LENIENT wherever the old hand-written coercion was: a model that
115
109
  // put one number in an array of strings should cost that element, not the whole
116
110
  // pass.
@@ -221,12 +215,14 @@ export async function researchFlow(input: { topic: string; requestedBy: string }
221
215
  // failed angle fails the RUN: its finished siblings are already journaled, so
222
216
  // the resume replays them for free and re-issues only what is missing, where
223
217
  // catching here would file a report with a silent hole in it.
224
- const first = await mapInBatches(angles, ANGLE_CONCURRENCY, (angle) => investigate(brief, angle));
218
+ const first = await mapConcurrent(angles, ANGLE_CONCURRENCY, (angle) =>
219
+ investigate(brief, angle),
220
+ );
225
221
 
226
222
  // The supervisor's second look. Usually empty — a second wave costs the caller
227
223
  // minutes, and the prompt says so.
228
224
  const gaps = await findGaps(brief, first);
229
- const second = await mapInBatches(gaps, ANGLE_CONCURRENCY, (angle) => investigate(brief, angle));
225
+ const second = await mapConcurrent(gaps, ANGLE_CONCURRENCY, (angle) => investigate(brief, angle));
230
226
 
231
227
  const notes = [...first, ...second];
232
228
  const written = await writeReport(input.topic, brief, notes);
@@ -261,10 +257,9 @@ export async function writeBrief(topic: string): Promise<Brief> {
261
257
  "use step";
262
258
 
263
259
  await report(`Working out what "${topic}" is really asking.`);
264
- const parsed = await askJson(
260
+ const parsed = await stepGenerateJsonClassified(
265
261
  `Research request, as the caller said it: ${topic}`,
266
- BRIEF_SYSTEM,
267
- BriefReply,
262
+ { system: BRIEF_SYSTEM, schema: BriefReply },
268
263
  );
269
264
  return { brief: parsed.brief || topic, criteria: parsed.criteria.slice(0, MAX_ANGLES) };
270
265
  }
@@ -279,7 +274,10 @@ export async function writeBrief(topic: string): Promise<Brief> {
279
274
  export async function planAngles(brief: Brief): Promise<string[]> {
280
275
  "use step";
281
276
 
282
- const parsed = await askJson(briefText(brief), PLAN_SYSTEM, AnglesReply);
277
+ const parsed = await stepGenerateJsonClassified(briefText(brief), {
278
+ system: PLAN_SYSTEM,
279
+ schema: AnglesReply,
280
+ });
283
281
  const angles = parsed.angles.slice(0, MAX_ANGLES);
284
282
  if (angles.length === 0) {
285
283
  // Nothing to fan out over is a plan failure, not an empty result: the brief
@@ -287,7 +285,7 @@ export async function planAngles(brief: Brief): Promise<string[]> {
287
285
  await report("No angles came back; researching the brief itself.");
288
286
  return [brief.brief];
289
287
  }
290
- await report(`Researching ${angles.length} angle${angles.length === 1 ? "" : "s"}.`);
288
+ await report(`Researching ${angles.length} ${plural(angles.length, "angle")}.`);
291
289
  return angles;
292
290
  }
293
291
 
@@ -341,16 +339,15 @@ export async function findGaps(brief: Brief, notes: readonly Note[]): Promise<st
341
339
  "use step";
342
340
 
343
341
  if (notes.length === 0) return [];
344
- const parsed = await askJson(
342
+ const parsed = await stepGenerateJsonClassified(
345
343
  `${briefText(brief)}\n\nWhat came back:\n${notes.map(noteText).join("\n\n")}`,
346
- GAPS_SYSTEM,
347
- AnglesReply,
344
+ { system: GAPS_SYSTEM, schema: AnglesReply },
348
345
  );
349
346
  const gaps = parsed.angles.slice(0, MAX_ANGLES - 1);
350
347
  await report(
351
348
  gaps.length === 0
352
349
  ? "The brief is covered; writing it up."
353
- : `Following up ${gaps.length} gap${gaps.length === 1 ? "" : "s"}.`,
350
+ : `Following up ${gaps.length} ${plural(gaps.length, "gap")}.`,
354
351
  );
355
352
  return gaps;
356
353
  }
@@ -369,12 +366,14 @@ export async function writeReport(
369
366
  ): Promise<{ report: string; summary: string }> {
370
367
  "use step";
371
368
 
372
- await report(`Writing up ${notes.length} angle${notes.length === 1 ? "" : "s"}.`);
373
- const written = await ask(
369
+ await report(`Writing up ${notes.length} ${plural(notes.length, "angle")}.`);
370
+ const written = await stepGenerateClassified(
374
371
  `${briefText(brief)}\n\nFindings:\n${notes.map(noteText).join("\n\n")}`,
375
- REPORT_SYSTEM,
372
+ { system: REPORT_SYSTEM },
376
373
  );
377
- const summary = await ask(`Topic: ${topic}\n\nReport:\n${written}`, BRIEF_SUMMARY_SYSTEM);
374
+ const summary = await stepGenerateClassified(`Topic: ${topic}\n\nReport:\n${written}`, {
375
+ system: BRIEF_SUMMARY_SYSTEM,
376
+ });
378
377
  return { report: written, summary };
379
378
  }
380
379
 
@@ -404,12 +403,11 @@ async function nextAction(
404
403
  seen: readonly string[],
405
404
  left: number,
406
405
  ): Promise<Action> {
407
- return await askJson(
406
+ return await stepGenerateJsonClassified(
408
407
  `${briefText(brief)}\n\nYour angle: ${angle}\n` +
409
408
  `Actions left: ${left}\n\n` +
410
409
  (seen.length === 0 ? "You have not looked at anything yet." : seen.join("\n\n")),
411
- RESEARCH_SYSTEM,
412
- ActionReply,
410
+ { system: RESEARCH_SYSTEM, schema: ActionReply },
413
411
  );
414
412
  }
415
413
 
@@ -474,11 +472,10 @@ async function compress(angle: string, seen: readonly string[], sources: Source[
474
472
  if (seen.length === 0) {
475
473
  return { angle, findings: "Nothing was found on this angle.", sources: [] };
476
474
  }
477
- const parsed = await askJson(
478
- `Angle: ${angle}\n\n${seen.join("\n\n")}`,
479
- COMPRESS_SYSTEM,
480
- CompressReply,
481
- );
475
+ const parsed = await stepGenerateJsonClassified(`Angle: ${angle}\n\n${seen.join("\n\n")}`, {
476
+ system: COMPRESS_SYSTEM,
477
+ schema: CompressReply,
478
+ });
482
479
  return {
483
480
  angle,
484
481
  findings: parsed.findings ?? seen.join("\n\n"),
@@ -489,31 +486,17 @@ async function compress(angle: string, seen: readonly string[], sources: Source[
489
486
  }
490
487
 
491
488
  // ---- Model plumbing ---------------------------------------------------------
492
-
493
- /**
494
- * `stepGenerate`, with this desk's retry POLICY on top.
495
- *
496
- * The SDK classifies the gateway's failure (`StepGenerateError.retryable`) and
497
- * stops there, deliberately: whether a terminal failure should burn the step's
498
- * remaining attempts is the caller's call. `throwStepError` is that call made
499
- * one way terminal stays terminal, and a rate limit becomes a `RetryableError`
500
- * carrying the delay the gateway itself named.
501
- */
502
- async function ask(prompt: string, system: string): Promise<string> {
503
- return await stepGenerate(prompt, { system }).catch(throwStepError);
504
- }
505
-
506
- /**
507
- * The same call, for a stage whose reply is JSON of a known shape.
508
- *
509
- * `stepGenerateJson` owns the four things every such stage used to re-derive —
510
- * unwrap the fence, parse, reject a non-object, check the shape — and throws
511
- * PLAINLY when any of them misses, which is what makes a malformed reply a
512
- * retry rather than a failure.
513
- */
514
- async function askJson<S extends z.ZodType>(prompt: string, system: string, schema: S) {
515
- return await stepGenerateJson(prompt, { system, schema }).catch(throwStepError);
516
- }
489
+ //
490
+ // There is none left, and its absence is the point. This desk carried an `ask()`
491
+ // and an `askJson()` whose whole body was `.catch(throwStepError)`; the SDK's
492
+ // `stepGenerateClassified` and `stepGenerateJsonClassified`
493
+ // (`@alexkroman1/aai/step-errors`) ARE that call the `/step` one with the
494
+ // gateway's verdict classified, so a terminal failure stays terminal and a rate
495
+ // limit becomes a `RetryableError` carrying the delay the gateway itself named.
496
+ // `stepGenerateJsonClassified` also owns the four things every JSON stage used
497
+ // to re-derive — unwrap the fence, parse, reject a non-object, check the shape
498
+ // and throws PLAINLY when any of them misses, which is what makes a malformed
499
+ // reply a retry rather than a failure.
517
500
 
518
501
  // ---- Pure helpers -----------------------------------------------------------
519
502
 
@@ -1,10 +1,10 @@
1
1
  import type { ToolContext } from "@alexkroman1/aai";
2
2
  import { isToolFailure } from "@alexkroman1/aai";
3
- import { createToolContext } from "@alexkroman1/aai/testing";
3
+ import { createToolContext, ok } from "@alexkroman1/aai/testing";
4
4
  import { describe, expect, test } from "vitest";
5
5
  import type { AuthResult } from "./authenticate.ts";
6
6
  import type { Address } from "./shared.ts";
7
- import { retailSlot } from "./store.ts";
7
+ import { callFlow, retailSlot } from "./store.ts";
8
8
  import cancelPendingOrder from "./tools/cancel_pending_order.ts";
9
9
  import exchangeDeliveredOrderItems from "./tools/exchange_delivered_order_items.ts";
10
10
  import findUserIdByEmail from "./tools/find_user_id_by_email.ts";
@@ -27,27 +27,6 @@ function makeCtx(): ToolContext {
27
27
  return createToolContext();
28
28
  }
29
29
 
30
- /**
31
- * The success half of a tool result, or a thrown failure carrying the tool's
32
- * own message.
33
- *
34
- * `ToolDef["execute"]`'s public signature always returns `unknown` — the wire
35
- * type is fixed so any tool is assignable to `ToolDef`, whatever its body
36
- * really returns — so every test that reads a field back off a result needed a
37
- * cast. There were EIGHTEEN of them here, each the same three lines, which is
38
- * the shape the repo's guides call a missing typed seam: a concentration of
39
- * identical casts is one narrowing that belongs in one helper, not a cast per
40
- * assertion. This is that helper, and it is the file's only cast.
41
- *
42
- * It also removes the second half of the boilerplate: a `ToolFailure` fails the
43
- * test HERE, naming what the tool refused, rather than surfacing three lines
44
- * later as `undefined` on a field nobody assigned.
45
- */
46
- function ok<T>(result: unknown): T {
47
- if (isToolFailure(result)) throw new Error(`tool refused: ${result.error}`);
48
- return result as T;
49
- }
50
-
51
30
  /** A context already authenticated as `userId`, via the real tool. */
52
31
  async function authedCtx(email: string): Promise<ToolContext> {
53
32
  const ctx = makeCtx();
@@ -63,6 +42,8 @@ describe("authentication", () => {
63
42
  );
64
43
  expect(result.user_id).toBe("olivia_ito_3591");
65
44
  expect(retailSlot.get(ctx).authenticatedUserId).toBe("olivia_ito_3591");
45
+ // Two facts, two homes: the store latches WHO, the flow holds WHETHER.
46
+ expect(callFlow.position(ctx).state).toBe("serving");
66
47
  });
67
48
 
68
49
  test("an unknown email is refused and leaves the session unauthenticated", async () => {
@@ -70,6 +51,9 @@ describe("authentication", () => {
70
51
  const result = await findUserIdByEmail.execute({ email: "nobody@example.com" }, ctx);
71
52
  expect(isToolFailure(result)).toBe(true);
72
53
  expect(retailSlot.get(ctx).authenticatedUserId).toBeNull();
54
+ // `IDENTIFIED` is not sent when the body answers a `ToolFailure`, so a
55
+ // lookup that found nobody cannot leave the call one step ahead of itself.
56
+ expect(callFlow.position(ctx).state).toBe("identifying");
73
57
  });
74
58
 
75
59
  test("find_user_id_by_name_zip is case-insensitive on names and exact on zip", async () => {
@@ -121,6 +105,9 @@ describe("authentication", () => {
121
105
  );
122
106
  expect(isToolFailure(switched) && switched.error.toLowerCase()).toContain("one customer");
123
107
  expect(retailSlot.get(ctx).authenticatedUserId).toBe("olivia_ito_3591");
108
+ // The tool is legal in `serving` — the refusal comes from `authenticateAs`,
109
+ // not from the gate — so the call stays exactly where it was.
110
+ expect(callFlow.position(ctx).state).toBe("serving");
124
111
  });
125
112
 
126
113
  test("switching via name + zip is refused too — both doors, one lock", async () => {
@@ -1090,4 +1077,20 @@ describe("transfer_to_human_agents", () => {
1090
1077
  expect(isToolFailure(result)).toBe(false);
1091
1078
  expect(result.transferred).toBe(true);
1092
1079
  });
1080
+
1081
+ test("the handoff is terminal, so the call cannot be worked afterwards", async () => {
1082
+ const ctx = await authedCtx("olivia.ito5204@example.com");
1083
+ ok(await transferToHumanAgents.execute({ summary: "wants a human" }, ctx));
1084
+
1085
+ const at = callFlow.position(ctx);
1086
+ expect(at.state).toBe("transferred");
1087
+ expect(at.done).toBe(true);
1088
+
1089
+ // The policy's "say nothing else after that" used to be enforced by
1090
+ // nothing: every tool stayed callable, so a model that kept going kept
1091
+ // acting on a call it had given away.
1092
+ const refused = await getUserDetails.execute({ user_id: "olivia_ito_3591" }, ctx);
1093
+ expect(isToolFailure(refused)).toBe(true);
1094
+ expect(isToolFailure(refused) && refused.error).toContain('"transferred"');
1095
+ });
1093
1096
  });