@alexkroman1/aai-cli 6.11.0 → 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 +40 -19
  26. package/dist/scaffold/package.json +5 -3
  27. package/dist/scaffold/server.mjs +1 -1
  28. package/dist/{secret-BuMuFR4B.mjs → secret-CVvSLIDV.mjs} +2 -2
  29. package/dist/{storage-DzRZ-eCw.mjs → storage-BvUrnvM3.mjs} +2 -2
  30. package/dist/{studio-BCNUpDgP.mjs → studio--MUV0cid.mjs} +4 -4
  31. package/dist/templates/call-audit/agent.test.ts +57 -204
  32. package/dist/templates/call-audit/agent.ts +32 -19
  33. package/dist/templates/call-audit/client.tsx +17 -54
  34. package/dist/templates/call-audit/workflows/audit.ts +11 -18
  35. package/dist/templates/call-audit/workflows/ingest.ts +106 -114
  36. package/dist/templates/call-audit/workflows/media.ts +2 -12
  37. package/dist/templates/call-audit/workflows/summarize.ts +47 -52
  38. package/dist/templates/call-audit/workflows/sync-api.ts +7 -7
  39. package/dist/templates/dispatch-center/agent.test.ts +149 -25
  40. package/dist/templates/dispatch-center/client.tsx +239 -129
  41. package/dist/templates/dispatch-center/shared.ts +99 -1
  42. package/dist/templates/dispatch-center/system-prompt.md +3 -1
  43. package/dist/templates/dispatch-center/tools/incident_add_note.ts +16 -13
  44. package/dist/templates/dispatch-center/tools/incident_create.ts +19 -1
  45. package/dist/templates/dispatch-center/tools/incident_escalate.ts +68 -59
  46. package/dist/templates/dispatch-center/tools/incident_triage.ts +57 -43
  47. package/dist/templates/dispatch-center/tools/incident_update_status.ts +49 -40
  48. package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +8 -2
  49. package/dist/templates/dispatch-center/tools/resources_dispatch.ts +69 -52
  50. package/dist/templates/dispatch-center/tools/resources_update_status.ts +45 -41
  51. package/dist/templates/embedded-assets/agent.test.ts +17 -14
  52. package/dist/templates/health-assistant/agent.test.ts +22 -4
  53. package/dist/templates/infocom-adventure/agent.test.ts +4 -6
  54. package/dist/templates/infocom-adventure/client.tsx +246 -164
  55. package/dist/templates/link-digest/agent.test.ts +24 -19
  56. package/dist/templates/link-digest/client.tsx +47 -61
  57. package/dist/templates/link-digest/workflows/digest.ts +19 -31
  58. package/dist/templates/night-owl/agent.test.ts +70 -19
  59. package/dist/templates/night-owl/agent.ts +5 -0
  60. package/dist/templates/night-owl/client.tsx +56 -56
  61. package/dist/templates/night-owl/shared.ts +24 -0
  62. package/dist/templates/night-owl/tools/recommend.ts +18 -6
  63. package/dist/templates/pizza-ordering/agent.test.ts +27 -27
  64. package/dist/templates/pizza-ordering/client.tsx +9 -26
  65. package/dist/templates/plan-and-execute/agent.test.ts +99 -58
  66. package/dist/templates/plan-and-execute/agent.ts +1 -1
  67. package/dist/templates/plan-and-execute/client.tsx +12 -15
  68. package/dist/templates/plan-and-execute/shared.ts +71 -2
  69. package/dist/templates/plan-and-execute/tools/plan_status.ts +18 -6
  70. package/dist/templates/plan-and-execute/tools/revise_plan.ts +18 -10
  71. package/dist/templates/plan-and-execute/tools/start_plan.ts +13 -2
  72. package/dist/templates/plan-and-execute/tools/work_next_step.ts +70 -22
  73. package/dist/templates/podcast-digest/agent.test.ts +746 -0
  74. package/dist/templates/podcast-digest/agent.ts +139 -0
  75. package/dist/templates/podcast-digest/client.tsx +154 -0
  76. package/dist/templates/podcast-digest/workflows/digest.ts +411 -0
  77. package/dist/templates/podcast-digest/workflows/feeds.ts +507 -0
  78. package/dist/templates/podcast-digest/workflows/slack.ts +209 -0
  79. package/dist/templates/recap-workflow/agent.test.ts +97 -94
  80. package/dist/templates/recap-workflow/tools/recap_progress.ts +9 -12
  81. package/dist/templates/recap-workflow/tools/recap_status.ts +8 -9
  82. package/dist/templates/recap-workflow/tools/request_recap.ts +2 -1
  83. package/dist/templates/recap-workflow/workflows/recap.ts +36 -40
  84. package/dist/templates/redline/agent.test.ts +17 -15
  85. package/dist/templates/redline/client.tsx +12 -12
  86. package/dist/templates/redline/workflows/redline.ts +19 -31
  87. package/dist/templates/research-workflow/agent.test.ts +60 -59
  88. package/dist/templates/research-workflow/tools/research_progress.ts +9 -12
  89. package/dist/templates/research-workflow/tools/research_status.ts +9 -11
  90. package/dist/templates/research-workflow/workflows/research.ts +44 -61
  91. package/dist/templates/retail/agent.test.ts +26 -23
  92. package/dist/templates/retail/client.tsx +226 -117
  93. package/dist/templates/retail/registry.test.ts +38 -6
  94. package/dist/templates/retail/store.test.ts +82 -15
  95. package/dist/templates/retail/store.ts +174 -47
  96. package/dist/templates/retail/system-prompt.md +11 -2
  97. package/dist/templates/retail/tools/cancel_pending_order.ts +2 -2
  98. package/dist/templates/retail/tools/exchange_delivered_order_items.ts +2 -2
  99. package/dist/templates/retail/tools/find_user_id_by_email.ts +8 -5
  100. package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +4 -5
  101. package/dist/templates/retail/tools/get_item_details.ts +3 -4
  102. package/dist/templates/retail/tools/get_order_details.ts +3 -4
  103. package/dist/templates/retail/tools/get_product_details.ts +3 -4
  104. package/dist/templates/retail/tools/get_user_details.ts +2 -2
  105. package/dist/templates/retail/tools/list_all_product_types.ts +5 -5
  106. package/dist/templates/retail/tools/modify_pending_order_address.ts +2 -2
  107. package/dist/templates/retail/tools/modify_pending_order_items.ts +2 -3
  108. package/dist/templates/retail/tools/modify_pending_order_payment.ts +2 -2
  109. package/dist/templates/retail/tools/modify_user_address.ts +2 -2
  110. package/dist/templates/retail/tools/return_delivered_order_items.ts +2 -2
  111. package/dist/templates/retail/tools/transfer_to_human_agents.ts +8 -4
  112. package/dist/templates/solo-rpg/agent.test.ts +227 -48
  113. package/dist/templates/solo-rpg/client.tsx +38 -37
  114. package/dist/templates/solo-rpg/shared.ts +145 -19
  115. package/dist/templates/solo-rpg/system-prompt.md +3 -2
  116. package/dist/templates/solo-rpg/tools/action_roll.ts +82 -63
  117. package/dist/templates/solo-rpg/tools/burn_momentum.ts +85 -50
  118. package/dist/templates/solo-rpg/tools/check_state.ts +24 -7
  119. package/dist/templates/solo-rpg/tools/load_game.ts +13 -1
  120. package/dist/templates/solo-rpg/tools/save_game.ts +16 -7
  121. package/dist/templates/solo-rpg/tools/setup_character.ts +22 -2
  122. package/dist/templates/solo-rpg/tools/update_state.ts +113 -100
  123. package/dist/templates/spoken-summary/agent.test.ts +68 -97
  124. package/dist/templates/spoken-summary/agent.ts +30 -17
  125. package/dist/templates/spoken-summary/client.tsx +10 -50
  126. package/dist/templates/spoken-summary/workflows/summarize.ts +17 -22
  127. package/dist/templates/spoken-summary/workflows/transcribe.ts +19 -26
  128. package/dist/templates/support-line/agent.test.ts +11 -16
  129. package/dist/templates/support-line/agent.ts +1 -1
  130. package/dist/templates/support-line/client.tsx +9 -9
  131. package/dist/templates/support-line/nodes.ts +100 -0
  132. package/dist/templates/support-line/procedure.ts +407 -0
  133. package/dist/templates/support-line/tools/answer_question.ts +17 -4
  134. package/dist/templates/transcription-workflow/agent.test.ts +94 -194
  135. package/dist/templates/transcription-workflow/agent.ts +1 -1
  136. package/dist/templates/transcription-workflow/client.tsx +17 -42
  137. package/dist/templates/transcription-workflow/workflows/batch.ts +19 -22
  138. package/dist/templates/transcription-workflow/workflows/normalize.ts +90 -186
  139. package/dist/templates/transcription-workflow/workflows/stitch.ts +0 -11
  140. package/dist/templates/transcription-workflow/workflows/stream.ts +4 -5
  141. package/dist/templates/transcription-workflow/workflows/sync-api.ts +6 -6
  142. package/dist/templates/transcription-workflow/workflows/transcribe.ts +32 -26
  143. package/dist/templates/transcription-workflow/workflows/wav.ts +0 -31
  144. package/dist/templates/travel-concierge/agent.test.ts +64 -33
  145. package/dist/templates/travel-concierge/client.tsx +11 -23
  146. package/dist/templates/travel-concierge/routing.ts +34 -15
  147. package/dist/templates/travel-concierge/shared.ts +70 -3
  148. package/dist/templates/travel-concierge/tools/book_car_rental.ts +2 -2
  149. package/dist/templates/travel-concierge/tools/book_excursion.ts +2 -2
  150. package/dist/templates/travel-concierge/tools/book_hotel.ts +2 -2
  151. package/dist/templates/travel-concierge/tools/cancel_ticket.ts +2 -2
  152. package/dist/templates/travel-concierge/tools/update_ticket.ts +2 -2
  153. package/dist/{worker-bundler-CGD4r8Kc.mjs → worker-bundler-COxnqstQ.mjs} +221 -3
  154. package/dist/worker-bundler.mjs +1 -1
  155. package/dist/{workflow-CFpxOFfQ.mjs → workflow-D2AQf2Pl.mjs} +28 -1
  156. package/dist/workflow-bundler.d.ts +6 -1
  157. package/dist/workflow.d.ts +1 -1
  158. package/package.json +5 -4
  159. package/dist/templates/call-audit/workflows/temp-media.ts +0 -138
  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
@@ -1,6 +1,5 @@
1
- import { tool } from "@alexkroman1/aai";
2
1
  import { z } from "zod";
3
- import { CATEGORIES, type Category, MOODS, type Mood } from "../shared.ts";
2
+ import { CATEGORIES, type Category, MOODS, type Mood, nightSlot, type Rec } from "../shared.ts";
4
3
 
5
4
  const PICKS: Record<Category, Record<Mood, string[]>> = {
6
5
  movie: {
@@ -46,19 +45,32 @@ const PICKS: Record<Category, Record<Mood, string[]>> = {
46
45
  },
47
46
  };
48
47
 
49
- export default tool({
48
+ /**
49
+ * `updateTool` rather than `tool`: the body is handed the night's own draft and
50
+ * whatever it leaves behind is stored, so the log the client renders and the
51
+ * value this returns to the model are written in one place. It must be
52
+ * SYNCHRONOUS — the mutation is committed when it returns.
53
+ */
54
+ export default nightSlot.updateTool({
50
55
  description: "Get recommendations for movies, music, or books based on mood.",
51
56
  inputSchema: z.object({
52
57
  category: z.enum(CATEGORIES),
53
58
  mood: z.enum(MOODS),
54
59
  }),
55
- async execute(args, ctx) {
56
- const result = {
60
+ execute: (args, night, ctx) => {
61
+ const result: Rec = {
57
62
  category: args.category,
58
63
  mood: args.mood,
59
64
  picks: PICKS[args.category][args.mood],
60
65
  };
61
- ctx.send("recommendations", result);
66
+ night.recs.unshift(result);
67
+ // A NUDGE, not state: shown once, when the third pick lands. It is a
68
+ // `ctx.send` rather than a field on the projection precisely because
69
+ // replaying it on every reconnect would be nagging — the distinction the
70
+ // client's header comment spells out.
71
+ if (night.recs.length === 3) {
72
+ ctx.send("wind_down", "Three picks in. Want me to work out your bedtime?");
73
+ }
62
74
  return result;
63
75
  },
64
76
  });
@@ -1,7 +1,13 @@
1
1
  /// <reference types="vite/client" />
2
2
 
3
3
  import type { ToolContext } from "@alexkroman1/aai";
4
- import { createToolContext, runTool, toolOf, withDiscoveredTools } from "@alexkroman1/aai/testing";
4
+ import {
5
+ createToolContext,
6
+ parseToolInput,
7
+ toolInputIssues,
8
+ toolRunner,
9
+ withDiscoveredTools,
10
+ } from "@alexkroman1/aai/testing";
5
11
  import { describe, expect, test } from "vitest";
6
12
  import authoredAgent from "./agent.ts";
7
13
 
@@ -34,11 +40,12 @@ import {
34
40
  * its cart in a session slot and must never touch storage. */
35
41
  const makeCtx = (): ToolContext => createToolContext();
36
42
 
37
- /** A tool by the name the model calls it by, bound to this agent. The lookup
38
- * and its "no such tool" message are `runTool`'s (`@alexkroman1/aai/testing`);
39
- * what is local is only which agent they run against. */
40
- const run = (name: string, args: Record<string, unknown>, ctx: ToolContext) =>
41
- runTool(agentDef, name, args, ctx);
43
+ /** A tool by the name the model calls it by, bound to this agent. The lookup,
44
+ * its "no such tool" message and the args-or-context shape are all
45
+ * `toolRunner`'s (`@alexkroman1/aai/testing`); what is local is only which
46
+ * agent it runs against. Its second parameter is args-or-context, so a
47
+ * no-argument tool passes the context in the arguments' place. */
48
+ const run = toolRunner(agentDef);
42
49
 
43
50
  const margherita: Omit<Pizza, "id"> = {
44
51
  size: "medium",
@@ -79,18 +86,11 @@ describe("pricing (shared.ts)", () => {
79
86
  });
80
87
 
81
88
  test("add_pizza schema defaults quantity to 1 and rejects non-positive quantities", async () => {
82
- const schema = toolOf(agentDef, "add_pizza").inputSchema;
83
- if (!schema) throw new Error("add_pizza has no input schema");
84
- // Validate through the Standard Schema contract — the vendor-neutral
85
- // interface every inputSchema carries.
86
- const validate = (value: unknown) => schema["~standard"].validate(value);
87
- const ok = await validate({ size: "small", crust: "thin", toppings: [] });
88
- if (ok.issues) throw new Error("expected valid input");
89
- expect((ok.value as { quantity: number }).quantity).toBe(1);
90
- const zero = await validate({ size: "small", crust: "thin", toppings: [], quantity: 0 });
91
- expect(zero.issues).toBeDefined();
92
- const frac = await validate({ size: "small", crust: "thin", toppings: [], quantity: 1.5 });
93
- expect(frac.issues).toBeDefined();
89
+ const base = { size: "small", crust: "thin", toppings: [] };
90
+ const parsed = await parseToolInput<{ quantity: number }>(agentDef, "add_pizza", base);
91
+ expect(parsed.quantity).toBe(1);
92
+ expect(await toolInputIssues(agentDef, "add_pizza", { ...base, quantity: 0 })).toBeDefined();
93
+ expect(await toolInputIssues(agentDef, "add_pizza", { ...base, quantity: 1.5 })).toBeDefined();
94
94
  });
95
95
  });
96
96
 
@@ -101,8 +101,8 @@ describe("tool flow (add → update → remove → place_order)", () => {
101
101
  const ctx = makeCtx();
102
102
 
103
103
  // Empty order guards
104
- expect(await run("view_order", {}, ctx)).toEqual({ message: "The order is empty." });
105
- expect(await run("place_order", {}, ctx)).toEqual({ error: "Cannot place an empty order." });
104
+ expect(await run("view_order", ctx)).toEqual({ message: "The order is empty." });
105
+ expect(await run("place_order", ctx)).toEqual({ error: "Cannot place an empty order." });
106
106
 
107
107
  // Add two pizzas — IDs increment
108
108
  const first = (await run(
@@ -144,7 +144,7 @@ describe("tool flow (add → update → remove → place_order)", () => {
144
144
 
145
145
  // Name + place the order
146
146
  await run("set_customer_name", { name: "Alex" }, ctx);
147
- const placed = (await run("place_order", {}, ctx)) as {
147
+ const placed = (await run("place_order", ctx)) as {
148
148
  orderNumber: number;
149
149
  customerName: string;
150
150
  pizzas: number;
@@ -155,8 +155,8 @@ describe("tool flow (add → update → remove → place_order)", () => {
155
155
  expect(placed.total).toBe(`$${(8.99 + 2.0).toFixed(2)}`);
156
156
 
157
157
  // The cart is cleared after placing — a follow-up order starts fresh.
158
- expect(await run("view_order", {}, ctx)).toEqual({ message: "The order is empty." });
159
- expect(await run("place_order", {}, ctx)).toEqual({ error: "Cannot place an empty order." });
158
+ expect(await run("view_order", ctx)).toEqual({ message: "The order is empty." });
159
+ expect(await run("place_order", ctx)).toEqual({ error: "Cannot place an empty order." });
160
160
  });
161
161
 
162
162
  test("two independent contexts never share pizzas or names", async () => {
@@ -175,10 +175,10 @@ describe("tool flow (add → update → remove → place_order)", () => {
175
175
  );
176
176
  await run("set_customer_name", { name: "Alice" }, firstCall);
177
177
 
178
- expect(await run("view_order", {}, secondCall)).toEqual({ message: "The order is empty." });
178
+ expect(await run("view_order", secondCall)).toEqual({ message: "The order is empty." });
179
179
 
180
180
  await run("add_pizza", { size: "small", crust: "thin", toppings: [], quantity: 1 }, secondCall);
181
- const placedB = (await run("place_order", {}, secondCall)) as {
181
+ const placedB = (await run("place_order", secondCall)) as {
182
182
  customerName: string;
183
183
  pizzas: number;
184
184
  };
@@ -187,7 +187,7 @@ describe("tool flow (add → update → remove → place_order)", () => {
187
187
  expect(placedB.pizzas).toBe(1);
188
188
 
189
189
  // And the first context's cart survives the second's checkout untouched.
190
- const viewA = (await run("view_order", {}, firstCall)) as { pizzas: unknown[] };
190
+ const viewA = (await run("view_order", firstCall)) as { pizzas: unknown[] };
191
191
  expect(viewA.pizzas).toHaveLength(1);
192
192
  });
193
193
  });
@@ -227,7 +227,7 @@ describe("orderView projection", () => {
227
227
  // checkout, and the UI still has to show the order that was just placed.
228
228
  const ctx = makeCtx();
229
229
  await run("add_pizza", { size: "small", crust: "thin", toppings: [], quantity: 1 }, ctx);
230
- await run("place_order", {}, ctx);
230
+ await run("place_order", ctx);
231
231
 
232
232
  const view = orderView(orderSlot.get(ctx));
233
233
  expect(view.orderPlaced).toBe(true);
@@ -1,5 +1,5 @@
1
1
  import "@alexkroman1/aai-ui/styles.css";
2
- import { client, useAgentState, useTheme } from "@alexkroman1/aai-ui";
2
+ import { client, useAgentState } from "@alexkroman1/aai-ui";
3
3
  import { formatPrice, orderProjection, pizzaPrice } from "./shared.ts";
4
4
 
5
5
  function PizzaIcon({ size }: { size: string }) {
@@ -20,7 +20,6 @@ function PizzaIcon({ size }: { size: string }) {
20
20
  }
21
21
 
22
22
  function OrderSidebar() {
23
- const theme = useTheme();
24
23
  // The agent's own cart, projected by `syncState` and pushed after every
25
24
  // tool call. This replaced ~45 lines that rebuilt the cart by diffing
26
25
  // added/removed/updated events — where one missed event desynced the view
@@ -29,16 +28,11 @@ function OrderSidebar() {
29
28
 
30
29
  if (order.orderPlaced) {
31
30
  return (
32
- <div
33
- className="flex flex-col items-center gap-4 p-6 text-center"
34
- style={{ color: theme.text }}
35
- >
31
+ <div className="flex flex-col items-center gap-4 p-6 text-center text-aai-text">
36
32
  <div className="text-5xl">&#10003;</div>
37
33
  <h2 className="text-lg font-bold">Order Placed</h2>
38
34
  {order.orderNumber && <p className="opacity-70">Order #{order.orderNumber}</p>}
39
- <p className="font-bold text-xl" style={{ color: theme.primary }}>
40
- {order.total}
41
- </p>
35
+ <p className="font-bold text-xl text-aai-primary">{order.total}</p>
42
36
  {order.estimatedMinutes && (
43
37
  <p className="opacity-60 text-sm">Ready in ~{order.estimatedMinutes} minutes</p>
44
38
  )}
@@ -50,22 +44,16 @@ function OrderSidebar() {
50
44
  return (
51
45
  <div className="flex flex-col items-center gap-3 p-6 text-center opacity-50">
52
46
  <PizzaIcon size="large" />
53
- <p className="text-sm" style={{ color: theme.text }}>
54
- Your order is empty. Tell me what you'd like.
55
- </p>
47
+ <p className="text-sm text-aai-text">Your order is empty. Tell me what you'd like.</p>
56
48
  </div>
57
49
  );
58
50
  }
59
51
 
60
52
  return (
61
- <div className="flex flex-col gap-3 p-4" style={{ color: theme.text }}>
53
+ <div className="flex flex-col gap-3 p-4 text-aai-text">
62
54
  <h3 className="text-sm font-bold opacity-60 uppercase tracking-wide">Your Order</h3>
63
55
  {order.pizzas.map((p) => (
64
- <div
65
- key={p.id}
66
- className="flex items-center gap-3 p-3 rounded-lg"
67
- style={{ background: theme.surface }}
68
- >
56
+ <div key={p.id} className="flex items-center gap-3 p-3 rounded-lg bg-aai-surface">
69
57
  <PizzaIcon size={p.size} />
70
58
  <div className="flex-1 min-w-0">
71
59
  <p className="text-sm font-medium">
@@ -78,19 +66,14 @@ function OrderSidebar() {
78
66
  : "cheese only"}
79
67
  </p>
80
68
  </div>
81
- <p className="text-sm font-bold whitespace-nowrap" style={{ color: theme.primary }}>
69
+ <p className="text-sm font-bold whitespace-nowrap text-aai-primary">
82
70
  {formatPrice(pizzaPrice(p))}
83
71
  </p>
84
72
  </div>
85
73
  ))}
86
- <div
87
- className="flex justify-between items-center pt-3 mt-1 border-t"
88
- style={{ borderColor: theme.border }}
89
- >
74
+ <div className="flex justify-between items-center pt-3 mt-1 border-t border-aai-border">
90
75
  <span className="font-bold">Total</span>
91
- <span className="font-bold text-lg" style={{ color: theme.primary }}>
92
- {order.total}
93
- </span>
76
+ <span className="font-bold text-lg text-aai-primary">{order.total}</span>
94
77
  </div>
95
78
  </div>
96
79
  );
@@ -1,10 +1,11 @@
1
1
  /// <reference types="vite/client" />
2
2
 
3
- import type { GenerateFn, ToolContext } from "@alexkroman1/aai";
3
+ import type { ToolContext } from "@alexkroman1/aai";
4
4
  import {
5
5
  createToolContext,
6
- runTool,
6
+ okPosition,
7
7
  stubGenerate,
8
+ toolRunner,
8
9
  withDiscoveredTools,
9
10
  } from "@alexkroman1/aai/testing";
10
11
  import { describe, expect, test } from "vitest";
@@ -23,10 +24,10 @@ const agentDef = withDiscoveredTools(
23
24
  import.meta.glob("./tools/*.ts", { eager: true }),
24
25
  );
25
26
 
26
- import { executeStep, MAX_STEP_SEARCHES, normalizeAct, planNode } from "./graph.ts";
27
+ import { executeStep, MAX_STEP_SEARCHES, normalizeAct, planNode } from "./procedure.ts";
27
28
  import { EXECUTOR_SYSTEM, PLANNER_SYSTEM, REPLANNER_SYSTEM, REVISE_SYSTEM } from "./prompts.ts";
28
29
  import type { SearchFn } from "./shared.ts";
29
- import { MAX_PAST_STEPS, planProjection, planSlot, planView } from "./shared.ts";
30
+ import { MAX_PAST_STEPS, planFlow, planProjection, planSlot, planView } from "./shared.ts";
30
31
 
31
32
  // ─── A scripted model ────────────────────────────────────────────────────────
32
33
  //
@@ -80,15 +81,11 @@ function fakeSearch(hits: Record<string, { title: string; url: string }[]>): {
80
81
  return { search, queries };
81
82
  }
82
83
 
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);
84
+ /** A tool by the name the model calls it by, bound to this agent. The lookup,
85
+ * its "no such tool" message and the args-or-context shape are all
86
+ * `toolRunner`'s (`@alexkroman1/aai/testing`); what is local is only which
87
+ * agent it runs against. */
88
+ const run = toolRunner(agentDef);
92
89
 
93
90
  function stateOf(ctx: ToolContext) {
94
91
  return planSlot.get(ctx);
@@ -190,7 +187,7 @@ describe("normalizeAct", () => {
190
187
  describe("start_plan", () => {
191
188
  test("stores the objective and the steps", async () => {
192
189
  const { generate } = scriptedModel({ steps: ["Check prices", "Compare hotels", "Book"] });
193
- const ctx = makeCtx(generate);
190
+ const ctx = createToolContext({ generate });
194
191
  const result = (await run("start_plan", { objective: "a weekend in Lisbon" }, ctx)) as {
195
192
  steps: string[];
196
193
  };
@@ -214,12 +211,20 @@ describe("start_plan", () => {
214
211
  });
215
212
 
216
213
  describe("work_next_step", () => {
217
- test("refuses before there is a plan", async () => {
214
+ test("is refused before there is a plan, by the flow rather than by the body", async () => {
218
215
  const { generate } = scriptedModel();
219
- const ctx = makeCtx(generate);
220
- expect(await run("work_next_step", {}, ctx)).toEqual({
221
- error: "There is no plan yetuse start_plan first.",
216
+ const ctx = createToolContext({ generate });
217
+ // The gate is `when: "working"`, so the refusal names the state the call is
218
+ // actually in and quotes that state's instruction which is what the model
219
+ // needs in order to do the right thing on its own next turn.
220
+ expect(await run("work_next_step", ctx)).toMatchObject({
221
+ error: expect.stringContaining('this conversation is at "idle"'),
222
222
  });
223
+ expect(await run("work_next_step", ctx)).toMatchObject({
224
+ error: expect.stringContaining("use start_plan"),
225
+ });
226
+ // Refused means the body did not run: nothing was claimed off the plan.
227
+ expect(stateOf(ctx).pastSteps).toEqual([]);
223
228
  });
224
229
 
225
230
  test("does the head step, records it, and takes the replanner's next plan", async () => {
@@ -228,19 +233,21 @@ describe("work_next_step", () => {
228
233
  turns: [{ answer: "Fares are about 180 return." }],
229
234
  acts: [{ kind: "plan", steps: ["Compare hotels"] }],
230
235
  });
231
- const ctx = makeCtx(generate);
236
+ const ctx = createToolContext({ generate });
232
237
  await run("start_plan", { objective: "a weekend in Lisbon" }, ctx);
233
238
 
234
- const first = (await run("work_next_step", {}, ctx)) as {
235
- done: boolean;
239
+ const first = okPosition<{
240
+ finished: boolean;
236
241
  step: string;
237
242
  result: string;
238
243
  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
+ }>(await run("work_next_step", ctx));
245
+ expect(first.result.finished).toBe(false);
246
+ expect(first.result.step).toBe("Check prices");
247
+ expect(first.result.result).toContain("180");
248
+ expect(first.result.remaining).toEqual(["Compare hotels"]);
249
+ // Still working: nothing was concluded, so the flow has not moved on.
250
+ expect(first.state).toBe("working");
244
251
 
245
252
  const state = stateOf(ctx);
246
253
  expect(state.pastSteps).toHaveLength(1);
@@ -254,19 +261,29 @@ describe("work_next_step", () => {
254
261
  turns: [{ answer: "Fares are about 180 return." }],
255
262
  acts: [{ kind: "respond", response: "Go in May — flights are about 180 return." }],
256
263
  });
257
- const ctx = makeCtx(generate);
264
+ const ctx = createToolContext({ generate });
258
265
  await run("start_plan", { objective: "a weekend in Lisbon" }, ctx);
259
- const result = (await run("work_next_step", {}, ctx)) as { done: boolean; response: string };
266
+ const answered = okPosition<{ finished: boolean; response: string }>(
267
+ await run("work_next_step", ctx),
268
+ );
269
+
270
+ expect(answered.result.finished).toBe(true);
271
+ expect(answered.result.response).toContain("180");
272
+ // A response is what sends ANSWERED — the flow and the data agree because
273
+ // one tool call moved both.
274
+ expect(answered.state).toBe("answered");
275
+ expect(planFlow.position(ctx).state).toBe("answered");
260
276
 
261
- expect(result.done).toBe(true);
262
- expect(result.response).toContain("180");
263
277
  const state = stateOf(ctx);
264
278
  // The replanner deciding early is a good outcome, so the pending step goes.
265
279
  expect(state.plan).toEqual([]);
266
- expect(state.response).toBe(result.response);
280
+ expect(state.response).toBe(answered.result.response);
267
281
 
268
- // A finished plan is not worked again.
269
- expect(await run("work_next_step", {}, ctx)).toMatchObject({ done: true });
282
+ // A finished plan is not worked again, and now it CANNOT be: the tool is
283
+ // gated out of `answered` rather than returning a done-shaped result.
284
+ expect(await run("work_next_step", ctx)).toMatchObject({
285
+ error: expect.stringContaining('this conversation is at "answered"'),
286
+ });
270
287
  expect(stateOf(ctx).pastSteps).toHaveLength(1);
271
288
  });
272
289
 
@@ -283,9 +300,9 @@ describe("work_next_step", () => {
283
300
  steps: Array.from({ length: total - i - 1 }, (_, j) => `Step ${i + j + 2}`),
284
301
  })),
285
302
  });
286
- const ctx = makeCtx(generate);
303
+ const ctx = createToolContext({ generate });
287
304
  await run("start_plan", { objective: "a long one" }, ctx);
288
- for (let i = 0; i < total; i++) await run("work_next_step", {}, ctx);
305
+ for (let i = 0; i < total; i++) await run("work_next_step", ctx);
289
306
 
290
307
  const state = stateOf(ctx);
291
308
  expect(state.pastSteps).toHaveLength(MAX_PAST_STEPS);
@@ -302,13 +319,15 @@ describe("work_next_step", () => {
302
319
  // session ids would prove nothing extra, and `sessionSlot` could stop
303
320
  // keying by session with this still passing.
304
321
  const { generate } = scriptedModel({ steps: ["Only step"] });
305
- const first = makeCtx(generate);
306
- const second = makeCtx(generate);
322
+ const first = createToolContext({ generate });
323
+ const second = createToolContext({ generate });
307
324
 
308
325
  await run("start_plan", { objective: "mine" }, first);
309
326
  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.",
327
+ // The FLOW is per-session too, not just the plan — `second` is still idle.
328
+ expect(planFlow.position(second).state).toBe("idle");
329
+ expect(await run("work_next_step", second)).toMatchObject({
330
+ error: expect.stringContaining('this conversation is at "idle"'),
312
331
  });
313
332
  });
314
333
  });
@@ -323,17 +342,19 @@ describe("revise_plan", () => {
323
342
  { kind: "plan", steps: ["Check Porto prices"] },
324
343
  ],
325
344
  });
326
- const ctx = makeCtx(generate);
345
+ const ctx = createToolContext({ generate });
327
346
  await run("start_plan", { objective: "a weekend in Lisbon" }, ctx);
328
- await run("work_next_step", {}, ctx);
347
+ await run("work_next_step", ctx);
329
348
  expect(stateOf(ctx).response).not.toBeNull();
330
349
 
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"]);
350
+ const revised = okPosition<{ finished: boolean; remaining: string[] }>(
351
+ await run("revise_plan", { instruction: "make it Porto instead" }, ctx),
352
+ );
353
+ expect(revised.result.finished).toBe(false);
354
+ expect(revised.result.remaining).toEqual(["Check Porto prices"]);
355
+ // REOPENED: the one transition that goes backwards, and the reason
356
+ // `revise_plan` is legal in `answered` at all.
357
+ expect(revised.state).toBe("working");
337
358
 
338
359
  const state = stateOf(ctx);
339
360
  // The old answer is no longer the answer — the caller moved the goalposts.
@@ -346,13 +367,27 @@ describe("revise_plan", () => {
346
367
  expect(calls.at(-1)?.prompt).toContain("make it Porto instead");
347
368
  });
348
369
 
349
- test("refuses before there is a plan", async () => {
370
+ test("is refused before there is a plan", async () => {
350
371
  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.",
372
+ const ctx = createToolContext({ generate });
373
+ expect(await run("revise_plan", { instruction: "change it" }, ctx)).toMatchObject({
374
+ error: expect.stringContaining('this conversation is at "idle"'),
354
375
  });
355
376
  });
377
+
378
+ test("a revision that answers outright lands in `answered`", async () => {
379
+ const { generate } = scriptedModel({
380
+ steps: ["Check Lisbon prices"],
381
+ acts: [{ kind: "respond", response: "Nothing to do — you already booked it." }],
382
+ });
383
+ const ctx = createToolContext({ generate });
384
+ await run("start_plan", { objective: "a weekend in Lisbon" }, ctx);
385
+ const revised = okPosition<{ finished: boolean }>(
386
+ await run("revise_plan", { instruction: "never mind, it is booked" }, ctx),
387
+ );
388
+ expect(revised.result.finished).toBe(true);
389
+ expect(revised.state).toBe("answered");
390
+ });
356
391
  });
357
392
 
358
393
  describe("plan_status", () => {
@@ -362,14 +397,20 @@ describe("plan_status", () => {
362
397
  turns: [{ answer: "About 180 return." }],
363
398
  acts: [{ kind: "plan", steps: ["Book"] }],
364
399
  });
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.",
400
+ const ctx = createToolContext({ generate });
401
+ // Legal in every state, so it READS the position rather than being gated on
402
+ // one — and "no plan yet" is the flow's own answer, not a third derivation
403
+ // of `!objective`.
404
+ expect(await run("plan_status", ctx)).toMatchObject({
405
+ stage: "idle",
406
+ next: expect.stringContaining("start_plan"),
407
+ objective: null,
368
408
  });
369
409
 
370
410
  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({
411
+ await run("work_next_step", ctx);
412
+ expect(await run("plan_status", ctx)).toMatchObject({
413
+ stage: "working",
373
414
  objective: "a weekend in Lisbon",
374
415
  remaining: ["Book"],
375
416
  response: null,
@@ -399,11 +440,11 @@ describe("planView projection", () => {
399
440
  turns: [{ answer: "Done one." }],
400
441
  acts: [{ kind: "plan", steps: ["Two", "Three"] }],
401
442
  });
402
- const ctx = makeCtx(generate);
443
+ const ctx = createToolContext({ generate });
403
444
  await run("start_plan", { objective: "three things" }, ctx);
404
445
  expect(planView(stateOf(ctx)).progress).toBe(0);
405
446
 
406
- await run("work_next_step", {}, ctx);
447
+ await run("work_next_step", ctx);
407
448
  const view = planView(stateOf(ctx));
408
449
  expect(view.progress).toBeCloseTo(1 / 3, 5);
409
450
  expect(view.done[0]?.step).toBe("One");
@@ -3,7 +3,7 @@ import { planProjection } from "./shared.ts";
3
3
 
4
4
  /**
5
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`
6
+ * `prompts.ts` carries the attribution, `procedure.ts` the three nodes, `shared.ts`
7
7
  * the state (which is their `PlanExecute`, field for field).
8
8
  *
9
9
  * **The steps do real work.** `work_next_step` runs a bounded search/answer loop
@@ -1,5 +1,5 @@
1
1
  import "@alexkroman1/aai-ui/styles.css";
2
- import { AutoScroll, client, useAgentState, useTheme } from "@alexkroman1/aai-ui";
2
+ import { AutoScroll, client, useAgentState } from "@alexkroman1/aai-ui";
3
3
  import { planProjection } from "./shared.ts";
4
4
 
5
5
  /**
@@ -10,14 +10,13 @@ import { planProjection } from "./shared.ts";
10
10
  * out. The sidebar is where that lives; the call is where the decisions happen.
11
11
  */
12
12
  function PlanSidebar() {
13
- const theme = useTheme();
14
13
  const plan = useAgentState(planProjection);
15
14
 
16
15
  if (!plan.objective) {
17
16
  return (
18
17
  <div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
19
18
  <span className="text-4xl">🗂️</span>
20
- <p className="text-sm opacity-60" style={{ color: theme.text }}>
19
+ <p className="text-sm opacity-60 text-aai-text">
21
20
  Say what you want to get done and the plan appears here.
22
21
  </p>
23
22
  </div>
@@ -25,14 +24,16 @@ function PlanSidebar() {
25
24
  }
26
25
 
27
26
  return (
28
- <div className="flex h-full min-h-0 flex-col gap-4 p-4" style={{ color: theme.text }}>
27
+ <div className="flex h-full min-h-0 flex-col gap-4 p-4 text-aai-text">
29
28
  <div className="flex flex-col gap-2">
30
29
  <h3 className="text-sm font-bold uppercase tracking-wide opacity-60">Objective</h3>
31
30
  <p className="text-sm">{plan.objective}</p>
32
- <div className="h-1.5 w-full rounded-full" style={{ background: theme.surface }}>
31
+ <div className="h-1.5 w-full rounded-full bg-aai-surface">
32
+ {/* The WIDTH is the one thing here that is genuinely computed, so it
33
+ stays inline; the colour is a token. */}
33
34
  <div
34
- className="h-1.5 rounded-full transition-all"
35
- style={{ width: `${Math.round(plan.progress * 100)}%`, background: theme.primary }}
35
+ className="h-1.5 rounded-full transition-all bg-aai-primary"
36
+ style={{ width: `${Math.round(plan.progress * 100)}%` }}
36
37
  />
37
38
  </div>
38
39
  <p className="text-xs opacity-50">
@@ -45,9 +46,9 @@ function PlanSidebar() {
45
46
  contentClassName="flex flex-col gap-2 pr-1"
46
47
  >
47
48
  {plan.done.map((past) => (
48
- <div key={past.step} className="rounded-lg p-3" style={{ background: theme.surface }}>
49
+ <div key={past.step} className="rounded-lg p-3 bg-aai-surface">
49
50
  <p className="text-sm">
50
- <span style={{ color: theme.primary }}>✓</span> {past.step}
51
+ <span className="text-aai-primary">✓</span> {past.step}
51
52
  </p>
52
53
  <p className="mt-1 text-xs opacity-70">{past.result}</p>
53
54
  {past.searches.length > 0 && (
@@ -60,8 +61,7 @@ function PlanSidebar() {
60
61
  // a revision, so the position is part of the identity.
61
62
  <div
62
63
  key={`${index}-${step}`}
63
- className="rounded-lg p-3 text-sm opacity-60"
64
- style={{ border: `1px dashed ${theme.border}` }}
64
+ className="rounded-lg p-3 text-sm opacity-60 border border-dashed border-aai-border"
65
65
  >
66
66
  <span className="opacity-50">{plan.done.length + index + 1}.</span> {step}
67
67
  </div>
@@ -69,10 +69,7 @@ function PlanSidebar() {
69
69
  </AutoScroll>
70
70
 
71
71
  {plan.response && (
72
- <div
73
- className="rounded-lg p-3"
74
- style={{ background: theme.surface, border: `1px solid ${theme.primary}` }}
75
- >
72
+ <div className="rounded-lg p-3 bg-aai-surface border border-aai-primary">
76
73
  <p className="text-[11px] font-bold uppercase tracking-wide opacity-60">Answer</p>
77
74
  <p className="mt-1 text-sm">{plan.response}</p>
78
75
  </div>