@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.
- package/README.md +1 -0
- package/dist/{_bundler-B4RqNF5Z.mjs → _bundler-CDuYl5Gb.mjs} +1 -1
- package/dist/{_dev-server-CiKFH9qw.mjs → _dev-server-Dt3TCjhj.mjs} +6 -2
- package/dist/{_init-CE4HKdgV.mjs → _init-BhEfQ6Yi.mjs} +1 -1
- package/dist/_resource-commands.d.ts +23 -0
- package/dist/{_slug-api-nRFaBEPJ.mjs → _slug-api-z-USxPtL.mjs} +14 -10
- package/dist/_slug-api.d.ts +5 -2
- package/dist/{_templates-FI3xPEMj.mjs → _templates-4WcKOjS5.mjs} +4 -5
- package/dist/_templates.d.ts +3 -4
- package/dist/{build-6jfaakgc.mjs → build-BBxf8vom.mjs} +2 -2
- package/dist/cli.mjs +338 -346
- package/dist/{deploy-DvYeD8ia.mjs → deploy-DRC8jWr8.mjs} +1 -1
- package/dist/{dev-B59JIfWO.mjs → dev-QuuOOUtv.mjs} +1 -1
- package/dist/eval-DfQ8cm5p.mjs +56 -0
- package/dist/eval.d.ts +43 -0
- package/dist/{init-CpJVjEg0.mjs → init-CUo87gcI.mjs} +2 -2
- package/dist/{logs-9q-psa4q.mjs → logs-CQ2T46dr.mjs} +1 -1
- package/dist/scaffold/CLAUDE.md +409 -68
- package/dist/scaffold/package.json +5 -4
- package/dist/scaffold/server.mjs +10 -3
- package/dist/scaffold/tsconfig.json +0 -1
- package/dist/{secret-CVvSLIDV.mjs → secret-Ct34kdgU.mjs} +1 -1
- package/dist/{storage-BvUrnvM3.mjs → storage-i_79ZwAR.mjs} +1 -1
- package/dist/{studio--MUV0cid.mjs → studio-CzTzPqe1.mjs} +1 -1
- package/dist/templates/briefing-desk/agent.test.ts +350 -0
- package/dist/templates/briefing-desk/agent.ts +30 -0
- package/dist/templates/briefing-desk/shared.ts +200 -0
- package/dist/templates/briefing-desk/system-prompt.md +39 -0
- package/dist/templates/briefing-desk/tools/briefing_so_far.ts +35 -0
- package/dist/templates/briefing-desk/tools/research_topic.ts +88 -0
- package/dist/templates/briefing-desk/tools/verify_claim.ts +77 -0
- package/dist/templates/call-audit/agent.eval.test.ts +474 -0
- package/dist/templates/code-interpreter/agent.eval.test.ts +165 -0
- package/dist/templates/dispatch-center/agent.eval.test.ts +349 -0
- package/dist/templates/embedded-assets/agent.eval.test.ts +138 -0
- package/dist/templates/health-assistant/agent.eval.test.ts +167 -0
- package/dist/templates/infocom-adventure/agent.eval.test.ts +158 -0
- package/dist/templates/infocom-adventure/agent.test.ts +49 -18
- package/dist/templates/infocom-adventure/agent.ts +21 -0
- package/dist/templates/infocom-adventure/shared.ts +20 -9
- package/dist/templates/infocom-adventure/system-prompt.md +1 -1
- package/dist/templates/infocom-adventure/tools/game_state_move.ts +3 -2
- package/dist/templates/link-digest/agent.eval.test.ts +195 -0
- package/dist/templates/math-buddy/agent.eval.test.ts +146 -0
- package/dist/templates/night-owl/agent.eval.test.ts +272 -0
- package/dist/templates/personal-finance/agent.eval.test.ts +168 -0
- package/dist/templates/personal-finance/system-prompt.md +4 -0
- package/dist/templates/pipeline-simple/agent.eval.test.ts +78 -0
- package/dist/templates/pipeline-simple/agent.ts +2 -2
- package/dist/templates/pizza-ordering/agent.eval.test.ts +171 -0
- package/dist/templates/pizza-ordering/agent.test.ts +15 -0
- package/dist/templates/pizza-ordering/shared.ts +20 -1
- package/dist/templates/pizza-ordering/system-prompt.md +6 -0
- package/dist/templates/plan-and-execute/agent.eval.test.ts +209 -0
- package/dist/templates/podcast-digest/agent.eval.test.ts +411 -0
- package/dist/templates/podcast-digest/agent.test.ts +30 -59
- package/dist/templates/podcast-digest/agent.ts +1 -1
- package/dist/templates/podcast-digest/workflows/digest.ts +52 -19
- package/dist/templates/podcast-digest/workflows/slack.ts +48 -169
- package/dist/templates/recap-workflow/agent.eval.test.ts +366 -0
- package/dist/templates/redline/agent.eval.test.ts +209 -0
- package/dist/templates/research-workflow/agent.eval.test.ts +329 -0
- package/dist/templates/retail/address.ts +87 -1
- package/dist/templates/retail/agent.eval.test.ts +314 -0
- package/dist/templates/retail/agent.test.ts +171 -91
- package/dist/templates/retail/agent.ts +35 -1
- package/dist/templates/retail/cancel.ts +116 -0
- package/dist/templates/retail/client.tsx +39 -0
- package/dist/templates/retail/payment.ts +107 -0
- package/dist/templates/retail/pending.test.ts +196 -0
- package/dist/templates/retail/pending.ts +137 -0
- package/dist/templates/retail/registry.test.ts +119 -8
- package/dist/templates/retail/returns.ts +102 -0
- package/dist/templates/retail/shared.test.ts +42 -0
- package/dist/templates/retail/shared.ts +35 -0
- package/dist/templates/retail/store.test.ts +6 -4
- package/dist/templates/retail/store.ts +113 -57
- package/dist/templates/retail/swap.test.ts +4 -4
- package/dist/templates/retail/swap.ts +248 -15
- package/dist/templates/retail/system-prompt.md +62 -30
- package/dist/templates/retail/tools/cancel_change.ts +33 -0
- package/dist/templates/retail/tools/cancel_pending_order.ts +15 -59
- package/dist/templates/retail/tools/confirm_change.ts +42 -0
- package/dist/templates/retail/tools/exchange_delivered_order_items.ts +21 -66
- package/dist/templates/retail/tools/modify_pending_order_address.ts +12 -24
- package/dist/templates/retail/tools/modify_pending_order_items.ts +22 -60
- package/dist/templates/retail/tools/modify_pending_order_payment.ts +15 -77
- package/dist/templates/retail/tools/modify_user_address.ts +12 -15
- package/dist/templates/retail/tools/return_delivered_order_items.ts +16 -71
- package/dist/templates/retail/tools/transfer_to_human_agents.ts +4 -2
- package/dist/templates/simple/agent.eval.test.ts +54 -0
- package/dist/templates/solo-rpg/agent.eval.test.ts +320 -0
- package/dist/templates/spoken-summary/agent.eval.test.ts +342 -0
- package/dist/templates/support-line/agent.eval.test.ts +273 -0
- package/dist/templates/support-line/system-prompt.md +2 -1
- package/dist/templates/support-line/tools/answer_question.ts +56 -7
- package/dist/templates/transcription-workflow/agent.eval.test.ts +234 -0
- package/dist/templates/travel-concierge/agent.eval.test.ts +323 -0
- package/dist/templates/travel-concierge/agent.test.ts +110 -10
- package/dist/templates/travel-concierge/agent.ts +18 -9
- package/dist/templates/travel-concierge/shared.ts +110 -9
- package/dist/templates/travel-concierge/system-prompt.md +7 -1
- package/dist/templates/travel-concierge/tools/book_car_rental.ts +6 -3
- package/dist/templates/travel-concierge/tools/book_excursion.ts +6 -3
- package/dist/templates/travel-concierge/tools/book_hotel.ts +6 -3
- package/dist/templates/travel-concierge/tools/cancel_ticket.ts +6 -3
- package/dist/templates/travel-concierge/tools/search_car_rentals.ts +9 -5
- package/dist/templates/travel-concierge/tools/search_excursions.ts +9 -5
- package/dist/templates/travel-concierge/tools/search_flights.ts +9 -6
- package/dist/templates/travel-concierge/tools/search_hotels.ts +9 -5
- package/dist/templates/travel-concierge/tools/update_ticket.ts +6 -3
- package/dist/templates/web-researcher/agent.eval.test.ts +121 -0
- package/dist/templates/web-researcher/system-prompt.md +15 -4
- package/dist/{test-9kPKJI-w.mjs → test-BvyT6sAc.mjs} +24 -10
- package/dist/test.d.ts +29 -5
- package/dist/{worker-bundler-COxnqstQ.mjs → worker-bundler-CCVEDjm1.mjs} +45 -25
- package/dist/worker-bundler.mjs +1 -1
- package/dist/{workflow-D2AQf2Pl.mjs → workflow-BflATMmU.mjs} +19 -11
- package/package.json +5 -5
- package/dist/eject-C9WJyyr2.mjs +0 -116
- package/dist/eject.d.ts +0 -60
- package/dist/templates/infocom-adventure/tools/game_state_history.ts +0 -14
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cancelling a pending order, as a PLAN and an APPLY.
|
|
3
|
+
*
|
|
4
|
+
* The split is what the confirmation gate is built out of, and it is the same
|
|
5
|
+
* split in all seven mutating actions here: `plan*` validates everything and
|
|
6
|
+
* computes the effect without touching the store, `apply*` performs it and
|
|
7
|
+
* cannot fail. A staged plan is what the caller says yes to, so the validation
|
|
8
|
+
* has to happen BEFORE the readback — a "yes" followed by a refusal is the one
|
|
9
|
+
* sequence this gate exists to make impossible.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { isToolFailure, type ToolFailure } from "@alexkroman1/aai";
|
|
13
|
+
import { creditRefund, REFUND_DELAY_NOTE, REFUND_IMMEDIATE_NOTE } from "./refund.ts";
|
|
14
|
+
import { resolveOrder } from "./resolve.ts";
|
|
15
|
+
import type { RetailState } from "./shared.ts";
|
|
16
|
+
import { authenticatedUser, money } from "./store.ts";
|
|
17
|
+
|
|
18
|
+
/** The only two reasons the store records. */
|
|
19
|
+
export const CANCEL_REASONS = ["no longer needed", "ordered by mistake"] as const;
|
|
20
|
+
export type CancelReason = (typeof CANCEL_REASONS)[number];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A validated cancellation, holding only storable primitives.
|
|
24
|
+
*
|
|
25
|
+
* No `Order` or `PaymentMethod` reference: a plan is written into the session
|
|
26
|
+
* slot and rides across turns, so a live object here would alias the store and
|
|
27
|
+
* a persisted session could not carry it. Ids and amounts are enough, and
|
|
28
|
+
* looking them up again at apply time is what keeps {@link applyCancel}
|
|
29
|
+
* total.
|
|
30
|
+
*/
|
|
31
|
+
export interface CancelPlan {
|
|
32
|
+
/** The sentence the agent reads back before asking for a yes. */
|
|
33
|
+
readBack: string;
|
|
34
|
+
orderId: string;
|
|
35
|
+
reason: CancelReason;
|
|
36
|
+
refunds: { methodId: string; amount: number }[];
|
|
37
|
+
total: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function planCancel(
|
|
41
|
+
state: RetailState,
|
|
42
|
+
spokenOrderId: string,
|
|
43
|
+
reason: CancelReason,
|
|
44
|
+
): CancelPlan | ToolFailure {
|
|
45
|
+
const user = authenticatedUser(state);
|
|
46
|
+
if (isToolFailure(user)) return user;
|
|
47
|
+
|
|
48
|
+
const order = resolveOrder(state, spokenOrderId);
|
|
49
|
+
if (isToolFailure(order)) return order;
|
|
50
|
+
|
|
51
|
+
// The enum makes this unreachable from a well-formed call; it stays because
|
|
52
|
+
// the reason is the one field a caller supplies in free speech, and an LLM
|
|
53
|
+
// tool call arrives untyped.
|
|
54
|
+
if (!CANCEL_REASONS.includes(reason)) {
|
|
55
|
+
return {
|
|
56
|
+
error: `'${reason}' is not an accepted cancellation reason. It must be 'no longer needed' or 'ordered by mistake'.`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Exactly 'pending'. A 'pending (item modified)' order has spent its one
|
|
61
|
+
// modification and is past cancelling.
|
|
62
|
+
if (order.status !== "pending") {
|
|
63
|
+
return {
|
|
64
|
+
error: `Order ${order.order_id} is ${order.status}, and only a pending order can be cancelled.`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const refunds = order.payment_history
|
|
69
|
+
.filter((payment) => payment.transaction_type === "payment")
|
|
70
|
+
.map((payment) => ({ methodId: payment.payment_method_id, amount: payment.amount }));
|
|
71
|
+
const total = money(refunds.reduce((sum, refund) => sum + refund.amount, 0));
|
|
72
|
+
|
|
73
|
+
const items = order.items.map((item) => item.name).join(", ");
|
|
74
|
+
const destinations = [...new Set(refunds.map((refund) => refund.methodId))].join(" and ");
|
|
75
|
+
return {
|
|
76
|
+
readBack:
|
|
77
|
+
`cancel order ${order.order_id} (${items}) as '${reason}' and refund ` +
|
|
78
|
+
`$${total.toFixed(2)} to ${destinations}`,
|
|
79
|
+
orderId: order.order_id,
|
|
80
|
+
reason,
|
|
81
|
+
refunds,
|
|
82
|
+
total,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The effect. Total by construction: every id in the plan was resolved from
|
|
87
|
+
* this same store, and nothing between staging and confirming can remove one. */
|
|
88
|
+
export function applyCancel(state: RetailState, plan: CancelPlan) {
|
|
89
|
+
const order = state.store.orders[plan.orderId];
|
|
90
|
+
const user = order ? state.store.users[order.user_id] : undefined;
|
|
91
|
+
let immediate = false;
|
|
92
|
+
|
|
93
|
+
if (order && user) {
|
|
94
|
+
for (const refund of plan.refunds) {
|
|
95
|
+
immediate = creditRefund(user, refund.methodId, refund.amount).immediate || immediate;
|
|
96
|
+
order.payment_history.push({
|
|
97
|
+
transaction_type: "refund",
|
|
98
|
+
amount: refund.amount,
|
|
99
|
+
payment_method_id: refund.methodId,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
order.status = "cancelled";
|
|
103
|
+
order.cancel_reason = plan.reason;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
order_id: plan.orderId,
|
|
108
|
+
status: "cancelled" as const,
|
|
109
|
+
cancel_reason: plan.reason,
|
|
110
|
+
refunded: plan.total,
|
|
111
|
+
refund_immediate: immediate,
|
|
112
|
+
message: `Order ${plan.orderId} is cancelled and $${plan.total.toFixed(2)} is being refunded. ${
|
|
113
|
+
immediate ? REFUND_IMMEDIATE_NOTE : REFUND_DELAY_NOTE
|
|
114
|
+
}`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
OrderStatus,
|
|
14
14
|
OrderView,
|
|
15
15
|
PaymentMethodView,
|
|
16
|
+
PendingView,
|
|
16
17
|
StoreView,
|
|
17
18
|
SwapOptionView,
|
|
18
19
|
} from "./shared.ts";
|
|
@@ -155,6 +156,42 @@ function OrderCard({ order, focused }: { order: OrderView; focused: boolean }) {
|
|
|
155
156
|
|
|
156
157
|
/** Character select. Rendered before authentication, because a user with no
|
|
157
158
|
* email to give cannot start the conversation at all. */
|
|
159
|
+
/**
|
|
160
|
+
* The change waiting on the caller's word.
|
|
161
|
+
*
|
|
162
|
+
* The one panel that renders BECAUSE nothing has happened. A staged change is
|
|
163
|
+
* the agent's promise about what it is going to do, so showing it beside the
|
|
164
|
+
* orders it has not touched is what lets a watcher catch a readback that does
|
|
165
|
+
* not match the request — which is the failure the whole gate is aimed at, and
|
|
166
|
+
* the one nobody can see from a transcript alone.
|
|
167
|
+
*/
|
|
168
|
+
function PendingChange({ pending }: { pending: PendingView }) {
|
|
169
|
+
return (
|
|
170
|
+
<div
|
|
171
|
+
className="rounded-lg p-3"
|
|
172
|
+
style={{
|
|
173
|
+
background: "#fffbeb",
|
|
174
|
+
border: "1px solid #fcd34d",
|
|
175
|
+
animation: "rt-slide-in 180ms ease-out",
|
|
176
|
+
}}
|
|
177
|
+
>
|
|
178
|
+
<div
|
|
179
|
+
className="text-[10px] font-semibold uppercase tracking-wider mb-1.5 flex items-center gap-1.5"
|
|
180
|
+
style={{ color: "#b45309" }}
|
|
181
|
+
>
|
|
182
|
+
<span style={{ animation: "rt-pulse 1.4s ease-in-out infinite" }}>●</span>
|
|
183
|
+
Awaiting the caller's yes
|
|
184
|
+
</div>
|
|
185
|
+
<div className="text-xs leading-relaxed" style={{ color: "#78350f" }}>
|
|
186
|
+
{pending.readBack}
|
|
187
|
+
</div>
|
|
188
|
+
<div className="text-[10px] mt-1.5" style={{ color: "#a16207" }}>
|
|
189
|
+
Nothing has changed yet — {pending.kind}
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
158
195
|
function PersonaList() {
|
|
159
196
|
return (
|
|
160
197
|
<div className="flex flex-col gap-2">
|
|
@@ -489,6 +526,8 @@ function App() {
|
|
|
489
526
|
className="rt-scroll overflow-y-auto p-3 flex flex-col gap-3"
|
|
490
527
|
style={{ background: "#f4f4f5" }}
|
|
491
528
|
>
|
|
529
|
+
{view.pending && <PendingChange pending={view.pending} />}
|
|
530
|
+
|
|
492
531
|
{!view.customer && (
|
|
493
532
|
<Panel title="Who to be">
|
|
494
533
|
<PersonaList />
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Changing which payment method a pending order is charged to, as a plan and an
|
|
3
|
+
* apply (see `cancel.ts` for why every mutating action is split that way).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { isToolFailure, type ToolFailure } from "@alexkroman1/aai";
|
|
7
|
+
import { resolveOrder } from "./resolve.ts";
|
|
8
|
+
import type { RetailState } from "./shared.ts";
|
|
9
|
+
import { authenticatedUser, findPaymentMethod, isGiftCard, money } from "./store.ts";
|
|
10
|
+
|
|
11
|
+
export interface PaymentPlan {
|
|
12
|
+
readBack: string;
|
|
13
|
+
orderId: string;
|
|
14
|
+
/** Where the order is charged after the change. */
|
|
15
|
+
newMethodId: string;
|
|
16
|
+
/** Where the original charge goes back to. */
|
|
17
|
+
oldMethodId: string;
|
|
18
|
+
amount: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function planPayment(
|
|
22
|
+
state: RetailState,
|
|
23
|
+
spokenOrderId: string,
|
|
24
|
+
newMethodId: string,
|
|
25
|
+
): PaymentPlan | ToolFailure {
|
|
26
|
+
const user = authenticatedUser(state);
|
|
27
|
+
if (isToolFailure(user)) return user;
|
|
28
|
+
|
|
29
|
+
const order = resolveOrder(state, spokenOrderId);
|
|
30
|
+
if (isToolFailure(order)) return order;
|
|
31
|
+
|
|
32
|
+
if (!order.status.startsWith("pending")) {
|
|
33
|
+
return {
|
|
34
|
+
error: `Order ${order.order_id} is ${order.status}, and only a pending order's payment method can be changed.`,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const newMethod = findPaymentMethod(user, newMethodId);
|
|
39
|
+
if (isToolFailure(newMethod)) return newMethod;
|
|
40
|
+
|
|
41
|
+
const original = order.payment_history[0];
|
|
42
|
+
if (order.payment_history.length !== 1 || original?.transaction_type !== "payment") {
|
|
43
|
+
return {
|
|
44
|
+
error: `Order ${order.order_id} does not have exactly one payment on record, so its payment method cannot be changed.`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
if (original.payment_method_id === newMethodId) {
|
|
48
|
+
return {
|
|
49
|
+
error: `Order ${order.order_id} is already paid with ${newMethodId}. The new method must be different.`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const amount = original.amount;
|
|
54
|
+
if (isGiftCard(newMethod) && newMethod.balance < amount) {
|
|
55
|
+
return {
|
|
56
|
+
error: `Gift card ${newMethodId}'s balance ($${newMethod.balance.toFixed(2)}) does not cover the $${amount.toFixed(2)} order total.`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
readBack:
|
|
62
|
+
`charge order ${order.order_id} — $${amount.toFixed(2)} — to ${newMethodId} instead, ` +
|
|
63
|
+
`refunding ${original.payment_method_id}`,
|
|
64
|
+
orderId: order.order_id,
|
|
65
|
+
newMethodId,
|
|
66
|
+
oldMethodId: original.payment_method_id,
|
|
67
|
+
amount,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function applyPayment(state: RetailState, plan: PaymentPlan) {
|
|
72
|
+
const order = state.store.orders[plan.orderId];
|
|
73
|
+
const user = order ? state.store.users[order.user_id] : undefined;
|
|
74
|
+
|
|
75
|
+
if (order && user) {
|
|
76
|
+
order.payment_history.push(
|
|
77
|
+
{
|
|
78
|
+
transaction_type: "payment",
|
|
79
|
+
amount: plan.amount,
|
|
80
|
+
payment_method_id: plan.newMethodId,
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
transaction_type: "refund",
|
|
84
|
+
amount: plan.amount,
|
|
85
|
+
payment_method_id: plan.oldMethodId,
|
|
86
|
+
},
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const newMethod = user.payment_methods[plan.newMethodId];
|
|
90
|
+
if (newMethod && isGiftCard(newMethod)) {
|
|
91
|
+
newMethod.balance = money(newMethod.balance - plan.amount);
|
|
92
|
+
}
|
|
93
|
+
const oldMethod = user.payment_methods[plan.oldMethodId];
|
|
94
|
+
if (oldMethod && isGiftCard(oldMethod)) {
|
|
95
|
+
oldMethod.balance = money(oldMethod.balance + plan.amount);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
order_id: plan.orderId,
|
|
101
|
+
status: order?.status ?? "pending",
|
|
102
|
+
amount: plan.amount,
|
|
103
|
+
paid_with: plan.newMethodId,
|
|
104
|
+
refunded_to: plan.oldMethodId,
|
|
105
|
+
message: `Order ${plan.orderId} is now charged to ${plan.newMethodId}, and $${plan.amount.toFixed(2)} is going back to ${plan.oldMethodId}.`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import type { ToolContext } from "@alexkroman1/aai";
|
|
2
|
+
import { isToolFailure } from "@alexkroman1/aai";
|
|
3
|
+
import { createToolContext, ok, okPosition } from "@alexkroman1/aai/testing";
|
|
4
|
+
import { describe, expect, test } from "vitest";
|
|
5
|
+
import type { StagedResult } from "./pending.ts";
|
|
6
|
+
import { retailSlot } from "./store.ts";
|
|
7
|
+
import cancelChange from "./tools/cancel_change.ts";
|
|
8
|
+
import cancelPendingOrder from "./tools/cancel_pending_order.ts";
|
|
9
|
+
import confirmChange from "./tools/confirm_change.ts";
|
|
10
|
+
import findUserIdByEmail from "./tools/find_user_id_by_email.ts";
|
|
11
|
+
import getOrderDetails from "./tools/get_order_details.ts";
|
|
12
|
+
import modifyUserAddress from "./tools/modify_user_address.ts";
|
|
13
|
+
import returnDeliveredOrderItems from "./tools/return_delivered_order_items.ts";
|
|
14
|
+
import transferToHumanAgents from "./tools/transfer_to_human_agents.ts";
|
|
15
|
+
|
|
16
|
+
/** Aarav Anderson: one pending order (#W9300146, $153.23 on a gift card) and
|
|
17
|
+
* one delivered order (#W4316152), which is every shape these cases need. */
|
|
18
|
+
async function aaravCtx(): Promise<ToolContext> {
|
|
19
|
+
const ctx = createToolContext();
|
|
20
|
+
ok(await findUserIdByEmail.execute({ email: "aarav.anderson9752@example.com" }, ctx));
|
|
21
|
+
return ctx;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const stageCancel = (ctx: ToolContext) =>
|
|
25
|
+
cancelPendingOrder.execute({ order_id: "#W9300146", reason: "no longer needed" }, ctx);
|
|
26
|
+
|
|
27
|
+
describe("staging", () => {
|
|
28
|
+
test("a staging tool changes NOTHING and moves the call to awaitingConfirmation", async () => {
|
|
29
|
+
const ctx = await aaravCtx();
|
|
30
|
+
const before = structuredClone(retailSlot.get(ctx).store.orders["#W9300146"]);
|
|
31
|
+
|
|
32
|
+
const staged = okPosition<StagedResult>(await stageCancel(ctx));
|
|
33
|
+
|
|
34
|
+
expect(staged.state).toBe("serving.awaitingConfirmation");
|
|
35
|
+
expect(staged.result.staged).toBe("cancel_pending_order");
|
|
36
|
+
// The whole claim of this template's policy section, as one assertion: the
|
|
37
|
+
// tool that used to cancel and refund on its first call now does neither.
|
|
38
|
+
expect(retailSlot.get(ctx).store.orders["#W9300146"]).toEqual(before);
|
|
39
|
+
const card =
|
|
40
|
+
retailSlot.get(ctx).store.users.aarav_anderson_8794?.payment_methods.gift_card_7245904;
|
|
41
|
+
expect(card?.source === "gift_card" && card.balance).toBe(17);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("the readback names the order, the items, the amount and where the money goes", async () => {
|
|
45
|
+
const ctx = await aaravCtx();
|
|
46
|
+
const staged = ok<StagedResult>(await stageCancel(ctx));
|
|
47
|
+
expect(staged.read_back).toContain("#W9300146");
|
|
48
|
+
expect(staged.read_back).toContain("153.23");
|
|
49
|
+
expect(staged.read_back).toContain("gift_card_7245904");
|
|
50
|
+
expect(staged.read_back).toContain("no longer needed");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("the result says out loud that nothing has happened", async () => {
|
|
54
|
+
const ctx = await aaravCtx();
|
|
55
|
+
const staged = ok<StagedResult>(await stageCancel(ctx));
|
|
56
|
+
expect(staged.message).toContain("NOTHING HAS CHANGED YET");
|
|
57
|
+
expect(staged.message).toContain("confirm_change");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("a second stage is refused, and the refusal names the one already waiting", async () => {
|
|
61
|
+
const ctx = await aaravCtx();
|
|
62
|
+
ok(await stageCancel(ctx));
|
|
63
|
+
|
|
64
|
+
// A DIFFERENT change, so this is not a repeat of the first call.
|
|
65
|
+
const second = await modifyUserAddress.execute(
|
|
66
|
+
{
|
|
67
|
+
user_id: "aarav_anderson_8794",
|
|
68
|
+
address1: "1 A St",
|
|
69
|
+
address2: "",
|
|
70
|
+
city: "Springfield",
|
|
71
|
+
state: "OR",
|
|
72
|
+
country: "USA",
|
|
73
|
+
zip: "97477",
|
|
74
|
+
},
|
|
75
|
+
ctx,
|
|
76
|
+
);
|
|
77
|
+
expect(isToolFailure(second)).toBe(true);
|
|
78
|
+
// Naming the waiting sentence is why the staging tools are gated on
|
|
79
|
+
// `serving` rather than on `serving.helping`: a state's instruction is
|
|
80
|
+
// static and could only have said which STATE the call is in.
|
|
81
|
+
expect(isToolFailure(second) && second.error).toContain("#W9300146");
|
|
82
|
+
expect(isToolFailure(second) && second.error).toContain("cancel_change");
|
|
83
|
+
expect(retailSlot.get(ctx).store.users.aarav_anderson_8794?.address.city).not.toBe(
|
|
84
|
+
"Springfield",
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("confirming", () => {
|
|
90
|
+
test("confirm_change applies the staged change and returns to helping", async () => {
|
|
91
|
+
const ctx = await aaravCtx();
|
|
92
|
+
ok(await stageCancel(ctx));
|
|
93
|
+
|
|
94
|
+
const done = okPosition<{ confirmed: string; status: string }>(
|
|
95
|
+
await confirmChange.execute({}, ctx),
|
|
96
|
+
);
|
|
97
|
+
expect(done.state).toBe("serving.helping");
|
|
98
|
+
expect(done.result.confirmed).toBe("cancel_pending_order");
|
|
99
|
+
expect(retailSlot.get(ctx).store.orders["#W9300146"]?.status).toBe("cancelled");
|
|
100
|
+
expect(retailSlot.get(ctx).pending).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("confirm_change with nothing staged is refused by the GATE, before its body", async () => {
|
|
104
|
+
const ctx = await aaravCtx();
|
|
105
|
+
const result = await confirmChange.execute({}, ctx);
|
|
106
|
+
expect(isToolFailure(result)).toBe(true);
|
|
107
|
+
// The SDK writes this refusal from `when` plus the state's instruction, so
|
|
108
|
+
// it says where the call is rather than merely that this was not allowed —
|
|
109
|
+
// and the body, which is the only thing in the template that mutates, never
|
|
110
|
+
// ran to find out.
|
|
111
|
+
expect(isToolFailure(result) && result.error).toContain("serving.helping");
|
|
112
|
+
expect(isToolFailure(result) && result.error).toContain("STAGES");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("confirming twice is refused — the second call has nothing staged", async () => {
|
|
116
|
+
const ctx = await aaravCtx();
|
|
117
|
+
ok(await stageCancel(ctx));
|
|
118
|
+
ok(await confirmChange.execute({}, ctx));
|
|
119
|
+
|
|
120
|
+
const again = await confirmChange.execute({}, ctx);
|
|
121
|
+
expect(isToolFailure(again)).toBe(true);
|
|
122
|
+
// One cancellation, one refund: a double-apply would have credited the card
|
|
123
|
+
// twice, which is the failure a "did we already do this?" flag gets wrong.
|
|
124
|
+
const card =
|
|
125
|
+
retailSlot.get(ctx).store.users.aarav_anderson_8794?.payment_methods.gift_card_7245904;
|
|
126
|
+
expect(card?.source === "gift_card" && card.balance).toBe(170.23);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("cancelling a staged change", () => {
|
|
131
|
+
test("cancel_change drops it, changes nothing, and frees the call", async () => {
|
|
132
|
+
const ctx = await aaravCtx();
|
|
133
|
+
ok(await stageCancel(ctx));
|
|
134
|
+
|
|
135
|
+
const dropped = okPosition<{ dropped: string | null; message: string }>(
|
|
136
|
+
await cancelChange.execute({}, ctx),
|
|
137
|
+
);
|
|
138
|
+
expect(dropped.state).toBe("serving.helping");
|
|
139
|
+
expect(dropped.result.dropped).toBe("cancel_pending_order");
|
|
140
|
+
expect(dropped.result.message).toContain("#W9300146");
|
|
141
|
+
expect(retailSlot.get(ctx).store.orders["#W9300146"]?.status).toBe("pending");
|
|
142
|
+
expect(retailSlot.get(ctx).pending).toBeNull();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("a corrected change can be staged straight after", async () => {
|
|
146
|
+
const ctx = await aaravCtx();
|
|
147
|
+
ok(await stageCancel(ctx));
|
|
148
|
+
ok(await cancelChange.execute({}, ctx));
|
|
149
|
+
|
|
150
|
+
// The caller changed their mind about which order — the very thing
|
|
151
|
+
// `cancel_change` exists for.
|
|
152
|
+
const restaged = ok<StagedResult>(
|
|
153
|
+
await returnDeliveredOrderItems.execute(
|
|
154
|
+
{
|
|
155
|
+
order_id: "#W4316152",
|
|
156
|
+
item_ids: ["7292993796"],
|
|
157
|
+
payment_method_id: "gift_card_7245904",
|
|
158
|
+
},
|
|
159
|
+
ctx,
|
|
160
|
+
),
|
|
161
|
+
);
|
|
162
|
+
expect(restaged.staged).toBe("return_delivered_order_items");
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("what stays legal while a change waits", () => {
|
|
167
|
+
test("a read is still answerable — 'what was the total again?'", async () => {
|
|
168
|
+
const ctx = await aaravCtx();
|
|
169
|
+
ok(await stageCancel(ctx));
|
|
170
|
+
// `when: "serving"` matches both children, which is what makes this work.
|
|
171
|
+
const read = okPosition<{ order_id: string }>(
|
|
172
|
+
await getOrderDetails.execute({ order_id: "#W9300146" }, ctx),
|
|
173
|
+
);
|
|
174
|
+
expect(read.result.order_id).toBe("#W9300146");
|
|
175
|
+
// A read does not settle anything, so the change is still waiting.
|
|
176
|
+
expect(read.state).toBe("serving.awaitingConfirmation");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("a caller can still ask for a human, and everything refuses afterwards", async () => {
|
|
180
|
+
const ctx = await aaravCtx();
|
|
181
|
+
ok(await stageCancel(ctx));
|
|
182
|
+
|
|
183
|
+
const transferred = okPosition(
|
|
184
|
+
await transferToHumanAgents.execute({ summary: "wants a human" }, ctx),
|
|
185
|
+
);
|
|
186
|
+
// `TRANSFERRED` is declared on the `serving` PARENT, which is what lets it
|
|
187
|
+
// fire from inside the confirmation.
|
|
188
|
+
expect(transferred.state).toBe("transferred");
|
|
189
|
+
expect(transferred.done).toBe(true);
|
|
190
|
+
|
|
191
|
+
// Including the two settling tools: a call given away cannot be finished.
|
|
192
|
+
expect(isToolFailure(await confirmChange.execute({}, ctx))).toBe(true);
|
|
193
|
+
expect(isToolFailure(await cancelChange.execute({}, ctx))).toBe(true);
|
|
194
|
+
expect(retailSlot.get(ctx).store.orders["#W9300146"]?.status).toBe("pending");
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The change waiting on the caller's word — what is staged, and what applying
|
|
3
|
+
* it does.
|
|
4
|
+
*
|
|
5
|
+
* Retail's policy has always said "confirm every change out loud … never act on
|
|
6
|
+
* an implied yes", and for a long time the only thing carrying that was prose:
|
|
7
|
+
* the sentence sat in the system prompt and in seven tool descriptions, and
|
|
8
|
+
* `cancel_pending_order` cancelled and refunded on its first call whether or not
|
|
9
|
+
* a word had been said. A rule enforced by asking a model nicely is not a rule.
|
|
10
|
+
*
|
|
11
|
+
* It is a MECHANISM now, and the mechanism is two halves that need each other:
|
|
12
|
+
*
|
|
13
|
+
* 1. **No tool mutates.** The seven changing tools validate everything, price
|
|
14
|
+
* it, write a {@link PendingAction} here and return the sentence to read
|
|
15
|
+
* back. `confirm_change` is the only thing in the template that writes to
|
|
16
|
+
* the store.
|
|
17
|
+
* 2. **`confirm_change` is gated on a STATE.** `callFlow`'s
|
|
18
|
+
* `serving.awaitingConfirmation` (`store.ts`) is reachable only by staging,
|
|
19
|
+
* so confirming a change nobody staged is refused by the SDK before the body
|
|
20
|
+
* runs — and the refusal says where the call is and what has to happen first.
|
|
21
|
+
*
|
|
22
|
+
* The half that is not enforceable stays honest about it: nothing here can know
|
|
23
|
+
* that the agent really SAID the sentence, or that the caller really answered
|
|
24
|
+
* yes. What the gate buys is that a change cannot happen in the same turn it was
|
|
25
|
+
* described in — the model has to come back for a second call, with the caller's
|
|
26
|
+
* answer in between — which is the property the prose could not have.
|
|
27
|
+
*
|
|
28
|
+
* Adapted from `travel-concierge`, whose `stageAction`/`confirm_action` pair is
|
|
29
|
+
* the same shape one domain over. The difference worth noting: its plans are
|
|
30
|
+
* re-derived at confirm time, and these are computed once at stage time and held
|
|
31
|
+
* as primitives. That is deliberate — see `cancel.ts`. A "yes" must never be
|
|
32
|
+
* followed by a refusal.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import type { ToolFailure } from "@alexkroman1/aai";
|
|
36
|
+
import type { OrderAddressPlan, UserAddressPlan } from "./address.ts";
|
|
37
|
+
import { applyOrderAddress, applyUserAddress } from "./address.ts";
|
|
38
|
+
import { applyCancel, type CancelPlan } from "./cancel.ts";
|
|
39
|
+
import { applyPayment, type PaymentPlan } from "./payment.ts";
|
|
40
|
+
import { applyReturn, type ReturnPlan } from "./returns.ts";
|
|
41
|
+
import type { RetailState } from "./shared.ts";
|
|
42
|
+
import type { ExchangePlan, ModifyItemsPlan } from "./swap.ts";
|
|
43
|
+
import { applyExchange, applyModifyItems } from "./swap.ts";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A validated, priced, not-yet-applied change.
|
|
47
|
+
*
|
|
48
|
+
* `kind` is the NAME OF THE TOOL that staged it, so the activity feed, the
|
|
49
|
+
* sidebar and every refusal can say which change is waiting in the same words
|
|
50
|
+
* the model called it by.
|
|
51
|
+
*/
|
|
52
|
+
export type PendingAction =
|
|
53
|
+
| { kind: "cancel_pending_order"; plan: CancelPlan }
|
|
54
|
+
| { kind: "modify_pending_order_address"; plan: OrderAddressPlan }
|
|
55
|
+
| { kind: "modify_pending_order_items"; plan: ModifyItemsPlan }
|
|
56
|
+
| { kind: "modify_pending_order_payment"; plan: PaymentPlan }
|
|
57
|
+
| { kind: "modify_user_address"; plan: UserAddressPlan }
|
|
58
|
+
| { kind: "return_delivered_order_items"; plan: ReturnPlan }
|
|
59
|
+
| { kind: "exchange_delivered_order_items"; plan: ExchangePlan };
|
|
60
|
+
|
|
61
|
+
/** What a staging tool answers with: the sentence, and nothing done yet. */
|
|
62
|
+
export interface StagedResult {
|
|
63
|
+
staged: PendingAction["kind"];
|
|
64
|
+
read_back: string;
|
|
65
|
+
message: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Put a change in front of the caller.
|
|
70
|
+
*
|
|
71
|
+
* A SECOND stage is refused rather than queued, and the refusal names the
|
|
72
|
+
* sentence already waiting — which is the whole reason the staging tools are
|
|
73
|
+
* gated on `serving` (either child) rather than on `serving.helping`. A state
|
|
74
|
+
* gate could only say "you are at serving.awaitingConfirmation", a state's
|
|
75
|
+
* instruction being static; this can say *which* change is waiting, which is
|
|
76
|
+
* what lets the model settle that one and re-stage the other. Same resolution
|
|
77
|
+
* as `travel-concierge`, and for the same reason.
|
|
78
|
+
*/
|
|
79
|
+
export function stageAction(state: RetailState, action: PendingAction): StagedResult | ToolFailure {
|
|
80
|
+
const waiting = state.pending;
|
|
81
|
+
if (waiting) {
|
|
82
|
+
return {
|
|
83
|
+
error:
|
|
84
|
+
`A change is already waiting on the caller's yes: "${waiting.plan.readBack}". ` +
|
|
85
|
+
"Settle that one with confirm_change or cancel_change, then stage this one.",
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
state.pending = action;
|
|
89
|
+
return {
|
|
90
|
+
staged: action.kind,
|
|
91
|
+
read_back: action.plan.readBack,
|
|
92
|
+
message:
|
|
93
|
+
`NOTHING HAS CHANGED YET. Read this back to the caller — "${action.plan.readBack}" — ` +
|
|
94
|
+
"and wait for an explicit yes. Then call confirm_change, or cancel_change if they say no.",
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Perform the staged change.
|
|
100
|
+
*
|
|
101
|
+
* Total: every plan was validated against this same store, and nothing between
|
|
102
|
+
* staging and confirming can invalidate one — `confirm_change` and
|
|
103
|
+
* `cancel_change` are the only tools legal in `awaitingConfirmation`, and
|
|
104
|
+
* neither is a mutation the other's plan depends on. That totality is the
|
|
105
|
+
* point. An `applyAction` that could fail would mean a caller saying yes and
|
|
106
|
+
* hearing "actually, no", which is exactly the outcome staging exists to
|
|
107
|
+
* prevent.
|
|
108
|
+
*/
|
|
109
|
+
export function applyAction(state: RetailState, action: PendingAction) {
|
|
110
|
+
switch (action.kind) {
|
|
111
|
+
case "cancel_pending_order":
|
|
112
|
+
return applyCancel(state, action.plan);
|
|
113
|
+
case "modify_pending_order_address":
|
|
114
|
+
return applyOrderAddress(state, action.plan);
|
|
115
|
+
case "modify_pending_order_items":
|
|
116
|
+
return applyModifyItems(state, action.plan);
|
|
117
|
+
case "modify_pending_order_payment":
|
|
118
|
+
return applyPayment(state, action.plan);
|
|
119
|
+
case "modify_user_address":
|
|
120
|
+
return applyUserAddress(state, action.plan);
|
|
121
|
+
case "return_delivered_order_items":
|
|
122
|
+
return applyReturn(state, action.plan);
|
|
123
|
+
case "exchange_delivered_order_items":
|
|
124
|
+
return applyExchange(state, action.plan);
|
|
125
|
+
default: {
|
|
126
|
+
// Unreachable: the seven arms above exhaust `PendingAction`, and this
|
|
127
|
+
// ASSIGNMENT is what keeps that true — an eighth member of the union
|
|
128
|
+
// stops compiling here rather than falling silently through to a change
|
|
129
|
+
// nobody applies. It exists because biome's `useDefaultSwitchClause`
|
|
130
|
+
// wants an arm, not because a call can reach it, which is why it throws
|
|
131
|
+
// rather than answering the `ToolFailure` the rest of this template
|
|
132
|
+
// prefers: there is no live call on this path to recover.
|
|
133
|
+
const unreachable: never = action;
|
|
134
|
+
throw new Error(`No apply for staged action ${JSON.stringify(unreachable)}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|