@alexkroman1/aai-cli 5.14.0 → 6.1.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 (231) hide show
  1. package/dist/{_agent-C8Jk-ZcP.mjs → _agent-vGW5SBnX.mjs} +23 -5
  2. package/dist/_agent.d.ts +18 -0
  3. package/dist/{_api-client-BX8Qv51n.mjs → _api-client-LgLksMhN.mjs} +45 -8
  4. package/dist/_api-client.d.ts +39 -0
  5. package/dist/{_bundler-BuIyDdvs.mjs → _bundler-BapCUUJN.mjs} +42 -14
  6. package/dist/_bundler.d.ts +43 -5
  7. package/dist/_cli-common.d.ts +67 -3
  8. package/dist/{_config-CzwAWOWr.mjs → _config-D_s09e7g.mjs} +36 -18
  9. package/dist/_deploy.d.ts +2 -5
  10. package/dist/_dev-restart.d.ts +6 -1
  11. package/dist/{_dev-server-Ch7K8e8i.mjs → _dev-server-DQ1t9sbw.mjs} +94 -38
  12. package/dist/_dev-server.d.ts +30 -2
  13. package/dist/_e2e-test-utils.d.ts +80 -0
  14. package/dist/_fault-mode.d.ts +175 -0
  15. package/dist/{_init-CbkrE2wq.mjs → _init-CpISqoR8.mjs} +3 -3
  16. package/dist/{_output-CC300DzW.mjs → _output-CKkmWs7i.mjs} +8 -1
  17. package/dist/_output.d.ts +9 -2
  18. package/dist/{_server-common-61fL__39.mjs → _server-common-6e0QI4mq.mjs} +1 -1
  19. package/dist/{_slug-api-D30zPmsn.mjs → _slug-api-D2heILuC.mjs} +2 -2
  20. package/dist/{_studio-D2ciIhbi.mjs → _studio-zAJycZ8j.mjs} +3 -3
  21. package/dist/{_templates-B1kt3BGt.mjs → _templates-iDGjGiPf.mjs} +4 -6
  22. package/dist/{_typecheck-gate-4v9UBZHh.mjs → _typecheck-gate-BXvUNnfs.mjs} +1 -1
  23. package/dist/{_utils-0DHbcqUb.mjs → _utils-B8QmtFhK.mjs} +21 -7
  24. package/dist/_utils.d.ts +18 -2
  25. package/dist/{_vite-env-Dg_QlVv0.mjs → _vite-env-BNveawd1.mjs} +31 -1
  26. package/dist/_vite-env.d.ts +30 -0
  27. package/dist/build-BO1Ni6oJ.mjs +62 -0
  28. package/dist/build.d.ts +19 -0
  29. package/dist/cli-workflow.d.ts +10 -0
  30. package/dist/cli.d.ts +1 -2
  31. package/dist/cli.mjs +406 -224
  32. package/dist/{client-bundler-C9qXL8A2.mjs → client-bundler-B8Q9F8gJ.mjs} +3 -22
  33. package/dist/client-bundler.mjs +1 -1
  34. package/dist/{delete-DCRX3d96.mjs → delete-4I0uNR4a.mjs} +6 -7
  35. package/dist/delete.d.ts +2 -8
  36. package/dist/{deploy-DjsEV-40.mjs → deploy-DJ3HRcia.mjs} +13 -14
  37. package/dist/{dev-CvPH5dbF.mjs → dev-C6SWaAZP.mjs} +3 -3
  38. package/dist/{eject-LgBRfnbH.mjs → eject-Zrzk5KV3.mjs} +41 -15
  39. package/dist/eject.d.ts +30 -3
  40. package/dist/{init-BppxMR8r.mjs → init-BTRGiPQr.mjs} +61 -26
  41. package/dist/init.d.ts +11 -0
  42. package/dist/{login-BQQaxJhL.mjs → login-k0Z3VQu9.mjs} +5 -5
  43. package/dist/project-config.mjs +1 -1
  44. package/dist/scaffold/CLAUDE.md +498 -91
  45. package/dist/scaffold/package.json +11 -8
  46. package/dist/scaffold/pnpm-workspace.yaml +8 -0
  47. package/dist/scaffold/server.mjs +53 -57
  48. package/dist/{secret-Ds9dvqAj.mjs → secret-DzSQx1Ds.mjs} +6 -3
  49. package/dist/{storage-DbrQSHFk.mjs → storage-C-Do0mIA.mjs} +18 -6
  50. package/dist/{studio-GSFd4s5O.mjs → studio-BGVmCA2G.mjs} +10 -10
  51. package/dist/templates/code-interpreter/agent.ts +0 -2
  52. package/dist/templates/dispatch-center/agent.test.ts +17 -19
  53. package/dist/templates/dispatch-center/agent.ts +2 -29
  54. package/dist/templates/dispatch-center/client.tsx +33 -16
  55. package/dist/templates/dispatch-center/shared.ts +53 -19
  56. package/dist/templates/dispatch-center/tools/incident_add_note.ts +12 -14
  57. package/dist/templates/dispatch-center/tools/incident_create.ts +41 -44
  58. package/dist/templates/dispatch-center/tools/incident_escalate.ts +59 -61
  59. package/dist/templates/dispatch-center/tools/incident_get.ts +3 -4
  60. package/dist/templates/dispatch-center/tools/incident_triage.ts +43 -45
  61. package/dist/templates/dispatch-center/tools/incident_update_status.ts +39 -41
  62. package/dist/templates/dispatch-center/tools/ops_dashboard.ts +2 -5
  63. package/dist/templates/dispatch-center/tools/ops_protocols.ts +1 -1
  64. package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +20 -23
  65. package/dist/templates/dispatch-center/tools/resources_dispatch.ts +52 -54
  66. package/dist/templates/dispatch-center/tools/resources_get_available.ts +2 -4
  67. package/dist/templates/dispatch-center/tools/resources_update_status.ts +39 -42
  68. package/dist/templates/embedded-assets/agent.test.ts +83 -0
  69. package/dist/templates/embedded-assets/agent.ts +1 -48
  70. package/dist/templates/embedded-assets/shared.ts +17 -0
  71. package/dist/templates/embedded-assets/system-prompt.md +13 -0
  72. package/dist/templates/embedded-assets/tools/list_topics.ts +9 -0
  73. package/dist/templates/embedded-assets/tools/search_knowledge.ts +26 -0
  74. package/dist/templates/health-assistant/agent.test.ts +169 -0
  75. package/dist/templates/health-assistant/agent.ts +1 -166
  76. package/dist/templates/health-assistant/fda.ts +100 -0
  77. package/dist/templates/health-assistant/tools/check_drug_interaction.ts +59 -0
  78. package/dist/templates/health-assistant/tools/medication_lookup.ts +31 -0
  79. package/dist/templates/infocom-adventure/agent.test.ts +205 -0
  80. package/dist/templates/infocom-adventure/agent.ts +3 -106
  81. package/dist/templates/infocom-adventure/client.tsx +28 -12
  82. package/dist/templates/infocom-adventure/shared.ts +28 -11
  83. package/dist/templates/infocom-adventure/tools/game_state_drop.ts +13 -0
  84. package/dist/templates/infocom-adventure/tools/game_state_flag.ts +13 -0
  85. package/dist/templates/infocom-adventure/tools/game_state_get.ts +16 -0
  86. package/dist/templates/infocom-adventure/tools/game_state_history.ts +14 -0
  87. package/dist/templates/infocom-adventure/tools/game_state_move.ts +14 -0
  88. package/dist/templates/infocom-adventure/tools/game_state_restart.ts +13 -0
  89. package/dist/templates/infocom-adventure/tools/game_state_score.ts +13 -0
  90. package/dist/templates/infocom-adventure/tools/game_state_take.ts +13 -0
  91. package/dist/templates/link-digest/agent.test.ts +223 -0
  92. package/dist/templates/link-digest/agent.ts +79 -0
  93. package/dist/templates/link-digest/client.tsx +146 -0
  94. package/dist/templates/link-digest/workflows/digest.ts +248 -0
  95. package/dist/templates/math-buddy/agent.ts +0 -2
  96. package/dist/templates/night-owl/agent.test.ts +80 -0
  97. package/dist/templates/night-owl/agent.ts +1 -68
  98. package/dist/templates/night-owl/system-prompt.md +10 -0
  99. package/dist/templates/night-owl/tools/recommend.ts +64 -0
  100. package/dist/templates/personal-finance/agent.ts +0 -2
  101. package/dist/templates/pizza-ordering/agent.test.ts +52 -47
  102. package/dist/templates/pizza-ordering/agent.ts +9 -158
  103. package/dist/templates/pizza-ordering/shared.ts +35 -12
  104. package/dist/templates/pizza-ordering/tools/add_pizza.ts +31 -0
  105. package/dist/templates/pizza-ordering/tools/place_order.ts +22 -0
  106. package/dist/templates/pizza-ordering/tools/remove_pizza.ts +22 -0
  107. package/dist/templates/pizza-ordering/tools/set_customer_name.ts +13 -0
  108. package/dist/templates/pizza-ordering/tools/update_pizza.ts +28 -0
  109. package/dist/templates/pizza-ordering/tools/view_order.ts +21 -0
  110. package/dist/templates/plan-and-execute/agent.test.ts +411 -0
  111. package/dist/templates/plan-and-execute/agent.ts +23 -0
  112. package/dist/templates/plan-and-execute/client.tsx +115 -0
  113. package/dist/templates/plan-and-execute/graph.ts +178 -0
  114. package/dist/templates/plan-and-execute/prompts.ts +110 -0
  115. package/dist/templates/plan-and-execute/shared.ts +161 -0
  116. package/dist/templates/plan-and-execute/system-prompt.md +18 -0
  117. package/dist/templates/plan-and-execute/tools/plan_status.ts +20 -0
  118. package/dist/templates/plan-and-execute/tools/revise_plan.ts +63 -0
  119. package/dist/templates/plan-and-execute/tools/start_plan.ts +51 -0
  120. package/dist/templates/plan-and-execute/tools/work_next_step.ts +101 -0
  121. package/dist/templates/recap-workflow/agent.test.ts +762 -0
  122. package/dist/templates/recap-workflow/agent.ts +95 -0
  123. package/dist/templates/recap-workflow/shared.ts +43 -0
  124. package/dist/templates/recap-workflow/tools/cancel_recap.ts +23 -0
  125. package/dist/templates/recap-workflow/tools/keep_transcript.ts +31 -0
  126. package/dist/templates/recap-workflow/tools/recap_progress.ts +27 -0
  127. package/dist/templates/recap-workflow/tools/recap_status.ts +46 -0
  128. package/dist/templates/recap-workflow/tools/request_recap.ts +43 -0
  129. package/dist/templates/recap-workflow/workflows/recap.ts +544 -0
  130. package/dist/templates/recap-workflow/workflows/tokens.ts +26 -0
  131. package/dist/templates/redline/agent.test.ts +222 -0
  132. package/dist/templates/redline/agent.ts +89 -0
  133. package/dist/templates/redline/client.tsx +184 -0
  134. package/dist/templates/redline/workflows/prompts.ts +68 -0
  135. package/dist/templates/redline/workflows/redline.ts +258 -0
  136. package/dist/templates/research-workflow/agent.test.ts +525 -0
  137. package/dist/templates/research-workflow/agent.ts +94 -0
  138. package/dist/templates/research-workflow/shared.ts +35 -0
  139. package/dist/templates/research-workflow/tools/file_it_now.ts +22 -0
  140. package/dist/templates/research-workflow/tools/request_research.ts +33 -0
  141. package/dist/templates/research-workflow/tools/research_progress.ts +26 -0
  142. package/dist/templates/research-workflow/tools/research_status.ts +40 -0
  143. package/dist/templates/research-workflow/workflows/prompts.ts +143 -0
  144. package/dist/templates/research-workflow/workflows/research.ts +558 -0
  145. package/dist/templates/retail/agent.test.ts +236 -172
  146. package/dist/templates/retail/agent.ts +5 -42
  147. package/dist/templates/retail/client.tsx +33 -11
  148. package/dist/templates/retail/registry.test.ts +31 -16
  149. package/dist/templates/retail/resolve.ts +28 -64
  150. package/dist/templates/retail/shared.test.ts +4 -3
  151. package/dist/templates/retail/shared.ts +24 -7
  152. package/dist/templates/retail/store.test.ts +12 -10
  153. package/dist/templates/retail/store.ts +69 -30
  154. package/dist/templates/retail/tools/cancel_pending_order.ts +4 -9
  155. package/dist/templates/retail/tools/exchange_delivered_order_items.ts +22 -11
  156. package/dist/templates/retail/tools/find_user_id_by_email.ts +5 -9
  157. package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +5 -7
  158. package/dist/templates/retail/tools/get_item_details.ts +5 -7
  159. package/dist/templates/retail/tools/get_order_details.ts +5 -7
  160. package/dist/templates/retail/tools/get_product_details.ts +4 -7
  161. package/dist/templates/retail/tools/get_user_details.ts +4 -7
  162. package/dist/templates/retail/tools/list_all_product_types.ts +5 -7
  163. package/dist/templates/retail/tools/modify_pending_order_address.ts +4 -9
  164. package/dist/templates/retail/tools/modify_pending_order_items.ts +4 -16
  165. package/dist/templates/retail/tools/modify_pending_order_payment.ts +3 -9
  166. package/dist/templates/retail/tools/modify_user_address.ts +4 -9
  167. package/dist/templates/retail/tools/return_delivered_order_items.ts +3 -9
  168. package/dist/templates/retail/tools/transfer_to_human_agents.ts +1 -1
  169. package/dist/templates/solo-rpg/agent.test.ts +152 -29
  170. package/dist/templates/solo-rpg/agent.ts +3 -21
  171. package/dist/templates/solo-rpg/client.tsx +19 -6
  172. package/dist/templates/solo-rpg/shared.ts +19 -11
  173. package/dist/templates/solo-rpg/tools/action_roll.ts +2 -4
  174. package/dist/templates/solo-rpg/tools/burn_momentum.ts +2 -4
  175. package/dist/templates/solo-rpg/tools/check_state.ts +6 -6
  176. package/dist/templates/solo-rpg/tools/load_game.ts +1 -1
  177. package/dist/templates/solo-rpg/tools/oracle.ts +18 -11
  178. package/dist/templates/solo-rpg/tools/save_game.ts +10 -7
  179. package/dist/templates/solo-rpg/tools/setup_character.ts +1 -1
  180. package/dist/templates/solo-rpg/tools/update_state.ts +3 -4
  181. package/dist/templates/support-line/agent.test.ts +343 -0
  182. package/dist/templates/support-line/agent.ts +27 -0
  183. package/dist/templates/support-line/client.tsx +122 -0
  184. package/dist/templates/support-line/graph.ts +224 -0
  185. package/dist/templates/support-line/knowledge.json +65 -0
  186. package/dist/templates/support-line/prompts.ts +95 -0
  187. package/dist/templates/support-line/shared.ts +240 -0
  188. package/dist/templates/support-line/system-prompt.md +17 -0
  189. package/dist/templates/support-line/tools/answer_question.ts +72 -0
  190. package/dist/templates/support-line/tools/list_topics.ts +22 -0
  191. package/dist/templates/support-line/tools/log_ticket.ts +30 -0
  192. package/dist/templates/transcription-workflow/agent.test.ts +563 -0
  193. package/dist/templates/transcription-workflow/agent.ts +92 -0
  194. package/dist/templates/transcription-workflow/client.tsx +234 -0
  195. package/dist/templates/transcription-workflow/workflows/transcribe.ts +416 -0
  196. package/dist/templates/transcription-workflow/workflows/wav.ts +314 -0
  197. package/dist/templates/travel-concierge/agent.test.ts +338 -0
  198. package/dist/templates/travel-concierge/agent.ts +29 -0
  199. package/dist/templates/travel-concierge/client.tsx +161 -0
  200. package/dist/templates/travel-concierge/routing.ts +138 -0
  201. package/dist/templates/travel-concierge/shared.ts +547 -0
  202. package/dist/templates/travel-concierge/system-prompt.md +24 -0
  203. package/dist/templates/travel-concierge/tools/book_car_rental.ts +20 -0
  204. package/dist/templates/travel-concierge/tools/book_excursion.ts +18 -0
  205. package/dist/templates/travel-concierge/tools/book_hotel.ts +20 -0
  206. package/dist/templates/travel-concierge/tools/cancel_action.ts +7 -0
  207. package/dist/templates/travel-concierge/tools/cancel_ticket.ts +16 -0
  208. package/dist/templates/travel-concierge/tools/complete_or_escalate.ts +7 -0
  209. package/dist/templates/travel-concierge/tools/confirm_action.ts +7 -0
  210. package/dist/templates/travel-concierge/tools/lookup_booking.ts +35 -0
  211. package/dist/templates/travel-concierge/tools/search_car_rentals.ts +33 -0
  212. package/dist/templates/travel-concierge/tools/search_excursions.ts +37 -0
  213. package/dist/templates/travel-concierge/tools/search_flights.ts +43 -0
  214. package/dist/templates/travel-concierge/tools/search_hotels.ts +36 -0
  215. package/dist/templates/travel-concierge/tools/to_car_rental_assistant.ts +11 -0
  216. package/dist/templates/travel-concierge/tools/to_excursion_assistant.ts +11 -0
  217. package/dist/templates/travel-concierge/tools/to_flight_assistant.ts +11 -0
  218. package/dist/templates/travel-concierge/tools/to_hotel_assistant.ts +11 -0
  219. package/dist/templates/travel-concierge/tools/update_ticket.ts +22 -0
  220. package/dist/templates/web-researcher/agent.ts +0 -2
  221. package/dist/templates/web-researcher/system-prompt.md +10 -0
  222. package/dist/{test-2LWBcXvY.mjs → test-B6ZBxSpk.mjs} +2 -2
  223. package/dist/typecheck.mjs +17 -15
  224. package/dist/worker-bundler-CIdDhZ0c.mjs +398 -0
  225. package/dist/worker-bundler.d.ts +14 -0
  226. package/dist/worker-bundler.mjs +1 -108
  227. package/dist/workflow-B-O97AqI.mjs +125 -0
  228. package/dist/workflow-bundler.d.ts +133 -0
  229. package/dist/workflow.d.ts +67 -0
  230. package/package.json +13 -9
  231. package/dist/build-DQJ1ZSm9.mjs +0 -39
@@ -0,0 +1,525 @@
1
+ // Copyright 2026 the AAI authors. MIT license.
2
+ /// <reference types="vite/client" />
3
+
4
+ /**
5
+ * Specs for the research desk's four tools.
6
+ *
7
+ * All are exercised against a STUBBED `ctx.workflows`, which is the only honest
8
+ * way to unit-test them: the real client needs a Workflow DevKit world, and the
9
+ * bodies in `workflows/` are only durable once the build has transformed them.
10
+ * What these assert is the agent's half of the contract — that the handoff tool
11
+ * passes the correlation key, that the status tool narrows a snapshot correctly
12
+ * before reading it aloud, and that the two tools reaching PAST a status (the
13
+ * progress stream, the early wake) ask for what a voice reply can use.
14
+ *
15
+ * The STEPS are exercised separately, and directly: imported through vitest with
16
+ * no bundler in the path, a `"use step"` function is an ordinary async function,
17
+ * so its prompt handling, its parsing and its `FatalError` guards are all
18
+ * testable — while durability, suspension and replay are not. The body itself is
19
+ * not driven here for that reason; `aai-cli`'s `dev-workflow.scenario.test.ts`
20
+ * builds a project and runs one.
21
+ */
22
+
23
+ import type { ToolContext, WorkflowClient, WorkflowRunSnapshot } from "@alexkroman1/aai";
24
+ import {
25
+ createProgressStream,
26
+ createRunSnapshot,
27
+ createStubWorkflows,
28
+ createToolContext,
29
+ runTool,
30
+ type StubGatewayCall,
31
+ withDiscoveredTools,
32
+ } from "@alexkroman1/aai/testing";
33
+ import { installStubGateway as stubGateway } from "@alexkroman1/aai/testing/vitest";
34
+ import { visitWebpage, webSearch } from "@alexkroman1/aai/tools";
35
+ import { beforeEach, describe, expect, test, vi } from "vitest";
36
+ import { FatalError, RetryableError } from "workflow";
37
+ import authoredAgent from "./agent.ts";
38
+ import { research } from "./shared.ts";
39
+ import {
40
+ countSources,
41
+ dedupe,
42
+ findGaps,
43
+ investigate,
44
+ planAngles,
45
+ writeBrief,
46
+ writeReport,
47
+ } from "./workflows/research.ts";
48
+
49
+ /**
50
+ * The web, faked at the SDK's own seam.
51
+ *
52
+ * `webSearch` and `visitWebpage` screen a URL and then really fetch it, through
53
+ * an undici dispatcher a `globalThis.fetch` stub cannot reach — so mocking the
54
+ * module is the only honest way to keep this suite offline. What is asserted is
55
+ * that the researcher CALLS them with what the model asked for; the builtins'
56
+ * own behaviour is `aai`'s to test, and it does.
57
+ */
58
+ vi.mock("@alexkroman1/aai/tools", () => ({
59
+ webSearch: vi.fn(async () => ({
60
+ results: [{ title: "Otters", url: "https://otters.example/tools" }],
61
+ })),
62
+ visitWebpage: vi.fn(async () => ({ content: "The page body." })),
63
+ }));
64
+
65
+ /**
66
+ * The def a DEPLOYED agent runs: authored, plus what `tools/` declares.
67
+ *
68
+ * The glob is written HERE rather than reached for from a shared helper because
69
+ * this file SHIPS: it is what a scaffolded project runs, so it may not import
70
+ * anything outside its own template, and `import.meta.glob` is expanded against
71
+ * the file containing it either way. This is the pattern a user writes.
72
+ */
73
+ const agentDef = withDiscoveredTools(
74
+ authoredAgent,
75
+ import.meta.glob("./tools/*.ts", { eager: true }),
76
+ );
77
+
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);
81
+
82
+ /**
83
+ * A `ctx.workflows` that records `start` and answers `find` from a fixture.
84
+ *
85
+ * Returned WITHOUT a cast, which is the property worth keeping: a cast would
86
+ * 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.
90
+ */
91
+ 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
+ });
109
+ }
110
+
111
+ describe("the agent declares its workflow", () => {
112
+ test("under the name ctx.workflows.start resolves it by", () => {
113
+ expect(Object.keys(agentDef.workflows ?? {})).toEqual(["research"]);
114
+ expect(agentDef.workflows?.research).toBe(research);
115
+ });
116
+
117
+ 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();
125
+ });
126
+ });
127
+
128
+ /**
129
+ * The options `request_research` starts its run with.
130
+ *
131
+ * AWAITED rather than `void`-ed. Reading a mock's call list off a floating
132
+ * promise worked only because the tool body happens to reach `start` before its
133
+ * first `await`; anything async landing ahead of that would have made this
134
+ * return `undefined` and the reader throw a `TypeError` instead of failing on
135
+ * the option it is about — and the dropped promise is an unhandled rejection
136
+ * either way.
137
+ *
138
+ * The return type is inferred from the mock, so `notify` arrives typed and the
139
+ * caller needs no cast.
140
+ */
141
+ async function workflowsStartOptions() {
142
+ const workflows = stubWorkflows();
143
+ const ctx = createToolContext({ workflows });
144
+ await run("request_research", { topic: "otters" }, ctx);
145
+ return vi.mocked(workflows.start).mock.calls[0]?.[2];
146
+ }
147
+
148
+ describe("request_research", () => {
149
+ test("starts a run keyed by the session, so a later turn can find it", async () => {
150
+ const workflows = stubWorkflows();
151
+ const ctx = createToolContext({ workflows });
152
+ const result = await run("request_research", { topic: "otters" }, ctx);
153
+
154
+ expect(workflows.start).toHaveBeenCalledWith(
155
+ research,
156
+ { topic: "otters", requestedBy: ctx.sessionId },
157
+ // `key` is the DURABLE handle — a later call finds the run by it — and
158
+ // `notify` is the live one: this session is told when the run lands, which
159
+ // is what makes the agent's "I'll let you know" true.
160
+ { key: ctx.sessionId, notify: expect.stringContaining("read the summary") },
161
+ );
162
+ expect(result).toMatchObject({ started: true, runId: "wrun_stub", topic: "otters" });
163
+ });
164
+
165
+ test("asks to be TOLD when the run lands, rather than waiting to be asked", async () => {
166
+ // The gap this closes: the agent promised an update, the run finished, and
167
+ // nothing made it speak — so the caller had to think to ask again. A voice
168
+ // agent that starts durable work and never announces it is the shape to
169
+ // avoid, and one option is the whole fix.
170
+ const options = await workflowsStartOptions();
171
+ expect(options, "request_research started no run").toBeDefined();
172
+ expect(typeof options?.notify).toBe("string");
173
+ });
174
+
175
+ test("passes the definition rather than its name", async () => {
176
+ const workflows = stubWorkflows();
177
+ await run("request_research", { topic: "otters" }, createToolContext({ workflows }));
178
+ // The def overload is what types the input and turns a rename into a compile
179
+ // error; a string would still work at runtime and lose both.
180
+ expect(vi.mocked(workflows.start).mock.calls[0]?.[0]).toBe(research);
181
+ });
182
+ });
183
+
184
+ describe("research_status", () => {
185
+ test("says nothing was started when the key has no runs", async () => {
186
+ const ctx = createToolContext({ workflows: stubWorkflows([]) });
187
+ const result = await run("research_status", {}, ctx);
188
+ expect(result).toMatchObject({ runs: [], note: "Nothing started yet." });
189
+ });
190
+
191
+ test("reads a completed run's summary and source count back", async () => {
192
+ const runs = [
193
+ createRunSnapshot({
194
+ workflow: "research",
195
+ status: "completed",
196
+ output: { topic: "otters", summary: "Otters use tools.", sources: 3, filedAt: "now" },
197
+ }),
198
+ ];
199
+ const ctx = createToolContext({ workflows: stubWorkflows(runs) });
200
+ const result = (await run("research_status", {}, ctx)) as { runs: string[] };
201
+ expect(result.runs[0]).toContain("Otters use tools.");
202
+ expect(result.runs[0]).toContain("3 sources");
203
+ });
204
+
205
+ test("reports a live run as still working rather than as empty", async () => {
206
+ const ctx = createToolContext({
207
+ workflows: stubWorkflows([createRunSnapshot({ workflow: "research", status: "running" })]),
208
+ });
209
+ const result = (await run("research_status", {}, ctx)) as { runs: string[] };
210
+ expect(result.runs[0]).toContain("Still working on it.");
211
+ });
212
+
213
+ test("surfaces a failed run's message instead of swallowing it", async () => {
214
+ const runs = [
215
+ createRunSnapshot({ workflow: "research", status: "failed", error: "model unavailable" }),
216
+ ];
217
+ const ctx = createToolContext({ workflows: stubWorkflows(runs) });
218
+ const result = (await run("research_status", {}, ctx)) as { runs: string[] };
219
+ expect(result.runs[0]).toContain("model unavailable");
220
+ });
221
+
222
+ test("bounds how many past runs it reads aloud", async () => {
223
+ const workflows = stubWorkflows([]);
224
+ const ctx = createToolContext({ workflows });
225
+ await run("research_status", {}, ctx);
226
+ // A voice reply cannot be a list of twenty runs.
227
+ expect(workflows.find).toHaveBeenCalledWith(research, ctx.sessionId, { limit: 3 });
228
+ });
229
+ });
230
+
231
+ describe("research_progress", () => {
232
+ test("reads the run's own progress line rather than its status", async () => {
233
+ const workflows = stubWorkflows([
234
+ createRunSnapshot({ workflow: "research", status: "running" }),
235
+ ]);
236
+ vi.mocked(workflows.stream).mockResolvedValue(createProgressStream(["Found 3 sources."]));
237
+ const ctx = createToolContext({ workflows });
238
+ const result = await run("research_progress", {}, ctx);
239
+ expect(result).toMatchObject({ progress: "Found 3 sources." });
240
+ });
241
+
242
+ test("asks for the LAST line, not the whole log", async () => {
243
+ // A voice reply cannot recite every line the run has written.
244
+ const workflows = stubWorkflows([
245
+ createRunSnapshot({ workflow: "research", status: "running" }),
246
+ ]);
247
+ vi.mocked(workflows.stream).mockResolvedValue(createProgressStream(["a"]));
248
+ await run("research_progress", {}, createToolContext({ workflows }));
249
+ expect(workflows.stream).toHaveBeenCalledWith("wrun_1", { startIndex: -1 });
250
+ });
251
+
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.
256
+ const workflows = stubWorkflows([
257
+ createRunSnapshot({ workflow: "research", status: "running" }),
258
+ ]);
259
+ vi.mocked(workflows.streamTail).mockResolvedValue(-1);
260
+ const result = await run("research_progress", {}, createToolContext({ workflows }));
261
+ expect(result).toMatchObject({ note: "Started, nothing to report yet." });
262
+ expect(workflows.stream).not.toHaveBeenCalled();
263
+ });
264
+
265
+ test("says nothing was started when the key has no runs", async () => {
266
+ const workflows = stubWorkflows([]);
267
+ const result = await run("research_progress", {}, createToolContext({ workflows }));
268
+ expect(result).toMatchObject({ note: "Nothing started yet." });
269
+ expect(workflows.stream).not.toHaveBeenCalled();
270
+ });
271
+ });
272
+
273
+ describe("file_it_now", () => {
274
+ test("wakes the sleeping run so the review wait ends early", async () => {
275
+ const workflows = stubWorkflows([
276
+ createRunSnapshot({ workflow: "research", status: "running" }),
277
+ ]);
278
+ vi.mocked(workflows.wakeUp).mockResolvedValue(1);
279
+ const result = await run("file_it_now", {}, createToolContext({ workflows }));
280
+ expect(workflows.wakeUp).toHaveBeenCalledWith("wrun_1");
281
+ expect(result).toMatchObject({ filed: true });
282
+ });
283
+
284
+ test("a run that was not waiting is reported honestly, not as a failure", async () => {
285
+ // `wakeUp` answering 0 means the run had already moved past its sleep — the
286
+ // same shape as `cancel` answering false.
287
+ const workflows = stubWorkflows([
288
+ createRunSnapshot({ workflow: "research", status: "running" }),
289
+ ]);
290
+ vi.mocked(workflows.wakeUp).mockResolvedValue(0);
291
+ const result = await run("file_it_now", {}, createToolContext({ workflows }));
292
+ expect(result).toMatchObject({ filed: false });
293
+ });
294
+
295
+ test("says nothing was started when the key has no runs", async () => {
296
+ const workflows = stubWorkflows([]);
297
+ const result = await run("file_it_now", {}, createToolContext({ workflows }));
298
+ expect(result).toMatchObject({ note: "Nothing started yet." });
299
+ expect(workflows.wakeUp).not.toHaveBeenCalled();
300
+ });
301
+ });
302
+
303
+ describe("the pure helpers", () => {
304
+ test("dedupe keeps the first occurrence of each URL", () => {
305
+ const sources = [
306
+ { title: "One", url: "https://a.example" },
307
+ { title: "One again", url: "https://a.example" },
308
+ { title: "Two", url: "https://b.example" },
309
+ ];
310
+ expect(dedupe(sources)).toEqual([sources[0], sources[2]]);
311
+ });
312
+
313
+ test("countSources counts DISTINCT sources across every angle", () => {
314
+ // What the voice agent quotes. Two researchers finding the same page is one
315
+ // source, and reporting two would overstate the research.
316
+ const shared = { title: "Shared", url: "https://a.example" };
317
+ expect(
318
+ countSources([
319
+ { angle: "one", findings: "…", sources: [shared, { title: "B", url: "https://b" }] },
320
+ { angle: "two", findings: "…", sources: [shared] },
321
+ ]),
322
+ ).toBe(2);
323
+ });
324
+ });
325
+
326
+ describe("the steps that research", () => {
327
+ beforeEach(() => {
328
+ // `stepEnv` falls back to the process env when no host has published one,
329
+ // which is exactly the case a spec is. `unstubEnvs` clears it per test.
330
+ vi.stubEnv("ASSEMBLYAI_API_KEY", "sk-test");
331
+ });
332
+
333
+ /**
334
+ * The SDK's fake gateway, installed.
335
+ *
336
+ * The fake itself is `@alexkroman1/aai/testing`'s — it answers a QUEUE of
337
+ * completions, repeating the last, which is what a spec needs for a loop that
338
+ * is a CONVERSATION (search, then read, then stop) rather than one call. What
339
+ * stays here is the INSTALLATION, because the lifetime of a global stub is
340
+ * vitest's business and the SDK helper deliberately carries no test-runner
341
+ * dependency.
342
+ */
343
+
344
+ /** The prompt the Nth model call carried. */
345
+ function promptOf(calls: readonly StubGatewayCall[], at: number): string {
346
+ return calls[at]?.prompt ?? "";
347
+ }
348
+
349
+ const brief = { brief: "How otters use tools", criteria: ["Which species", "How it is learned"] };
350
+
351
+ test("writeBrief turns a spoken request into a brief and its criteria", async () => {
352
+ const calls = stubGateway([
353
+ JSON.stringify({ brief: "How otters use tools", criteria: ["Which species"] }),
354
+ ]);
355
+ expect(await writeBrief("otters")).toEqual({
356
+ brief: "How otters use tools",
357
+ criteria: ["Which species"],
358
+ });
359
+ expect(promptOf(calls, 0)).toContain("otters");
360
+ });
361
+
362
+ test("writeBrief falls back to the topic rather than filing an empty brief", async () => {
363
+ // The caller said something; a model that returns no brief must not erase it.
364
+ stubGateway([JSON.stringify({ criteria: [] })]);
365
+ expect(await writeBrief("otters")).toEqual({ brief: "otters", criteria: [] });
366
+ });
367
+
368
+ test("planAngles asks the model for the fan-out's width", async () => {
369
+ const calls = stubGateway([JSON.stringify({ angles: ["Tool use", "Which species"] })]);
370
+ expect(await planAngles(brief)).toEqual(["Tool use", "Which species"]);
371
+ // The angles are measured against the brief, so the criteria travel with it.
372
+ expect(promptOf(calls, 0)).toContain("Which species");
373
+ });
374
+
375
+ test("planAngles researches the brief itself when no angles come back", async () => {
376
+ // Nothing to fan out over is a plan failure, not an empty result — and the
377
+ // brief is the one angle that is always available.
378
+ stubGateway([JSON.stringify({ angles: [] })]);
379
+ expect(await planAngles(brief)).toEqual([brief.brief]);
380
+ });
381
+
382
+ test("investigate stops when the model says so, without inventing findings", async () => {
383
+ const calls = stubGateway([JSON.stringify({ action: "stop", why: "nothing to add" })]);
384
+ expect(await investigate(brief, "Tool use")).toEqual({
385
+ angle: "Tool use",
386
+ findings: "Nothing was found on this angle.",
387
+ sources: [],
388
+ });
389
+ // One call: it stopped, so there was nothing to compress.
390
+ expect(calls).toHaveLength(1);
391
+ });
392
+
393
+ test("investigate searches, reads, and compresses what it saw", async () => {
394
+ const calls = stubGateway([
395
+ JSON.stringify({ action: "search", query: "otter tool use" }),
396
+ JSON.stringify({ action: "read", url: "https://otters.example/tools" }),
397
+ JSON.stringify({ action: "stop", why: "enough" }),
398
+ JSON.stringify({
399
+ findings: "Sea otters crack shellfish with stones [1].",
400
+ sources: [{ title: "Otters", url: "https://otters.example/tools" }],
401
+ }),
402
+ ]);
403
+
404
+ const note = await investigate(brief, "Tool use");
405
+
406
+ expect(webSearch).toHaveBeenCalledWith({ query: "otter tool use", max_results: 5 });
407
+ expect(visitWebpage).toHaveBeenCalledWith("https://otters.example/tools");
408
+ expect(note.findings).toContain("crack shellfish");
409
+ expect(note.sources).toEqual([{ title: "Otters", url: "https://otters.example/tools" }]);
410
+ // Everything the researcher saw reaches the compression stage, which is what
411
+ // keeps the journaled result small without summarizing the findings away.
412
+ expect(promptOf(calls, 3)).toContain("The page body.");
413
+ });
414
+
415
+ test("investigate stops at its BUDGET, whatever the model asks for", async () => {
416
+ // The budget is the mechanism, not the prompt: a run whose cost is decided
417
+ // by a model is a run nobody can price.
418
+ const calls = stubGateway([JSON.stringify({ action: "search", query: "again" })]);
419
+ await investigate(brief, "Tool use");
420
+ // Six actions, then one compression call.
421
+ expect(calls).toHaveLength(7);
422
+ });
423
+
424
+ test("a failed search costs an action rather than the whole angle", async () => {
425
+ vi.mocked(webSearch).mockRejectedValueOnce(new Error("search is down"));
426
+ const calls = stubGateway([
427
+ JSON.stringify({ action: "search", query: "otters" }),
428
+ JSON.stringify({ action: "stop", why: "give up" }),
429
+ JSON.stringify({ findings: "Nothing usable.", sources: [] }),
430
+ ]);
431
+ const note = await investigate(brief, "Tool use");
432
+ expect(note.findings).toBe("Nothing usable.");
433
+ expect(promptOf(calls, 2)).toContain("search is down");
434
+ });
435
+
436
+ test("investigate retries beyond the default, because a rate limit is expected", () => {
437
+ expect(investigate.maxRetries).toBeGreaterThan(3);
438
+ });
439
+
440
+ test("a rate limit is RETRYABLE, so the DevKit tries again", async () => {
441
+ // The message alone cannot say this — a 429 and a 401 read alike — so what
442
+ // is asserted is the class the DevKit actually branches on.
443
+ stubGateway([""], { status: 429 });
444
+ const err = await investigate(brief, "Tool use").catch((thrown: unknown) => thrown);
445
+ expect(RetryableError.is(err)).toBe(true);
446
+ expect((err as Error).message).toMatch(/HTTP 429/);
447
+ });
448
+
449
+ test("a rejected request is FATAL rather than retried five times", async () => {
450
+ stubGateway([""], { status: 401 });
451
+ const err = await investigate(brief, "Tool use").catch((thrown: unknown) => thrown);
452
+ expect(FatalError.is(err)).toBe(true);
453
+ expect((err as Error).message).toMatch(/HTTP 401/);
454
+ });
455
+
456
+ test("a missing key is FATAL, naming the key", async () => {
457
+ vi.stubEnv("ASSEMBLYAI_API_KEY", "");
458
+ stubGateway(["anything"]);
459
+ const err = await investigate(brief, "Tool use").catch((thrown: unknown) => thrown);
460
+ expect(FatalError.is(err)).toBe(true);
461
+ expect((err as Error).message).toMatch(/ASSEMBLYAI_API_KEY/);
462
+ });
463
+
464
+ test("a malformed `sources` falls back to what the researcher was shown", async () => {
465
+ // `.catch(undefined)` on that field rather than a bare `.optional()`: the
466
+ // findings are already compressed by this point, and throwing them away to
467
+ // research the angle again is the expensive way to handle one bad field.
468
+ const calls = stubGateway([
469
+ JSON.stringify({ action: "search", query: "otters" }),
470
+ JSON.stringify({ action: "stop" }),
471
+ JSON.stringify({ findings: "Otters use stones.", sources: "not a list" }),
472
+ ]);
473
+ const note = await investigate(brief, "Tool use");
474
+
475
+ expect(note.findings).toBe("Otters use stones.");
476
+ expect(note.sources).toEqual([{ title: "Otters", url: "https://otters.example/tools" }]);
477
+ // Three calls, not four: the reply was USED, not retried.
478
+ expect(calls).toHaveLength(3);
479
+ });
480
+
481
+ test("a reply that is not JSON throws plainly, because a retry may well obey", async () => {
482
+ stubGateway(["I would rather write you an essay."]);
483
+ await expect(writeBrief("otters")).rejects.toThrow(/Expected JSON/);
484
+ });
485
+
486
+ test("findGaps asks nothing when the first wave found nothing", async () => {
487
+ const calls = stubGateway([JSON.stringify({ angles: ["anything"] })]);
488
+ expect(await findGaps(brief, [])).toEqual([]);
489
+ expect(calls).toHaveLength(0);
490
+ });
491
+
492
+ test("findGaps names what is still unanswered against the criteria", async () => {
493
+ const calls = stubGateway([JSON.stringify({ angles: ["How it is learned"] })]);
494
+ const gaps = await findGaps(brief, [
495
+ { angle: "Tool use", findings: "They use stones.", sources: [] },
496
+ ]);
497
+ expect(gaps).toEqual(["How it is learned"]);
498
+ expect(promptOf(calls, 0)).toContain("They use stones.");
499
+ });
500
+
501
+ test("writeReport writes the report AND the sentence a phone can carry", async () => {
502
+ // Two model calls in ONE step, because they are one decision: a resume must
503
+ // never pair a new summary with an old report.
504
+ const calls = stubGateway(["# Otters\n\nThey use stones [1].", "Otters use stones as tools."]);
505
+ const written = await writeReport("otters", brief, [
506
+ { angle: "Tool use", findings: "They use stones.", sources: [] },
507
+ ]);
508
+
509
+ expect(written.report).toContain("# Otters");
510
+ expect(written.summary).toBe("Otters use stones as tools.");
511
+ expect(calls).toHaveLength(2);
512
+ // Nothing researched is dropped on the way in.
513
+ expect(promptOf(calls, 0)).toContain("They use stones.");
514
+ // …and the summary is a reduction OF the report, not a second pass at the
515
+ // findings — which is what keeps it consistent with what a page renders.
516
+ expect(promptOf(calls, 1)).toContain("# Otters");
517
+ });
518
+
519
+ test("an empty completion throws rather than filing a blank report", async () => {
520
+ stubGateway([""]);
521
+ await expect(
522
+ writeReport("otters", brief, [{ angle: "a", findings: "b", sources: [] }]),
523
+ ).rejects.toThrow(/empty completion/);
524
+ });
525
+ });
@@ -0,0 +1,94 @@
1
+ // Copyright 2026 the AAI authors. MIT license.
2
+ /**
3
+ * A voice agent that hands work off to a durable workflow — the worked example
4
+ * for `agent({ workflows })`.
5
+ *
6
+ * This is the HANDOFF shape: a caller is on the line, so a tool starts a run and
7
+ * answers the turn. When the workflow IS the product — a form rather than a
8
+ * call — the agent is declared with `workflowApp()` instead and has no session
9
+ * at all; `link-digest` is that one, at its smallest.
10
+ *
11
+ * The whole point is the thing a voice agent cannot otherwise do: **answer the
12
+ * caller now, finish the work later.** Research takes minutes; the caller is on
13
+ * the line. So `request_research` starts a run and returns in the same turn, the
14
+ * run outlives the call, and a LATER call reads the result back.
15
+ *
16
+ * ## The four tools are four files
17
+ *
18
+ * `tools/` is the tool list — a file there IS a tool, named by its own filename —
19
+ * so this module declares the agent and the workflow it hands off to, and nothing
20
+ * about tools. The declaration they all share lives in `shared.ts`, because a
21
+ * tool starts a run by passing the DEFINITION rather than its name.
22
+ *
23
+ * ## And it SAYS SO when the work lands
24
+ *
25
+ * `start(…, { notify })` is what closes the loop that used to be open: the agent
26
+ * promised an update, the run finished, and nothing made it speak — the caller
27
+ * had to think to ask again. With it, a finished run takes an unprompted,
28
+ * interruptible turn on this session, built from the run's own output.
29
+ *
30
+ * Two limits worth knowing, both by construction. It reaches the session that
31
+ * STARTED the run, only while that session is alive — an announcement into a
32
+ * call that has ended is nobody's — and it needs a transport that can take an
33
+ * unprompted turn, which pipeline mode can and S2S cannot. That is why `key`
34
+ * stays: the next call still finds the run.
35
+ *
36
+ * ## The correlation key is what makes the second call possible
37
+ *
38
+ * `start()` hands back a `runId`, and the obvious place for a tool to keep it is
39
+ * a `sessionSlot` — which is swept shortly after the caller hangs up. So the
40
+ * run outlives the session and the only handle to it does not. Passing
41
+ * `{ key: ctx.sessionId }` puts the run in an index the agent can search later
42
+ * with `find`, without maintaining its own table.
43
+ *
44
+ * `ctx.sessionId` keys THIS call. A real desk would key on something that
45
+ * survives across calls — the caller's phone number, an account id — so
46
+ * "what happened to my research?" works from a different session. The mechanism
47
+ * is identical; only the key changes.
48
+ *
49
+ * ## What is NOT here
50
+ *
51
+ * No `ctx.step`, no `ctx.waitFor`. Steps are `"use step"` functions in
52
+ * `workflows/research.ts` and waitpoints are the Workflow DevKit's own
53
+ * `defineHook()`. The SDK's job is declaring the workflow and starting runs; the
54
+ * durable execution belongs to `workflow`.
55
+ *
56
+ * ## The research is real, and it really searches the web
57
+ *
58
+ * `workflows/research.ts` is a deep-research pass, not three model calls in a
59
+ * row: it writes a brief, plans the angles worth pursuing, gives each angle its
60
+ * own researcher step that SEARCHES and READS until its budget runs out, asks
61
+ * what is still unanswered, and only then writes the report. The search and the
62
+ * page reads go through `webSearch`/`visitWebpage` from `@alexkroman1/aai/tools`
63
+ * — the same implementations behind the model-facing builtins, which is the
64
+ * point: a step is not a lesser environment than a tool body.
65
+ *
66
+ * The model calls go through the same `ASSEMBLYAI_API_KEY` this agent's voice
67
+ * pipeline uses. A step is handed no `ToolContext`, so it reads that key with
68
+ * `requireStepEnv` rather than `ctx.env`; see that file's module doc for the one
69
+ * thing that changes under `aai dev` (the key has to be in `.env`, not just your
70
+ * shell).
71
+ *
72
+ * Requires storage (`aai storage enable`, or `DATABASE_URL` under `aai dev`) —
73
+ * runs and the key index both live there.
74
+ */
75
+
76
+ import { agent } from "@alexkroman1/aai";
77
+ import { research } from "./shared.ts";
78
+
79
+ export default agent({
80
+ name: "Research Desk",
81
+ greeting: "Research desk. What would you like me to look into?",
82
+ systemPrompt: [
83
+ "You take research requests over the phone and read back results.",
84
+ "When someone asks you to research something, call request_research and tell them",
85
+ "you have started it — do NOT wait for it or promise a time. You WILL be told",
86
+ "when it lands, so it is safe to say you will let them know.",
87
+ "When someone asks about earlier work, call research_status.",
88
+ "If they ask what is happening right now, call research_progress.",
89
+ "If they say they need it immediately, call file_it_now.",
90
+ "Keep replies to one or two sentences; this is a voice call.",
91
+ ].join(" "),
92
+
93
+ workflows: { research },
94
+ });
@@ -0,0 +1,35 @@
1
+ // Copyright 2026 the AAI authors. MIT license.
2
+ /**
3
+ * The workflow DECLARATION, in a module both `agent.ts` and every tool can
4
+ * import.
5
+ *
6
+ * It lives here rather than in `agent.ts` because all four tools name it —
7
+ * `ctx.workflows.start(research, …)` takes the definition itself rather than its
8
+ * name, which is what types the input and makes a typo a compile error instead of
9
+ * a rejected promise the model reads as a tool failure. A tool is its own file,
10
+ * so "both halves import the declaration" needs the declaration to have a home
11
+ * that is neither half.
12
+ *
13
+ * The `"use workflow"` BODY stays in `workflows/research.ts`: the Workflow DevKit
14
+ * builder scans that directory and rewrites what it finds, and a body written
15
+ * anywhere else runs inline once with no durability and nothing saying so.
16
+ */
17
+
18
+ import { workflow } from "@alexkroman1/aai";
19
+ import { z } from "zod";
20
+ import { researchFlow } from "./workflows/research.ts";
21
+
22
+ /**
23
+ * The declaration: schema, description, and the directive body.
24
+ *
25
+ * Exported so a client page could derive its output type with `WorkflowOutputOf`.
26
+ */
27
+ export const research = workflow({
28
+ description:
29
+ "Research a topic properly — brief, angles, web search per angle, a gap pass, then a written report",
30
+ input: z.object({
31
+ topic: z.string().min(3).describe("What to research"),
32
+ requestedBy: z.string().describe("Who asked — used when filing the result"),
33
+ }),
34
+ run: researchFlow,
35
+ });
@@ -0,0 +1,22 @@
1
+ import { tool } from "@alexkroman1/aai";
2
+ import { research } from "../shared.ts";
3
+
4
+ export default tool({
5
+ description:
6
+ "Skip the review wait on the research and file it immediately. Use when the caller says they need it now.",
7
+ execute: async (_args, ctx) => {
8
+ const [latest] = await ctx.workflows.find(research, ctx.sessionId, { limit: 1 });
9
+ if (!latest) return { note: "Nothing started yet." };
10
+ // The counterpart of the `sleep` in `workflows/research.ts`. Without it
11
+ // the only handle on a sleeping run is `cancel`, so "send it now" and
12
+ // "throw it away" would be the same button — and the wait a real desk
13
+ // uses is hours, not the thirty seconds this template ships.
14
+ //
15
+ // `0` is an honest answer, not a failure: the run had already moved past
16
+ // its wait, or finished.
17
+ const woken = await ctx.workflows.wakeUp(latest.runId);
18
+ return woken > 0
19
+ ? { filed: true, note: "Filing it now." }
20
+ : { filed: false, note: "That one was not waiting — it has already moved on." };
21
+ },
22
+ });