@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,411 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ import type { GenerateFn, ToolContext } from "@alexkroman1/aai";
4
+ import {
5
+ createToolContext,
6
+ runTool,
7
+ stubGenerate,
8
+ withDiscoveredTools,
9
+ } from "@alexkroman1/aai/testing";
10
+ import { describe, expect, test } from "vitest";
11
+ import authoredAgent from "./agent.ts";
12
+
13
+ /**
14
+ * The def a DEPLOYED agent runs: authored, plus what `tools/` declares.
15
+ *
16
+ * The glob is written HERE rather than reached for from a shared helper because
17
+ * this file SHIPS: it is what a scaffolded project runs, so it may not import
18
+ * anything outside its own template, and `import.meta.glob` is expanded against
19
+ * the file containing it either way. This is the pattern a user writes.
20
+ */
21
+ const agentDef = withDiscoveredTools(
22
+ authoredAgent,
23
+ import.meta.glob("./tools/*.ts", { eager: true }),
24
+ );
25
+
26
+ import { executeStep, MAX_STEP_SEARCHES, normalizeAct, planNode } from "./graph.ts";
27
+ import { EXECUTOR_SYSTEM, PLANNER_SYSTEM, REPLANNER_SYSTEM, REVISE_SYSTEM } from "./prompts.ts";
28
+ import type { SearchFn } from "./shared.ts";
29
+ import { MAX_PAST_STEPS, planSlot, planView } from "./shared.ts";
30
+
31
+ // ─── A scripted model ────────────────────────────────────────────────────────
32
+ //
33
+ // Each node is one `ctx.generate` call carrying its own system prompt, so
34
+ // `stubGenerate` — whose script IS keyed by system prompt — drives the whole
35
+ // loop with no model and no network. `calls` is every call the fake took, which
36
+ // is how the "a failed search goes back to the model" assertion is made: it
37
+ // asserts on the PROMPT the next turn carried.
38
+
39
+ interface Script {
40
+ steps?: string[];
41
+ /** One entry per executor turn: search that query, or answer with that text. */
42
+ turns?: ({ search: string } | { answer: string })[];
43
+ /** One entry per replan/revise call. */
44
+ acts?: { kind: "respond" | "plan"; response?: string; steps?: string[] }[];
45
+ }
46
+
47
+ function scriptedModel(script: Script = {}) {
48
+ const turns = [...(script.turns ?? [{ answer: "Settled it." }])];
49
+ const acts = [...(script.acts ?? [])];
50
+ // The replanner and the reviser are the same node with a different brief, so
51
+ // they share one queue — which is what the "revise then carry on" test rests on.
52
+ const act = () => ({ object: acts.shift() ?? { kind: "respond", response: "All done." } });
53
+
54
+ return stubGenerate({
55
+ [PLANNER_SYSTEM]: { object: { steps: script.steps ?? ["Only step"] } },
56
+ [EXECUTOR_SYSTEM]: () => {
57
+ const turn = turns.shift() ?? { answer: "Settled it." };
58
+ return "search" in turn
59
+ ? { object: { action: "search", query: turn.search } }
60
+ : { object: { action: "answer", answer: turn.answer } };
61
+ },
62
+ [REPLANNER_SYSTEM]: act,
63
+ [REVISE_SYSTEM]: act,
64
+ });
65
+ }
66
+
67
+ /** A searcher that never touches the network. The tools use `liveSearch`, so
68
+ * every tool-level test below scripts the executor to answer without one. */
69
+ function fakeSearch(hits: Record<string, { title: string; url: string }[]>): {
70
+ search: SearchFn;
71
+ queries: string[];
72
+ } {
73
+ const queries: string[] = [];
74
+ const search: SearchFn = async (query) => {
75
+ queries.push(query);
76
+ const found = hits[query];
77
+ if (!found) throw new Error("search backend unavailable");
78
+ return found;
79
+ };
80
+ return { search, queries };
81
+ }
82
+
83
+ function makeCtx(generate: GenerateFn, sessionId?: string) {
84
+ return createToolContext({ generate, ...(sessionId ? { sessionId } : {}) });
85
+ }
86
+
87
+ /** A tool by the name the model calls it by, bound to this agent. The lookup
88
+ * and its "no such tool" message are `runTool`'s (`@alexkroman1/aai/testing`);
89
+ * what is local is only which agent they run against. */
90
+ const run = (name: string, args: Record<string, unknown>, ctx: ToolContext) =>
91
+ runTool(agentDef, name, args, ctx);
92
+
93
+ function stateOf(ctx: ToolContext) {
94
+ return planSlot.get(ctx);
95
+ }
96
+
97
+ // ─── 1. The nodes ────────────────────────────────────────────────────────────
98
+
99
+ describe("planNode", () => {
100
+ test("returns the steps the planner produced", async () => {
101
+ const { generate, calls } = scriptedModel({ steps: ["Check prices", "Book it"] });
102
+ expect(await planNode(generate, "get me to Lisbon in May")).toEqual([
103
+ "Check prices",
104
+ "Book it",
105
+ ]);
106
+ expect(calls[0]?.prompt).toContain("get me to Lisbon in May");
107
+ });
108
+ });
109
+
110
+ describe("executeStep", () => {
111
+ test("searches, reads the results, then answers — and reports what it searched", async () => {
112
+ const { generate, calls } = scriptedModel({
113
+ turns: [{ search: "lisbon flights may" }, { answer: "Flights are around 180 return." }],
114
+ });
115
+ const { search, queries } = fakeSearch({
116
+ "lisbon flights may": [{ title: "Fares to Lisbon", url: "https://example.test/fares" }],
117
+ });
118
+
119
+ const outcome = await executeStep(generate, search, "get to Lisbon", "Check prices", []);
120
+ expect(queries).toEqual(["lisbon flights may"]);
121
+ expect(outcome.result).toContain("180");
122
+ expect(outcome.searches).toEqual(["lisbon flights may"]);
123
+ // The results are what the second turn reasons over, not a note in a log.
124
+ expect(calls[1]?.prompt).toContain("https://example.test/fares");
125
+ });
126
+
127
+ test("a failed search goes back to the model rather than only to a log", async () => {
128
+ // Told nothing, the model reads silence as "no such pages exist" and burns
129
+ // the rest of its budget re-asking the same question.
130
+ const { generate, calls } = scriptedModel({
131
+ turns: [{ search: "unindexed thing" }, { answer: "Could not confirm that." }],
132
+ });
133
+ const { search } = fakeSearch({});
134
+
135
+ const outcome = await executeStep(generate, search, "objective", "Check the thing", []);
136
+ expect(calls[1]?.prompt).toContain("search backend unavailable");
137
+ expect(outcome.result).toBe("Could not confirm that.");
138
+ });
139
+
140
+ test("the search budget is a bound, not a suggestion", async () => {
141
+ const { generate, calls } = scriptedModel({
142
+ // Three searches asked for, two allowed.
143
+ turns: [{ search: "a" }, { search: "b" }, { search: "c" }],
144
+ });
145
+ const { search, queries } = fakeSearch({
146
+ a: [{ title: "A", url: "https://example.test/a" }],
147
+ b: [{ title: "B", url: "https://example.test/b" }],
148
+ c: [{ title: "C", url: "https://example.test/c" }],
149
+ });
150
+
151
+ const outcome = await executeStep(generate, search, "objective", "step", []);
152
+ expect(queries).toHaveLength(MAX_STEP_SEARCHES);
153
+ // The last turn is told the budget is gone, which is what turns a search
154
+ // loop into an answer.
155
+ expect(calls.at(-1)?.prompt).toContain("search budget");
156
+ expect(outcome.result).toBe("This step could not be settled within its budget.");
157
+ });
158
+ });
159
+
160
+ describe("normalizeAct", () => {
161
+ test("a plan with steps is a plan", () => {
162
+ expect(normalizeAct({ kind: "plan", steps: ["Do X"] }, "fallback")).toEqual({
163
+ kind: "plan",
164
+ steps: ["Do X"],
165
+ });
166
+ });
167
+
168
+ test("'respond' with no response falls back rather than looping", () => {
169
+ // The failure mode that matters on a phone call is a desk that never stops.
170
+ expect(normalizeAct({ kind: "respond" }, "the last thing we know")).toEqual({
171
+ kind: "respond",
172
+ response: "the last thing we know",
173
+ });
174
+ });
175
+
176
+ test("'plan' with no usable steps is read as an answer", () => {
177
+ expect(normalizeAct({ kind: "plan", steps: [" "] }, "nothing left")).toEqual({
178
+ kind: "respond",
179
+ response: "nothing left",
180
+ });
181
+ expect(normalizeAct({ kind: "plan", response: "here it is" }, "nothing left")).toEqual({
182
+ kind: "respond",
183
+ response: "here it is",
184
+ });
185
+ });
186
+ });
187
+
188
+ // ─── 2. The tools ────────────────────────────────────────────────────────────
189
+
190
+ describe("start_plan", () => {
191
+ test("stores the objective and the steps", async () => {
192
+ const { generate } = scriptedModel({ steps: ["Check prices", "Compare hotels", "Book"] });
193
+ const ctx = makeCtx(generate);
194
+ const result = (await run("start_plan", { objective: "a weekend in Lisbon" }, ctx)) as {
195
+ steps: string[];
196
+ };
197
+ expect(result.steps).toHaveLength(3);
198
+
199
+ const state = stateOf(ctx);
200
+ expect(state.objective).toBe("a weekend in Lisbon");
201
+ expect(state.plan).toEqual(["Check prices", "Compare hotels", "Book"]);
202
+ expect(state.pastSteps).toEqual([]);
203
+ expect(state.revisions[0]).toContain("Planned 3 step(s)");
204
+ });
205
+
206
+ test("a broken model call is reported rather than thrown at the turn", async () => {
207
+ // The default `createToolContext` generate rejects — a bad key looks the
208
+ // same from here.
209
+ const ctx = createToolContext({});
210
+ expect(await run("start_plan", { objective: "anything" }, ctx)).toMatchObject({
211
+ error: expect.stringContaining("planner failed"),
212
+ });
213
+ });
214
+ });
215
+
216
+ describe("work_next_step", () => {
217
+ test("refuses before there is a plan", async () => {
218
+ const { generate } = scriptedModel();
219
+ const ctx = makeCtx(generate);
220
+ expect(await run("work_next_step", {}, ctx)).toEqual({
221
+ error: "There is no plan yet — use start_plan first.",
222
+ });
223
+ });
224
+
225
+ test("does the head step, records it, and takes the replanner's next plan", async () => {
226
+ const { generate } = scriptedModel({
227
+ steps: ["Check prices", "Compare hotels"],
228
+ turns: [{ answer: "Fares are about 180 return." }],
229
+ acts: [{ kind: "plan", steps: ["Compare hotels"] }],
230
+ });
231
+ const ctx = makeCtx(generate);
232
+ await run("start_plan", { objective: "a weekend in Lisbon" }, ctx);
233
+
234
+ const first = (await run("work_next_step", {}, ctx)) as {
235
+ done: boolean;
236
+ step: string;
237
+ result: string;
238
+ remaining: string[];
239
+ };
240
+ expect(first.done).toBe(false);
241
+ expect(first.step).toBe("Check prices");
242
+ expect(first.result).toContain("180");
243
+ expect(first.remaining).toEqual(["Compare hotels"]);
244
+
245
+ const state = stateOf(ctx);
246
+ expect(state.pastSteps).toHaveLength(1);
247
+ expect(state.plan).toEqual(["Compare hotels"]);
248
+ expect(state.response).toBeNull();
249
+ });
250
+
251
+ test("a 'respond' act finishes the plan and clears what is left", async () => {
252
+ const { generate } = scriptedModel({
253
+ steps: ["Check prices", "Compare hotels"],
254
+ turns: [{ answer: "Fares are about 180 return." }],
255
+ acts: [{ kind: "respond", response: "Go in May — flights are about 180 return." }],
256
+ });
257
+ const ctx = makeCtx(generate);
258
+ await run("start_plan", { objective: "a weekend in Lisbon" }, ctx);
259
+ const result = (await run("work_next_step", {}, ctx)) as { done: boolean; response: string };
260
+
261
+ expect(result.done).toBe(true);
262
+ expect(result.response).toContain("180");
263
+ const state = stateOf(ctx);
264
+ // The replanner deciding early is a good outcome, so the pending step goes.
265
+ expect(state.plan).toEqual([]);
266
+ expect(state.response).toBe(result.response);
267
+
268
+ // A finished plan is not worked again.
269
+ expect(await run("work_next_step", {}, ctx)).toMatchObject({ done: true });
270
+ expect(stateOf(ctx).pastSteps).toHaveLength(1);
271
+ });
272
+
273
+ test("the completed-step trail is capped, so the executor's prompt cannot grow forever", async () => {
274
+ // `historyOf` renders every past step into the executor's prompt AND the
275
+ // replanner's, so an uncapped list is a model bill that grows linearly with
276
+ // the plan — the reason `recordStep` holds MAX_PAST_STEPS.
277
+ const total = MAX_PAST_STEPS + 3;
278
+ const { generate } = scriptedModel({
279
+ steps: Array.from({ length: total }, (_, i) => `Step ${i + 1}`),
280
+ turns: Array.from({ length: total }, (_, i) => ({ answer: `Found ${i + 1}.` })),
281
+ acts: Array.from({ length: total }, (_, i) => ({
282
+ kind: "plan" as const,
283
+ steps: Array.from({ length: total - i - 1 }, (_, j) => `Step ${i + j + 2}`),
284
+ })),
285
+ });
286
+ const ctx = makeCtx(generate);
287
+ await run("start_plan", { objective: "a long one" }, ctx);
288
+ for (let i = 0; i < total; i++) await run("work_next_step", {}, ctx);
289
+
290
+ const state = stateOf(ctx);
291
+ expect(state.pastSteps).toHaveLength(MAX_PAST_STEPS);
292
+ // The OLDEST go: the replanner decides from what was just found, and its
293
+ // fallback answer is the last entry.
294
+ expect(state.pastSteps[0]?.step).toBe(`Step ${total - MAX_PAST_STEPS + 1}`);
295
+ expect(state.pastSteps.at(-1)?.step).toBe(`Step ${total}`);
296
+ });
297
+
298
+ test("two independent contexts never share a plan", async () => {
299
+ // What this really checks: the state lives in the SLOT and not in a
300
+ // module-level variable. `createToolContext()` hands each call its own
301
+ // detached slot store, so the isolation is per CONTEXT — two distinct
302
+ // session ids would prove nothing extra, and `sessionSlot` could stop
303
+ // keying by session with this still passing.
304
+ const { generate } = scriptedModel({ steps: ["Only step"] });
305
+ const first = makeCtx(generate);
306
+ const second = makeCtx(generate);
307
+
308
+ await run("start_plan", { objective: "mine" }, first);
309
+ expect(stateOf(second).objective).toBeNull();
310
+ expect(await run("work_next_step", {}, second)).toMatchObject({
311
+ error: "There is no plan yet — use start_plan first.",
312
+ });
313
+ });
314
+ });
315
+
316
+ describe("revise_plan", () => {
317
+ test("rewrites what is left, keeps what is done, and reopens a finished plan", async () => {
318
+ const { generate, calls } = scriptedModel({
319
+ steps: ["Check Lisbon prices", "Book Lisbon"],
320
+ turns: [{ answer: "Lisbon is about 180 return." }],
321
+ acts: [
322
+ { kind: "respond", response: "Lisbon in May, about 180." },
323
+ { kind: "plan", steps: ["Check Porto prices"] },
324
+ ],
325
+ });
326
+ const ctx = makeCtx(generate);
327
+ await run("start_plan", { objective: "a weekend in Lisbon" }, ctx);
328
+ await run("work_next_step", {}, ctx);
329
+ expect(stateOf(ctx).response).not.toBeNull();
330
+
331
+ const revised = (await run("revise_plan", { instruction: "make it Porto instead" }, ctx)) as {
332
+ done: boolean;
333
+ remaining: string[];
334
+ };
335
+ expect(revised.done).toBe(false);
336
+ expect(revised.remaining).toEqual(["Check Porto prices"]);
337
+
338
+ const state = stateOf(ctx);
339
+ // The old answer is no longer the answer — the caller moved the goalposts.
340
+ expect(state.response).toBeNull();
341
+ // Completed work survives a revision; that is what "only add steps that
342
+ // still NEED to be done" means.
343
+ expect(state.pastSteps).toHaveLength(1);
344
+ expect(state.revisions.at(-1)).toContain("make it Porto instead");
345
+ // The caller's words reach the replanner, which is the whole node.
346
+ expect(calls.at(-1)?.prompt).toContain("make it Porto instead");
347
+ });
348
+
349
+ test("refuses before there is a plan", async () => {
350
+ const { generate } = scriptedModel();
351
+ const ctx = makeCtx(generate);
352
+ expect(await run("revise_plan", { instruction: "change it" }, ctx)).toEqual({
353
+ error: "There is no plan to revise — use start_plan first.",
354
+ });
355
+ });
356
+ });
357
+
358
+ describe("plan_status", () => {
359
+ test("reports done, remaining and the answer", async () => {
360
+ const { generate } = scriptedModel({
361
+ steps: ["Check prices", "Book"],
362
+ turns: [{ answer: "About 180 return." }],
363
+ acts: [{ kind: "plan", steps: ["Book"] }],
364
+ });
365
+ const ctx = makeCtx(generate);
366
+ expect(await run("plan_status", {}, ctx)).toEqual({
367
+ message: "No plan yet. Ask what they want to get done.",
368
+ });
369
+
370
+ await run("start_plan", { objective: "a weekend in Lisbon" }, ctx);
371
+ await run("work_next_step", {}, ctx);
372
+ expect(await run("plan_status", {}, ctx)).toMatchObject({
373
+ objective: "a weekend in Lisbon",
374
+ remaining: ["Book"],
375
+ response: null,
376
+ });
377
+ });
378
+ });
379
+
380
+ // ─── 3. The projection contract with client.tsx ─────────────────────────────
381
+
382
+ describe("planView projection", () => {
383
+ test("an untouched call projects an empty plan, not undefined", () => {
384
+ // Exactly the value client.tsx hoists as its fallback.
385
+ expect(planSlot.projection(planView)(undefined)).toEqual({
386
+ objective: null,
387
+ plan: [],
388
+ done: [],
389
+ response: null,
390
+ revisions: [],
391
+ progress: 0,
392
+ });
393
+ });
394
+
395
+ test("progress is derived once, so the bar and any spoken count agree", async () => {
396
+ const { generate } = scriptedModel({
397
+ steps: ["One", "Two", "Three"],
398
+ turns: [{ answer: "Done one." }],
399
+ acts: [{ kind: "plan", steps: ["Two", "Three"] }],
400
+ });
401
+ const ctx = makeCtx(generate);
402
+ await run("start_plan", { objective: "three things" }, ctx);
403
+ expect(planView(stateOf(ctx)).progress).toBe(0);
404
+
405
+ await run("work_next_step", {}, ctx);
406
+ const view = planView(stateOf(ctx));
407
+ expect(view.progress).toBeCloseTo(1 / 3, 5);
408
+ expect(view.done[0]?.step).toBe("One");
409
+ expect(view.plan).toEqual(["Two", "Three"]);
410
+ });
411
+ });
@@ -0,0 +1,23 @@
1
+ import { agent } from "@alexkroman1/aai";
2
+ import { planSlot, planView } from "./shared.ts";
3
+
4
+ /**
5
+ * A planning desk you can phone: plan-and-execute, with the caller in the loop.
6
+ * `prompts.ts` carries the attribution, `graph.ts` the three nodes, `shared.ts`
7
+ * the state (which is their `PlanExecute`, field for field).
8
+ *
9
+ * **The steps do real work.** `work_next_step` runs a bounded search/answer loop
10
+ * on `webSearch` — the same DuckDuckGo-backed implementation behind the
11
+ * `web_search` builtin, no API key — so a plan about anything current is worked
12
+ * against what is actually out there rather than against what a model
13
+ * remembers. That is the difference between this and a template that "plans" by
14
+ * asking a model to imagine having looked something up.
15
+ */
16
+ export default agent({
17
+ name: "Planning Desk",
18
+ // The plan exists before the first tool call, so a resumed connection has
19
+ // something to project.
20
+ // The plan, its progress and its revision trail, pushed after every tool call.
21
+ syncState: planSlot.projection(planView),
22
+ greeting: "Planning desk. Tell me what you're trying to get done and I'll work out the steps.",
23
+ });
@@ -0,0 +1,115 @@
1
+ import "@alexkroman1/aai-ui/styles.css";
2
+ import { AutoScroll, client, useAgentState, useTheme } from "@alexkroman1/aai-ui";
3
+ import type { PlanView } from "./shared.ts";
4
+ import { planSlot, planView } from "./shared.ts";
5
+
6
+ const EMPTY_PLAN: PlanView = planSlot.projection(planView)(undefined);
7
+
8
+ /**
9
+ * The plan, ticking off.
10
+ *
11
+ * A plan is the one thing in this template that is genuinely hard to hold by
12
+ * ear — four steps, two of them done, one rewritten since it was first read
13
+ * out. The sidebar is where that lives; the call is where the decisions happen.
14
+ */
15
+ function PlanSidebar() {
16
+ const theme = useTheme();
17
+ const plan = useAgentState<PlanView>(EMPTY_PLAN);
18
+
19
+ if (!plan.objective) {
20
+ return (
21
+ <div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
22
+ <span className="text-4xl">🗂️</span>
23
+ <p className="text-sm opacity-60" style={{ color: theme.text }}>
24
+ Say what you want to get done and the plan appears here.
25
+ </p>
26
+ </div>
27
+ );
28
+ }
29
+
30
+ return (
31
+ <div className="flex h-full min-h-0 flex-col gap-4 p-4" style={{ color: theme.text }}>
32
+ <div className="flex flex-col gap-2">
33
+ <h3 className="text-sm font-bold uppercase tracking-wide opacity-60">Objective</h3>
34
+ <p className="text-sm">{plan.objective}</p>
35
+ <div className="h-1.5 w-full rounded-full" style={{ background: theme.surface }}>
36
+ <div
37
+ className="h-1.5 rounded-full transition-all"
38
+ style={{ width: `${Math.round(plan.progress * 100)}%`, background: theme.primary }}
39
+ />
40
+ </div>
41
+ <p className="text-xs opacity-50">
42
+ {plan.done.length} done · {plan.plan.length} to go
43
+ </p>
44
+ </div>
45
+
46
+ <AutoScroll
47
+ scrollClassName="min-h-0 overflow-y-auto"
48
+ contentClassName="flex flex-col gap-2 pr-1"
49
+ >
50
+ {plan.done.map((past) => (
51
+ <div key={past.step} className="rounded-lg p-3" style={{ background: theme.surface }}>
52
+ <p className="text-sm">
53
+ <span style={{ color: theme.primary }}>✓</span> {past.step}
54
+ </p>
55
+ <p className="mt-1 text-xs opacity-70">{past.result}</p>
56
+ {past.searches.length > 0 && (
57
+ <p className="mt-1 text-[11px] opacity-40">searched: {past.searches.join(" · ")}</p>
58
+ )}
59
+ </div>
60
+ ))}
61
+ {plan.plan.map((step, index) => (
62
+ // Steps are short spoken sentences and can legitimately repeat across
63
+ // a revision, so the position is part of the identity.
64
+ <div
65
+ key={`${index}-${step}`}
66
+ className="rounded-lg p-3 text-sm opacity-60"
67
+ style={{ border: `1px dashed ${theme.border}` }}
68
+ >
69
+ <span className="opacity-50">{plan.done.length + index + 1}.</span> {step}
70
+ </div>
71
+ ))}
72
+ </AutoScroll>
73
+
74
+ {plan.response && (
75
+ <div
76
+ className="rounded-lg p-3"
77
+ style={{ background: theme.surface, border: `1px solid ${theme.primary}` }}
78
+ >
79
+ <p className="text-[11px] font-bold uppercase tracking-wide opacity-60">Answer</p>
80
+ <p className="mt-1 text-sm">{plan.response}</p>
81
+ </div>
82
+ )}
83
+
84
+ {plan.revisions.length > 0 && (
85
+ <details className="text-xs opacity-60">
86
+ <summary className="cursor-pointer">Plan history ({plan.revisions.length})</summary>
87
+ <div className="mt-1 flex flex-col gap-1">
88
+ {plan.revisions.map((entry, index) => (
89
+ // Append-only and capped, so the index is stable for its lifetime.
90
+ <p key={`${index}-${entry}`}>{entry}</p>
91
+ ))}
92
+ </div>
93
+ </details>
94
+ )}
95
+ </div>
96
+ );
97
+ }
98
+
99
+ client({
100
+ name: "Planning Desk",
101
+ sidebar: PlanSidebar,
102
+ theme: {
103
+ bg: "#12100e",
104
+ primary: "#d9a441",
105
+ text: "#f3efe7",
106
+ surface: "#1e1a16",
107
+ border: "#2c2620",
108
+ },
109
+ tools: {
110
+ start_plan: { icon: "\u{1F5C2}", label: "Drafting the plan" },
111
+ work_next_step: { icon: "\u{1F50E}", label: "Working a step" },
112
+ revise_plan: { icon: "\u{270F}", label: "Revising the plan" },
113
+ plan_status: { icon: "\u{2139}", label: "Checking the plan" },
114
+ },
115
+ });