@alexkroman1/aai-cli 7.0.0 → 8.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 (122) hide show
  1. package/README.md +1 -0
  2. package/dist/{_bundler-B4RqNF5Z.mjs → _bundler-CDuYl5Gb.mjs} +1 -1
  3. package/dist/{_dev-server-CiKFH9qw.mjs → _dev-server-Dt3TCjhj.mjs} +6 -2
  4. package/dist/{_init-CE4HKdgV.mjs → _init-BhEfQ6Yi.mjs} +1 -1
  5. package/dist/_resource-commands.d.ts +23 -0
  6. package/dist/{_slug-api-nRFaBEPJ.mjs → _slug-api-z-USxPtL.mjs} +14 -10
  7. package/dist/_slug-api.d.ts +5 -2
  8. package/dist/{_templates-FI3xPEMj.mjs → _templates-4WcKOjS5.mjs} +4 -5
  9. package/dist/_templates.d.ts +3 -4
  10. package/dist/{build-6jfaakgc.mjs → build-BBxf8vom.mjs} +2 -2
  11. package/dist/cli.mjs +338 -346
  12. package/dist/{deploy-DvYeD8ia.mjs → deploy-DRC8jWr8.mjs} +1 -1
  13. package/dist/{dev-B59JIfWO.mjs → dev-QuuOOUtv.mjs} +1 -1
  14. package/dist/eval-DfQ8cm5p.mjs +56 -0
  15. package/dist/eval.d.ts +43 -0
  16. package/dist/{init-CpJVjEg0.mjs → init-CUo87gcI.mjs} +2 -2
  17. package/dist/{logs-9q-psa4q.mjs → logs-CQ2T46dr.mjs} +1 -1
  18. package/dist/scaffold/CLAUDE.md +409 -68
  19. package/dist/scaffold/package.json +5 -4
  20. package/dist/scaffold/server.mjs +10 -3
  21. package/dist/scaffold/tsconfig.json +0 -1
  22. package/dist/{secret-CVvSLIDV.mjs → secret-Ct34kdgU.mjs} +1 -1
  23. package/dist/{storage-BvUrnvM3.mjs → storage-i_79ZwAR.mjs} +1 -1
  24. package/dist/{studio--MUV0cid.mjs → studio-CzTzPqe1.mjs} +1 -1
  25. package/dist/templates/briefing-desk/agent.test.ts +350 -0
  26. package/dist/templates/briefing-desk/agent.ts +30 -0
  27. package/dist/templates/briefing-desk/shared.ts +200 -0
  28. package/dist/templates/briefing-desk/system-prompt.md +39 -0
  29. package/dist/templates/briefing-desk/tools/briefing_so_far.ts +35 -0
  30. package/dist/templates/briefing-desk/tools/research_topic.ts +88 -0
  31. package/dist/templates/briefing-desk/tools/verify_claim.ts +77 -0
  32. package/dist/templates/call-audit/agent.eval.test.ts +474 -0
  33. package/dist/templates/code-interpreter/agent.eval.test.ts +165 -0
  34. package/dist/templates/dispatch-center/agent.eval.test.ts +349 -0
  35. package/dist/templates/embedded-assets/agent.eval.test.ts +138 -0
  36. package/dist/templates/health-assistant/agent.eval.test.ts +167 -0
  37. package/dist/templates/infocom-adventure/agent.eval.test.ts +158 -0
  38. package/dist/templates/infocom-adventure/agent.test.ts +49 -18
  39. package/dist/templates/infocom-adventure/agent.ts +21 -0
  40. package/dist/templates/infocom-adventure/shared.ts +20 -9
  41. package/dist/templates/infocom-adventure/system-prompt.md +1 -1
  42. package/dist/templates/infocom-adventure/tools/game_state_move.ts +3 -2
  43. package/dist/templates/link-digest/agent.eval.test.ts +195 -0
  44. package/dist/templates/math-buddy/agent.eval.test.ts +146 -0
  45. package/dist/templates/night-owl/agent.eval.test.ts +272 -0
  46. package/dist/templates/personal-finance/agent.eval.test.ts +168 -0
  47. package/dist/templates/personal-finance/system-prompt.md +4 -0
  48. package/dist/templates/pipeline-simple/agent.eval.test.ts +78 -0
  49. package/dist/templates/pipeline-simple/agent.ts +2 -2
  50. package/dist/templates/pizza-ordering/agent.eval.test.ts +171 -0
  51. package/dist/templates/pizza-ordering/agent.test.ts +15 -0
  52. package/dist/templates/pizza-ordering/shared.ts +20 -1
  53. package/dist/templates/pizza-ordering/system-prompt.md +6 -0
  54. package/dist/templates/plan-and-execute/agent.eval.test.ts +209 -0
  55. package/dist/templates/podcast-digest/agent.eval.test.ts +411 -0
  56. package/dist/templates/podcast-digest/agent.test.ts +30 -59
  57. package/dist/templates/podcast-digest/agent.ts +1 -1
  58. package/dist/templates/podcast-digest/workflows/digest.ts +52 -19
  59. package/dist/templates/podcast-digest/workflows/slack.ts +48 -169
  60. package/dist/templates/recap-workflow/agent.eval.test.ts +366 -0
  61. package/dist/templates/redline/agent.eval.test.ts +209 -0
  62. package/dist/templates/research-workflow/agent.eval.test.ts +329 -0
  63. package/dist/templates/retail/address.ts +87 -1
  64. package/dist/templates/retail/agent.eval.test.ts +314 -0
  65. package/dist/templates/retail/agent.test.ts +171 -91
  66. package/dist/templates/retail/agent.ts +35 -1
  67. package/dist/templates/retail/cancel.ts +116 -0
  68. package/dist/templates/retail/client.tsx +39 -0
  69. package/dist/templates/retail/payment.ts +107 -0
  70. package/dist/templates/retail/pending.test.ts +196 -0
  71. package/dist/templates/retail/pending.ts +137 -0
  72. package/dist/templates/retail/registry.test.ts +119 -8
  73. package/dist/templates/retail/returns.ts +102 -0
  74. package/dist/templates/retail/shared.test.ts +42 -0
  75. package/dist/templates/retail/shared.ts +35 -0
  76. package/dist/templates/retail/store.test.ts +6 -4
  77. package/dist/templates/retail/store.ts +113 -57
  78. package/dist/templates/retail/swap.test.ts +4 -4
  79. package/dist/templates/retail/swap.ts +248 -15
  80. package/dist/templates/retail/system-prompt.md +62 -30
  81. package/dist/templates/retail/tools/cancel_change.ts +33 -0
  82. package/dist/templates/retail/tools/cancel_pending_order.ts +15 -59
  83. package/dist/templates/retail/tools/confirm_change.ts +42 -0
  84. package/dist/templates/retail/tools/exchange_delivered_order_items.ts +21 -66
  85. package/dist/templates/retail/tools/modify_pending_order_address.ts +12 -24
  86. package/dist/templates/retail/tools/modify_pending_order_items.ts +22 -60
  87. package/dist/templates/retail/tools/modify_pending_order_payment.ts +15 -77
  88. package/dist/templates/retail/tools/modify_user_address.ts +12 -15
  89. package/dist/templates/retail/tools/return_delivered_order_items.ts +16 -71
  90. package/dist/templates/retail/tools/transfer_to_human_agents.ts +4 -2
  91. package/dist/templates/simple/agent.eval.test.ts +54 -0
  92. package/dist/templates/solo-rpg/agent.eval.test.ts +320 -0
  93. package/dist/templates/spoken-summary/agent.eval.test.ts +342 -0
  94. package/dist/templates/support-line/agent.eval.test.ts +273 -0
  95. package/dist/templates/support-line/system-prompt.md +2 -1
  96. package/dist/templates/support-line/tools/answer_question.ts +56 -7
  97. package/dist/templates/transcription-workflow/agent.eval.test.ts +234 -0
  98. package/dist/templates/travel-concierge/agent.eval.test.ts +323 -0
  99. package/dist/templates/travel-concierge/agent.test.ts +110 -10
  100. package/dist/templates/travel-concierge/agent.ts +18 -9
  101. package/dist/templates/travel-concierge/shared.ts +110 -9
  102. package/dist/templates/travel-concierge/system-prompt.md +7 -1
  103. package/dist/templates/travel-concierge/tools/book_car_rental.ts +6 -3
  104. package/dist/templates/travel-concierge/tools/book_excursion.ts +6 -3
  105. package/dist/templates/travel-concierge/tools/book_hotel.ts +6 -3
  106. package/dist/templates/travel-concierge/tools/cancel_ticket.ts +6 -3
  107. package/dist/templates/travel-concierge/tools/search_car_rentals.ts +9 -5
  108. package/dist/templates/travel-concierge/tools/search_excursions.ts +9 -5
  109. package/dist/templates/travel-concierge/tools/search_flights.ts +9 -6
  110. package/dist/templates/travel-concierge/tools/search_hotels.ts +9 -5
  111. package/dist/templates/travel-concierge/tools/update_ticket.ts +6 -3
  112. package/dist/templates/web-researcher/agent.eval.test.ts +121 -0
  113. package/dist/templates/web-researcher/system-prompt.md +15 -4
  114. package/dist/{test-9kPKJI-w.mjs → test-BvyT6sAc.mjs} +24 -10
  115. package/dist/test.d.ts +29 -5
  116. package/dist/{worker-bundler-COxnqstQ.mjs → worker-bundler-CCVEDjm1.mjs} +45 -25
  117. package/dist/worker-bundler.mjs +1 -1
  118. package/dist/{workflow-D2AQf2Pl.mjs → workflow-BflATMmU.mjs} +19 -11
  119. package/package.json +5 -5
  120. package/dist/eject-C9WJyyr2.mjs +0 -116
  121. package/dist/eject.d.ts +0 -60
  122. package/dist/templates/infocom-adventure/tools/game_state_history.ts +0 -14
@@ -1,5 +1,5 @@
1
1
  import { agent } from "@alexkroman1/aai";
2
- import { anthropic } from "@alexkroman1/aai/llm";
2
+ import { anthropicLlm } from "@alexkroman1/aai/llm";
3
3
 
4
4
  export default agent({
5
5
  name: "pipeline-simple",
@@ -8,5 +8,5 @@ export default agent({
8
8
  // left unset (STT and TTS here) runs on the AssemblyAI default, billed to
9
9
  // the one key a published agent is guaranteed to have, so swapping a stage
10
10
  // never means restating the other two.
11
- llm: anthropic({ model: "claude-haiku-4-5" }),
11
+ llm: anthropicLlm({ model: "claude-haiku-4-5" }),
12
12
  });
@@ -0,0 +1,171 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ import type { SessionEvent } from "@alexkroman1/aai/protocol";
4
+ import { withDiscoveredTools } from "@alexkroman1/aai/testing";
5
+ // An EVAL: does the order-taker actually take the order?
6
+ //
7
+ // `agent.test.ts` drives the six tools directly, which settles what each one
8
+ // does with the arguments it is given. What no test in it can settle is whether
9
+ // the MODEL reaches for the right one, with the arguments the caller actually
10
+ // said — and for a cart that lives in a `sessionSlot`, whether what turn 1
11
+ // wrote is still there on turn 2. That is what this file is for.
12
+ //
13
+ // Run it with `aai eval`. Without a provider key every case runs against a
14
+ // SCRIPTED model (its `stubReply`), which still boots this agent, still
15
+ // resolves `tools/`, and still executes the tool a script names — so a stub run
16
+ // proves the wiring and proves nothing about what the agent chose.
17
+ import { lastStateIn, toolResultIn } from "@alexkroman1/aai-runtime/eval";
18
+ import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
19
+ import { expect } from "vitest";
20
+ import { z } from "zod";
21
+ import authoredAgent from "./agent.ts";
22
+ import { MENU } from "./shared.ts";
23
+
24
+ /**
25
+ * The def a DEPLOYED agent runs: authored, plus what `tools/` declares.
26
+ *
27
+ * The glob is written HERE rather than reached for from a shared helper because
28
+ * this file SHIPS — a scaffolded project has no repo helper to import. Without
29
+ * it the eval would drive an agent with no tools at all, which is the one
30
+ * failure mode a green eval must not be able to hide.
31
+ */
32
+ const agentDef = withDiscoveredTools(
33
+ authoredAgent,
34
+ import.meta.glob("./tools/*.ts", { eager: true }),
35
+ );
36
+
37
+ /**
38
+ * What the BROWSER is sent, as this eval reads it.
39
+ *
40
+ * Parsed rather than cast: `state.updated` carries `unknown`, so a projection
41
+ * that stopped matching FAILS naming the field, where the cast this replaced
42
+ * handed the assertions `undefined` and failed a line later on something else.
43
+ * It names only the fields asserted below, so `orderView` may grow without
44
+ * touching this.
45
+ */
46
+ const ProjectedOrder = z.object({
47
+ pizzas: z.array(z.object({ id: z.number(), size: z.string(), toppings: z.array(z.string()) })),
48
+ orderPlaced: z.boolean(),
49
+ });
50
+
51
+ /**
52
+ * The last cart `syncState` pushed to the browser, i.e. what the page shows.
53
+ *
54
+ * This agent declares ONE projection, so the frame IS `orderView`'s result —
55
+ * not a record keyed by the slot. That is the same value `useAgentState(
56
+ * orderProjection)` reads in `client.tsx`, which is why an eval can assert on
57
+ * it: it is the cart the customer is looking at. `lastStateIn` is the SDK's
58
+ * reader for exactly this.
59
+ */
60
+ const lastPushedView = (events: readonly SessionEvent[]) => lastStateIn(events, ProjectedOrder);
61
+
62
+ /** Every cart the session pushed, in stream order. */
63
+ const pushedViews = (events: readonly SessionEvent[]) =>
64
+ events.flatMap((e) => (e.type === "state.updated" ? [ProjectedOrder.parse(e.state)] : []));
65
+
66
+ describeEval(agentDef, (test) => {
67
+ test(
68
+ "adds the pizza the caller described, at the price the menu quotes",
69
+ async ({ session }) => {
70
+ const turn = await session.say(
71
+ "Hi, I'd like a large pepperoni pizza with extra cheese please.",
72
+ );
73
+
74
+ // One tool, and the right one: quoting a price without adding the pizza,
75
+ // or adding it twice, are both real findings.
76
+ expect(turn.toolCalls.map((c) => c.name)).toEqual(["add_pizza"]);
77
+ const call = turn.toolCalls[0]!;
78
+ const args = call.args as { size: string; crust: string; toppings: string[] };
79
+ expect(args.size).toBe("large");
80
+ // The two toppings the caller named, however the model spelled them.
81
+ const toppings = args.toppings.map((t) => t.toLowerCase().replaceAll(" ", "_"));
82
+ expect(toppings).toContain("pepperoni");
83
+ expect(toppings).toContain("extra_cheese");
84
+
85
+ // The claim the template's own comment makes: the menu is generated from
86
+ // MENU, so the agent can never quote a price the ordering code doesn't
87
+ // charge. Computed from the menu here rather than from the tool's own
88
+ // output, which is what gives it teeth — a topping name the pricing table
89
+ // doesn't recognise silently falls back to $1.00.
90
+ const expected =
91
+ MENU.sizes[args.size as keyof typeof MENU.sizes] +
92
+ MENU.crusts[args.crust as keyof typeof MENU.crusts] +
93
+ MENU.toppings.pepperoni +
94
+ MENU.toppings.extra_cheese;
95
+ expect(
96
+ toolResultIn(turn.toolCalls, "add_pizza", z.object({ orderTotal: z.string() })).orderTotal,
97
+ ).toBe(`$${expected.toFixed(2)}`);
98
+ },
99
+ {
100
+ stubReply: [
101
+ {
102
+ tool: "add_pizza",
103
+ args: {
104
+ size: "large",
105
+ crust: "regular",
106
+ toppings: ["pepperoni", "extra cheese"],
107
+ quantity: 1,
108
+ },
109
+ },
110
+ "Added a large pepperoni with extra cheese.",
111
+ ],
112
+ },
113
+ );
114
+
115
+ test(
116
+ "changes the pizza it added a turn ago, by the id that turn returned",
117
+ async ({ session }) => {
118
+ await session.say("Can I get a medium thin crust pizza with mushrooms?");
119
+ // The id only exists because turn 1 wrote it into the session's cart.
120
+ // Nothing in this utterance names it, so a slot that did not survive the
121
+ // turn leaves the model with nothing to address.
122
+ const turn = await session.say("Actually, make that a large.");
123
+
124
+ expect(turn.toolCalls.map((c) => c.name)).toEqual(["update_pizza"]);
125
+ const call = turn.toolCalls[0]!;
126
+ expect(call.args).toMatchObject({ pizza_id: 1, size: "large" });
127
+
128
+ // And the cart the page renders is the same one: still one pizza, resized
129
+ // rather than replaced, with the mushrooms from the first turn intact.
130
+ const view = lastPushedView(turn.events);
131
+ expect(view?.pizzas).toHaveLength(1);
132
+ expect(view?.pizzas[0]).toMatchObject({ id: 1, size: "large", toppings: ["mushrooms"] });
133
+ },
134
+ {
135
+ stubReply: [
136
+ {
137
+ tool: "add_pizza",
138
+ args: { size: "medium", crust: "thin", toppings: ["mushrooms"], quantity: 1 },
139
+ },
140
+ "A medium thin crust with mushrooms, coming up.",
141
+ { tool: "update_pizza", args: { pizza_id: 1, size: "large" } },
142
+ "Made it a large.",
143
+ ],
144
+ },
145
+ );
146
+
147
+ test(
148
+ "never places an empty order",
149
+ async ({ session }) => {
150
+ const turn = await session.say("That's everything, go ahead and place my order.");
151
+
152
+ // The model may check the cart first, or answer from the fact that
153
+ // nothing was ordered. What it may NOT do is get an order number: every
154
+ // place_order call on an empty cart has to come back refused, which is
155
+ // what stops the agent reading out a confirmation for nothing.
156
+ for (const call of turn.toolCalls.filter((c) => c.name === "place_order")) {
157
+ // `toolResultIn` over a ONE-CALL list: the name is this call's own, so
158
+ // the reader's "no such call" and "two calls" throws are unreachable and
159
+ // what is left is the parse plus the schema. The schema requires
160
+ // `error`, which is the claim — a call that SUCCEEDED fails here.
161
+ expect(toolResultIn([call], call.name, z.object({ error: z.string() })).error).toContain(
162
+ "Cannot place an empty order",
163
+ );
164
+ }
165
+ expect(turn.completed).toBe(true);
166
+ // Nothing the session pushed to the page may claim an order was placed.
167
+ expect(pushedViews(session.events()).filter((view) => view.orderPlaced)).toEqual([]);
168
+ },
169
+ { stubReply: [{ tool: "place_order" }, "There's nothing in your order yet."] },
170
+ );
171
+ });
@@ -26,6 +26,7 @@ const agentDef = withDiscoveredTools(
26
26
 
27
27
  import {
28
28
  calculateTotal,
29
+ MENU,
29
30
  orderProjection,
30
31
  orderSlot,
31
32
  orderView,
@@ -72,6 +73,20 @@ describe("pricing (shared.ts)", () => {
72
73
  expect(pizzaPrice({ id: 1, ...margherita })).toBeCloseTo(11.99, 5);
73
74
  });
74
75
 
76
+ test("a topping named the way the MENU PROSE spells it is charged menu price", () => {
77
+ // `menuText()` writes `extra_cheese` as "extra cheese", so that is what a
78
+ // model reading the prompt asks for. Before `toppingKey`, the table missed
79
+ // and charged the $1.00 unknown-topping default for a $1.50 topping —
80
+ // found live by `agent.eval.test.ts`, which priced the same pizza off MENU
81
+ // and got $17.99 against the tool's $17.49.
82
+ const keyed = pizzaPrice({ id: 1, ...margherita, toppings: ["extra_cheese"] });
83
+ expect(pizzaPrice({ id: 1, ...margherita, toppings: ["extra cheese"] })).toBeCloseTo(keyed, 5);
84
+ expect(pizzaPrice({ id: 1, ...margherita, toppings: ["Green Peppers"] })).toBeCloseTo(
85
+ 11.99 + MENU.toppings.green_peppers,
86
+ 5,
87
+ );
88
+ });
89
+
75
90
  test("unknown topping falls back to the $1.00 default", () => {
76
91
  const known = pizzaPrice({ id: 1, ...margherita, toppings: ["onions"] }); // $1.00 topping
77
92
  const unknown = pizzaPrice({ id: 1, ...margherita, toppings: ["dragonfruit"] });
@@ -62,11 +62,30 @@ export function menuText(): string {
62
62
  ].join("\n");
63
63
  }
64
64
 
65
+ /**
66
+ * A topping name as the price table keys it.
67
+ *
68
+ * `menuText()` renders `extra_cheese` as "extra cheese", so the menu the model
69
+ * reads and the table that charges for it are spelled differently — and the raw
70
+ * lookup below then missed and charged the $1.00 unknown-topping default for a
71
+ * $1.50 topping. `agent.eval.test.ts` found it against a live model: a large
72
+ * pepperoni with extra cheese was quoted at the menu's $17.99 and rung up at
73
+ * $17.49. Normalizing here keeps ONE spelling authoritative for pricing while
74
+ * the cart still stores what the caller actually said, which is what the
75
+ * sidebar and the read-back description show.
76
+ */
77
+ export function toppingKey(name: string): string {
78
+ return name
79
+ .trim()
80
+ .toLowerCase()
81
+ .replaceAll(/[\s-]+/g, "_");
82
+ }
83
+
65
84
  export function pizzaPrice(p: ReadonlyPizza): number {
66
85
  const base = MENU.sizes[p.size];
67
86
  const crust = MENU.crusts[p.crust];
68
87
  const toppings = p.toppings.reduce(
69
- (sum, t) => sum + (MENU.toppings[t as keyof typeof MENU.toppings] ?? 1.0),
88
+ (sum, t) => sum + (MENU.toppings[toppingKey(t) as keyof typeof MENU.toppings] ?? 1.0),
70
89
  0,
71
90
  );
72
91
  return (base + crust + toppings) * p.quantity;
@@ -8,6 +8,12 @@ Behavior:
8
8
  - When a customer wants a pizza, collect size, crust, and toppings, then use add_pizza to add it.
9
9
  - If they just say something like "pepperoni pizza", assume medium, regular crust, and confirm before adding.
10
10
  - Always confirm what you added after using add_pizza.
11
+ - NEVER say a pizza was added, changed, or removed unless the matching tool has
12
+ returned it. Saying "I've added that" with no tool call leaves the customer's
13
+ cart empty and their order unplaced.
14
+ - NEVER work out a price or a total yourself. Every price you say out loud comes
15
+ from a tool result — add_pizza, update_pizza, remove_pizza and view_order all
16
+ return the running total. If you have not called one, you do not have a total.
11
17
  - Use view_order when the customer asks to review their order.
12
18
  - Use update_pizza if they want to change an existing pizza.
13
19
  - Use remove_pizza if they want to remove one.
@@ -0,0 +1,209 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ // An EVAL: does the desk really plan, work one step, and replan? Run it with
4
+ // `aai eval`.
5
+ //
6
+ // `agent.test.ts` drives the three nodes and the four tools directly. What it
7
+ // cannot see is the LOOP as a caller drives it: whether the flow gate stops a
8
+ // step being worked before there is a plan, and whether one turn does exactly
9
+ // one step rather than running the plan to completion down a silent line. Both
10
+ // of those are read off the event stream here.
11
+ //
12
+ // **One thing a SCRIPTED run cannot reach: `ctx.generate`.** The stub model
13
+ // registers as an LLM provider, so `ctx.generate` resolves it — and the fake
14
+ // answers `doStream` only, so any tool that calls a model comes back
15
+ // `{"error":"The planner failed: fake LLM: doGenerate not implemented"}`. That
16
+ // is three of this template's four tools. So the two cases that run without a
17
+ // key are the ones about the GATE, which refuses before the body runs, and
18
+ // everything about the plan itself is `{ live: true }`.
19
+ //
20
+ // **`system-prompt.md` is applied HERE, not by `agent.ts`.** The build discovers
21
+ // the file, so an eval driving the raw default export would run this agent with
22
+ // the FRAMEWORK DEFAULT prompt — and the discipline that prompt imposes is the
23
+ // entire subject of this file. Measured against the default: the model answers
24
+ // product questions from its own knowledge and skips the tools the prompt exists
25
+ // to route it through, so a case run that way measures nothing it claims to.
26
+
27
+ import { withSystemPrompt } from "@alexkroman1/aai/manifest";
28
+ import { withDiscoveredTools } from "@alexkroman1/aai/testing";
29
+ import { type EvalSession, lastStateIn } from "@alexkroman1/aai-runtime/eval";
30
+ import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
31
+ import { expect } from "vitest";
32
+ import { z } from "zod";
33
+ import authoredAgent from "./agent.ts";
34
+ import systemPrompt from "./system-prompt.md?raw";
35
+
36
+ /** The def a DEPLOYED agent runs — see `agent.test.ts` on why the glob is here. */
37
+ const agentDef = withSystemPrompt(
38
+ withDiscoveredTools(authoredAgent, import.meta.glob("./tools/*.ts", { eager: true })),
39
+ systemPrompt,
40
+ );
41
+
42
+ /**
43
+ * What the BROWSER is sent, as this eval reads it.
44
+ *
45
+ * Parsed rather than cast: `state.updated` carries `unknown`, so a projection
46
+ * that stopped matching FAILS naming the field, where the cast this replaced
47
+ * handed the assertions `undefined` and failed a line later on something else.
48
+ * It names only the fields asserted below, so `planView` may grow without
49
+ * touching this.
50
+ */
51
+ const ProjectedPlan = z.object({
52
+ objective: z.string().nullable(),
53
+ plan: z.array(z.string()),
54
+ done: z.array(z.object({ step: z.string(), result: z.string() })),
55
+ response: z.string().nullable(),
56
+ revisions: z.array(z.string()),
57
+ progress: z.number(),
58
+ });
59
+
60
+ /**
61
+ * The latest `syncState` frame — what the browser would be rendering.
62
+ *
63
+ * `planProjection` rides out on `state.updated` after every tool call, so this
64
+ * is how an eval sees the plan at all: the session hands back events, and the
65
+ * projection is the one of them that carries state. `lastStateIn` is the SDK's
66
+ * reader for exactly this.
67
+ */
68
+ const planState = (session: EvalSession) => lastStateIn(session.events(), ProjectedPlan);
69
+
70
+ const named = (calls: readonly { name: string }[]): string[] => calls.map((call) => call.name);
71
+
72
+ describeEval(agentDef, (test) => {
73
+ test(
74
+ "the stage the desk reports is the flow's, not a guess at the plan",
75
+ async ({ session }) => {
76
+ const turn = await session.say("Where do we stand with all this?");
77
+
78
+ // `plan_status` is the one tool that reads the machine's own position, and
79
+ // it is legal in every state. Whatever the model did with the answer, the
80
+ // desk must not have invented a plan to report.
81
+ const view = planState(session);
82
+ expect(view?.objective ?? null).toBeNull();
83
+ expect(view?.progress ?? 0).toBe(0);
84
+
85
+ for (const call of turn.toolCalls.filter((one) => one.name === "plan_status")) {
86
+ // `stage` comes off `planFlow.position`, and `reads` off `stageLabel` —
87
+ // deriving either from `!plan.objective` a second time is the drift this
88
+ // template removed, and it would show up right here.
89
+ expect(call.result).toMatch(/"stage":"idle"/);
90
+ expect(call.result).toMatch(/no plan yet/);
91
+ expect(call.result).toMatch(/start_plan/);
92
+ }
93
+ },
94
+ { stubReply: [{ tool: "plan_status" }, "Nothing on the go yet — what are you trying to do?"] },
95
+ );
96
+
97
+ test(
98
+ "no step can be worked before a plan exists",
99
+ async ({ session, mode }) => {
100
+ const turn = await session.say("Skip the planning — just go and do the next step.");
101
+
102
+ const attempts = turn.toolCalls.filter((call) => call.name === "work_next_step");
103
+ // The script FORCES the call in stub mode, so the gate really runs; a live
104
+ // model that declines to call it has honoured the same rule a level
105
+ // earlier, which is why the count is asserted only where it is decided.
106
+ if (mode === "stub") expect(attempts).toHaveLength(1);
107
+ for (const attempt of attempts) {
108
+ // Refused BEFORE the body: `when: "working"` is what replaced the two
109
+ // hand-rolled guards, and its refusal names the position and quotes the
110
+ // state's instruction so the model can recover on its own turn.
111
+ expect(attempt.result).toMatch(/Not available yet/);
112
+ expect(attempt.result).toMatch(/idle/);
113
+ expect(attempt.result).toMatch(/start_plan/);
114
+ }
115
+ // And whatever else happened, nothing was ANSWERED: a desk that produced
116
+ // a response without working a step is the failure the flow exists to
117
+ // prevent.
118
+ expect(planState(session)?.response ?? null).toBeNull();
119
+ },
120
+ {
121
+ stubReply: [
122
+ { tool: "work_next_step" },
123
+ "There's no plan yet — tell me what you're trying to get done.",
124
+ ],
125
+ },
126
+ );
127
+
128
+ test(
129
+ "a step is worked once per call, and a worked step leaves the plan",
130
+ async ({ session }) => {
131
+ await session.say(
132
+ "I want to work out whether it is cheaper to take the train or fly from London to Edinburgh next month.",
133
+ );
134
+ const started = session.toolCalls().find((call) => call.name === "start_plan");
135
+ expect(started, `tools called: ${named(session.toolCalls()).join(", ")}`).toBeDefined();
136
+ const planned = planState(session);
137
+ // The tool's own result rides in the message: a planner that FAILED (a
138
+ // gateway error, a schema the provider would not honour) writes nothing,
139
+ // and "objective is null" on its own reads as a projection bug.
140
+ expect(planned?.objective, `start_plan answered: ${started?.result}`).toBeTruthy();
141
+ expect(planned?.plan.length ?? 0).toBeGreaterThan(0);
142
+
143
+ const worked = await session.say("Yes, go ahead and start on it.");
144
+ const calls = named(worked.toolCalls).filter((name) => name === "work_next_step").length;
145
+ expect(calls, `tools called: [${named(worked.toolCalls).join(", ")}]`).toBeGreaterThan(0);
146
+
147
+ const after = planState(session);
148
+ const done = after?.done ?? [];
149
+ expect(done.length).toBeGreaterThan(0);
150
+ // ONE STEP PER CALL: `work_next_step` is one execute-then-replan turn, not
151
+ // a loop. More steps recorded than calls made would mean the tool had run
152
+ // the plan to completion, leaving the caller on a silent line with no gap
153
+ // to change their mind in — which is the pause the whole design is for.
154
+ //
155
+ // Note this is the guarantee the template ENFORCES, and it is weaker than
156
+ // the one it asks for: "call this once per step, never in a loop" is in the
157
+ // system prompt AND the tool description, and a live model was measured
158
+ // calling it twice in one turn anyway. One session has one tool list and
159
+ // no per-turn hook, so the pause is asked for rather than enforced; what IS
160
+ // enforced is everything below.
161
+ expect(done.length).toBeLessThanOrEqual(calls);
162
+ for (const step of done) expect(step.result).toBeTruthy();
163
+ expect(after?.progress ?? 0).toBeGreaterThan(0);
164
+ // The step is CLAIMED off the head of the plan inside the synchronous
165
+ // update window, so no step can be done twice and a completed step is gone
166
+ // from what is left — that claim is what makes two concurrent calls safe.
167
+ const labels = done.map((step) => step.step);
168
+ expect(new Set(labels).size, `a step was worked twice: ${labels.join(" | ")}`).toBe(
169
+ labels.length,
170
+ );
171
+ for (const label of labels) expect(after?.plan ?? []).not.toContain(label);
172
+ },
173
+ // Live only: this step is a real model call and a real web search, which is
174
+ // the point — a scripted planner is a template that "plans" by imagining
175
+ // having looked something up.
176
+ { live: true },
177
+ );
178
+
179
+ test(
180
+ "when the caller changes their mind it is the replanner that rewrites the plan",
181
+ async ({ session }) => {
182
+ await session.say(
183
+ "I need to sort out getting from London to Edinburgh next month — train or flight, whichever works.",
184
+ );
185
+ const before = planState(session);
186
+ expect(before?.plan.length ?? 0).toBeGreaterThan(0);
187
+
188
+ const turn = await session.say("Actually, forget the train entirely — I only want to fly.");
189
+
190
+ const revised = turn.toolCalls.find((call) => call.name === "revise_plan");
191
+ expect(
192
+ revised,
193
+ `tools called: ${named(turn.toolCalls).join(", ")} — the caller changed the objective, ` +
194
+ "so this is `revise_plan`, not a plan rewritten by hand",
195
+ ).toBeDefined();
196
+ expect(revised?.args.instruction).toBeTruthy();
197
+
198
+ const after = planState(session);
199
+ // The trail is what the sidebar renders and what a caller cannot hold by
200
+ // ear: a revision the caller asked for is recorded as theirs.
201
+ expect(after?.revisions.some((entry) => entry.startsWith("Caller:"))).toBe(true);
202
+ // A revision reopens the plan rather than answering it, and completed
203
+ // steps are never redone — there are none here, so the whole plan is new.
204
+ expect(after?.plan).not.toEqual(before?.plan);
205
+ expect(after?.done).toHaveLength(0);
206
+ },
207
+ { live: true },
208
+ );
209
+ });