@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.
- package/dist/{_agent-DpH2pBJd.mjs → _agent-BTwZJu4P.mjs} +2 -2
- package/dist/{_bundler-C2BDwU7r.mjs → _bundler-B4RqNF5Z.mjs} +2 -2
- package/dist/_dev-env.d.ts +22 -2
- package/dist/{_dev-server-LLLWnFBs.mjs → _dev-server-CiKFH9qw.mjs} +38 -47
- package/dist/_dev-server.d.ts +0 -17
- package/dist/{_init-C57bWAQ8.mjs → _init-CE4HKdgV.mjs} +3 -2
- package/dist/{_preflight-BtfaYtbE.mjs → _preflight-Co1G9Jww.mjs} +1 -1
- package/dist/_preflight.d.ts +1 -1
- package/dist/{_slug-api-BxrsJXVI.mjs → _slug-api-nRFaBEPJ.mjs} +2 -2
- package/dist/{_studio-U3jhKlA3.mjs → _studio-DR5yzZ1T.mjs} +2 -1
- package/dist/{_templates-Dxb_P2Wz.mjs → _templates-FI3xPEMj.mjs} +1 -1
- package/dist/_workflow-scan.d.ts +108 -0
- package/dist/{build-Dfza2pRI.mjs → build-6jfaakgc.mjs} +5 -2
- package/dist/cli.mjs +30 -25
- package/dist/{client-bundler-BEIqgOtd.mjs → client-bundler-CWnG42cU.mjs} +2 -1
- package/dist/client-bundler.mjs +1 -1
- package/dist/{delete-vbpjGzqs.mjs → delete-Dl_LSfvI.mjs} +3 -3
- package/dist/{deploy-B8lubiRT.mjs → deploy-DvYeD8ia.mjs} +5 -5
- package/dist/{dev-Uq5ujP8a.mjs → dev-B59JIfWO.mjs} +1 -1
- package/dist/{eject-31gjtaHF.mjs → eject-C9WJyyr2.mjs} +1 -1
- package/dist/{init-l6xfU4xX.mjs → init-CpJVjEg0.mjs} +3 -3
- package/dist/{login-C71-qz8F.mjs → login-BBuM1sxH.mjs} +3 -4
- package/dist/{logs-BWVGSjLR.mjs → logs-9q-psa4q.mjs} +2 -2
- package/dist/project-config.mjs +1 -1
- package/dist/scaffold/CLAUDE.md +40 -19
- package/dist/scaffold/package.json +5 -3
- package/dist/scaffold/server.mjs +1 -1
- package/dist/{secret-BuMuFR4B.mjs → secret-CVvSLIDV.mjs} +2 -2
- package/dist/{storage-DzRZ-eCw.mjs → storage-BvUrnvM3.mjs} +2 -2
- package/dist/{studio-BCNUpDgP.mjs → studio--MUV0cid.mjs} +4 -4
- package/dist/templates/call-audit/agent.test.ts +57 -204
- package/dist/templates/call-audit/agent.ts +32 -19
- package/dist/templates/call-audit/client.tsx +17 -54
- package/dist/templates/call-audit/workflows/audit.ts +11 -18
- package/dist/templates/call-audit/workflows/ingest.ts +106 -114
- package/dist/templates/call-audit/workflows/media.ts +2 -12
- package/dist/templates/call-audit/workflows/summarize.ts +47 -52
- package/dist/templates/call-audit/workflows/sync-api.ts +7 -7
- package/dist/templates/dispatch-center/agent.test.ts +149 -25
- package/dist/templates/dispatch-center/client.tsx +239 -129
- package/dist/templates/dispatch-center/shared.ts +99 -1
- package/dist/templates/dispatch-center/system-prompt.md +3 -1
- package/dist/templates/dispatch-center/tools/incident_add_note.ts +16 -13
- package/dist/templates/dispatch-center/tools/incident_create.ts +19 -1
- package/dist/templates/dispatch-center/tools/incident_escalate.ts +68 -59
- package/dist/templates/dispatch-center/tools/incident_triage.ts +57 -43
- package/dist/templates/dispatch-center/tools/incident_update_status.ts +49 -40
- package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +8 -2
- package/dist/templates/dispatch-center/tools/resources_dispatch.ts +69 -52
- package/dist/templates/dispatch-center/tools/resources_update_status.ts +45 -41
- package/dist/templates/embedded-assets/agent.test.ts +17 -14
- package/dist/templates/health-assistant/agent.test.ts +22 -4
- package/dist/templates/infocom-adventure/agent.test.ts +4 -6
- package/dist/templates/infocom-adventure/client.tsx +246 -164
- package/dist/templates/link-digest/agent.test.ts +24 -19
- package/dist/templates/link-digest/client.tsx +47 -61
- package/dist/templates/link-digest/workflows/digest.ts +19 -31
- package/dist/templates/night-owl/agent.test.ts +70 -19
- package/dist/templates/night-owl/agent.ts +5 -0
- package/dist/templates/night-owl/client.tsx +56 -56
- package/dist/templates/night-owl/shared.ts +24 -0
- package/dist/templates/night-owl/tools/recommend.ts +18 -6
- package/dist/templates/pizza-ordering/agent.test.ts +27 -27
- package/dist/templates/pizza-ordering/client.tsx +9 -26
- package/dist/templates/plan-and-execute/agent.test.ts +99 -58
- package/dist/templates/plan-and-execute/agent.ts +1 -1
- package/dist/templates/plan-and-execute/client.tsx +12 -15
- package/dist/templates/plan-and-execute/shared.ts +71 -2
- package/dist/templates/plan-and-execute/tools/plan_status.ts +18 -6
- package/dist/templates/plan-and-execute/tools/revise_plan.ts +18 -10
- package/dist/templates/plan-and-execute/tools/start_plan.ts +13 -2
- package/dist/templates/plan-and-execute/tools/work_next_step.ts +70 -22
- package/dist/templates/podcast-digest/agent.test.ts +746 -0
- package/dist/templates/podcast-digest/agent.ts +139 -0
- package/dist/templates/podcast-digest/client.tsx +154 -0
- package/dist/templates/podcast-digest/workflows/digest.ts +411 -0
- package/dist/templates/podcast-digest/workflows/feeds.ts +507 -0
- package/dist/templates/podcast-digest/workflows/slack.ts +209 -0
- package/dist/templates/recap-workflow/agent.test.ts +97 -94
- package/dist/templates/recap-workflow/tools/recap_progress.ts +9 -12
- package/dist/templates/recap-workflow/tools/recap_status.ts +8 -9
- package/dist/templates/recap-workflow/tools/request_recap.ts +2 -1
- package/dist/templates/recap-workflow/workflows/recap.ts +36 -40
- package/dist/templates/redline/agent.test.ts +17 -15
- package/dist/templates/redline/client.tsx +12 -12
- package/dist/templates/redline/workflows/redline.ts +19 -31
- package/dist/templates/research-workflow/agent.test.ts +60 -59
- package/dist/templates/research-workflow/tools/research_progress.ts +9 -12
- package/dist/templates/research-workflow/tools/research_status.ts +9 -11
- package/dist/templates/research-workflow/workflows/research.ts +44 -61
- package/dist/templates/retail/agent.test.ts +26 -23
- package/dist/templates/retail/client.tsx +226 -117
- package/dist/templates/retail/registry.test.ts +38 -6
- package/dist/templates/retail/store.test.ts +82 -15
- package/dist/templates/retail/store.ts +174 -47
- package/dist/templates/retail/system-prompt.md +11 -2
- package/dist/templates/retail/tools/cancel_pending_order.ts +2 -2
- package/dist/templates/retail/tools/exchange_delivered_order_items.ts +2 -2
- package/dist/templates/retail/tools/find_user_id_by_email.ts +8 -5
- package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +4 -5
- package/dist/templates/retail/tools/get_item_details.ts +3 -4
- package/dist/templates/retail/tools/get_order_details.ts +3 -4
- package/dist/templates/retail/tools/get_product_details.ts +3 -4
- package/dist/templates/retail/tools/get_user_details.ts +2 -2
- package/dist/templates/retail/tools/list_all_product_types.ts +5 -5
- package/dist/templates/retail/tools/modify_pending_order_address.ts +2 -2
- package/dist/templates/retail/tools/modify_pending_order_items.ts +2 -3
- package/dist/templates/retail/tools/modify_pending_order_payment.ts +2 -2
- package/dist/templates/retail/tools/modify_user_address.ts +2 -2
- package/dist/templates/retail/tools/return_delivered_order_items.ts +2 -2
- package/dist/templates/retail/tools/transfer_to_human_agents.ts +8 -4
- package/dist/templates/solo-rpg/agent.test.ts +227 -48
- package/dist/templates/solo-rpg/client.tsx +38 -37
- package/dist/templates/solo-rpg/shared.ts +145 -19
- package/dist/templates/solo-rpg/system-prompt.md +3 -2
- package/dist/templates/solo-rpg/tools/action_roll.ts +82 -63
- package/dist/templates/solo-rpg/tools/burn_momentum.ts +85 -50
- package/dist/templates/solo-rpg/tools/check_state.ts +24 -7
- package/dist/templates/solo-rpg/tools/load_game.ts +13 -1
- package/dist/templates/solo-rpg/tools/save_game.ts +16 -7
- package/dist/templates/solo-rpg/tools/setup_character.ts +22 -2
- package/dist/templates/solo-rpg/tools/update_state.ts +113 -100
- package/dist/templates/spoken-summary/agent.test.ts +68 -97
- package/dist/templates/spoken-summary/agent.ts +30 -17
- package/dist/templates/spoken-summary/client.tsx +10 -50
- package/dist/templates/spoken-summary/workflows/summarize.ts +17 -22
- package/dist/templates/spoken-summary/workflows/transcribe.ts +19 -26
- package/dist/templates/support-line/agent.test.ts +11 -16
- package/dist/templates/support-line/agent.ts +1 -1
- package/dist/templates/support-line/client.tsx +9 -9
- package/dist/templates/support-line/nodes.ts +100 -0
- package/dist/templates/support-line/procedure.ts +407 -0
- package/dist/templates/support-line/tools/answer_question.ts +17 -4
- package/dist/templates/transcription-workflow/agent.test.ts +94 -194
- package/dist/templates/transcription-workflow/agent.ts +1 -1
- package/dist/templates/transcription-workflow/client.tsx +17 -42
- package/dist/templates/transcription-workflow/workflows/batch.ts +19 -22
- package/dist/templates/transcription-workflow/workflows/normalize.ts +90 -186
- package/dist/templates/transcription-workflow/workflows/stitch.ts +0 -11
- package/dist/templates/transcription-workflow/workflows/stream.ts +4 -5
- package/dist/templates/transcription-workflow/workflows/sync-api.ts +6 -6
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +32 -26
- package/dist/templates/transcription-workflow/workflows/wav.ts +0 -31
- package/dist/templates/travel-concierge/agent.test.ts +64 -33
- package/dist/templates/travel-concierge/client.tsx +11 -23
- package/dist/templates/travel-concierge/routing.ts +34 -15
- package/dist/templates/travel-concierge/shared.ts +70 -3
- package/dist/templates/travel-concierge/tools/book_car_rental.ts +2 -2
- package/dist/templates/travel-concierge/tools/book_excursion.ts +2 -2
- package/dist/templates/travel-concierge/tools/book_hotel.ts +2 -2
- package/dist/templates/travel-concierge/tools/cancel_ticket.ts +2 -2
- package/dist/templates/travel-concierge/tools/update_ticket.ts +2 -2
- package/dist/{worker-bundler-CGD4r8Kc.mjs → worker-bundler-COxnqstQ.mjs} +221 -3
- package/dist/worker-bundler.mjs +1 -1
- package/dist/{workflow-CFpxOFfQ.mjs → workflow-D2AQf2Pl.mjs} +28 -1
- package/dist/workflow-bundler.d.ts +6 -1
- package/dist/workflow.d.ts +1 -1
- package/package.json +5 -4
- package/dist/templates/call-audit/workflows/temp-media.ts +0 -138
- package/dist/templates/support-line/graph.ts +0 -224
- package/dist/{_config-D_s09e7g.mjs → _config-CmJOFsAP.mjs} +1 -1
- /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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
*
|
|
39
|
-
* what is local is only which
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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",
|
|
105
|
-
expect(await run("place_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",
|
|
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",
|
|
159
|
-
expect(await run("place_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",
|
|
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",
|
|
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",
|
|
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",
|
|
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
|
|
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">✓</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"
|
|
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"
|
|
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
|
|
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
|
|
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"
|
|
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 {
|
|
3
|
+
import type { ToolContext } from "@alexkroman1/aai";
|
|
4
4
|
import {
|
|
5
5
|
createToolContext,
|
|
6
|
-
|
|
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 "./
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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 =
|
|
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("
|
|
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 =
|
|
220
|
-
|
|
221
|
-
|
|
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 =
|
|
236
|
+
const ctx = createToolContext({ generate });
|
|
232
237
|
await run("start_plan", { objective: "a weekend in Lisbon" }, ctx);
|
|
233
238
|
|
|
234
|
-
const first =
|
|
235
|
-
|
|
239
|
+
const first = okPosition<{
|
|
240
|
+
finished: boolean;
|
|
236
241
|
step: string;
|
|
237
242
|
result: string;
|
|
238
243
|
remaining: string[];
|
|
239
|
-
};
|
|
240
|
-
expect(first.
|
|
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 =
|
|
264
|
+
const ctx = createToolContext({ generate });
|
|
258
265
|
await run("start_plan", { objective: "a weekend in Lisbon" }, ctx);
|
|
259
|
-
const
|
|
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
|
-
|
|
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 =
|
|
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",
|
|
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 =
|
|
306
|
-
const second =
|
|
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
|
-
|
|
311
|
-
|
|
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 =
|
|
345
|
+
const ctx = createToolContext({ generate });
|
|
327
346
|
await run("start_plan", { objective: "a weekend in Lisbon" }, ctx);
|
|
328
|
-
await run("work_next_step",
|
|
347
|
+
await run("work_next_step", ctx);
|
|
329
348
|
expect(stateOf(ctx).response).not.toBeNull();
|
|
330
349
|
|
|
331
|
-
const revised =
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
expect(revised.
|
|
336
|
-
|
|
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("
|
|
370
|
+
test("is refused before there is a plan", async () => {
|
|
350
371
|
const { generate } = scriptedModel();
|
|
351
|
-
const ctx =
|
|
352
|
-
expect(await run("revise_plan", { instruction: "change it" }, ctx)).
|
|
353
|
-
error:
|
|
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 =
|
|
366
|
-
|
|
367
|
-
|
|
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",
|
|
372
|
-
expect(await run("plan_status",
|
|
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 =
|
|
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",
|
|
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, `
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)}
|
|
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
|
|
49
|
+
<div key={past.step} className="rounded-lg p-3 bg-aai-surface">
|
|
49
50
|
<p className="text-sm">
|
|
50
|
-
<span
|
|
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>
|