@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
|
@@ -76,51 +76,50 @@ export const retailSlot = sessionSlot("retail", createDefaultState);
|
|
|
76
76
|
/**
|
|
77
77
|
* Where this call is, and what may be done from here, as a plain state map.
|
|
78
78
|
*
|
|
79
|
-
* A {@link DialogSpec} rather than an XState machine: this dialog
|
|
80
|
-
* states,
|
|
81
|
-
* say
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
* recovery text. `as const` is what keeps the `on` keys literal, so `send`
|
|
86
|
-
* below is checked against the events this spec actually declares.
|
|
79
|
+
* A {@link DialogSpec} rather than an XState machine: everything this dialog
|
|
80
|
+
* says is states, events and an instruction each, which is exactly what a spec
|
|
81
|
+
* can say. The instruction is a declared field instead of an untyped `meta`
|
|
82
|
+
* bag, so misspelling it is a compile error rather than a refusal that arrives
|
|
83
|
+
* with no recovery text. `as const` is what keeps the `on` keys literal, so
|
|
84
|
+
* `send` below is checked against the events this spec actually declares.
|
|
87
85
|
*
|
|
88
|
-
*
|
|
89
|
-
* human" — used to be prose plus a boolean. `requiresAuth` was that boolean:
|
|
90
|
-
* fifteen tools declared it (five opting out), the wrapper below read it, and a
|
|
91
|
-
* refusal answered one fixed sentence. Three things change by declaring the
|
|
92
|
-
* states instead.
|
|
86
|
+
* Three of the policy's sections live here rather than in prose.
|
|
93
87
|
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
* sentence "and nothing else" — which nothing checked, so every tool stayed
|
|
97
|
-
* callable after the handoff and a model that kept going would keep acting on a
|
|
98
|
-
* call it had already given away. It is a `final` state now: no tool declares
|
|
99
|
-
* itself legal there, so every one of them refuses.
|
|
88
|
+
* **"Authenticate first."** `identifying`'s instruction names both finder
|
|
89
|
+
* tools, and ten tools declare `when: "serving"`, so the gate is the SDK's.
|
|
100
90
|
*
|
|
101
|
-
* **
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
91
|
+
* **"Handing off to a human."** The policy says to call
|
|
92
|
+
* `transfer_to_human_agents` and then say exactly one sentence "and nothing
|
|
93
|
+
* else" — which nothing checked, so every tool stayed callable after the handoff
|
|
94
|
+
* and a model that kept going kept acting on a call it had already given away.
|
|
95
|
+
* `transferred` is a `final` state: no tool declares itself legal there, so
|
|
96
|
+
* every one of them refuses. `TRANSFERRED` sits on the `serving` PARENT so it
|
|
97
|
+
* is reachable from either child — a caller asking for a human mid-confirmation
|
|
98
|
+
* must get one.
|
|
105
99
|
*
|
|
106
|
-
* **
|
|
107
|
-
*
|
|
108
|
-
*
|
|
100
|
+
* **"Confirm every change out loud."** This was the last rule in the policy
|
|
101
|
+
* carried by nothing at all, and it is `serving`'s two children now. Nothing in
|
|
102
|
+
* this template mutates the store except `confirm_change`, which is legal only
|
|
103
|
+
* in `awaitingConfirmation`, which is reachable only by staging a change. See
|
|
104
|
+
* `pending.ts` for what that buys and what it still cannot promise.
|
|
109
105
|
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
106
|
+
* **`IDENTIFIED` is deliberately NOT declared on `serving`.** It was, while
|
|
107
|
+
* `serving` was a leaf, so that a caller repeating their email did not hit an
|
|
108
|
+
* error. Now that `serving` has children, a self-transition would RE-ENTER it
|
|
109
|
+
* and reset to `helping` — stranding a staged change that `state.pending` still
|
|
110
|
+
* holds, which is the one way the position and the store could come to
|
|
111
|
+
* disagree. An event no active state handles is ignored, and ignoring it is
|
|
112
|
+
* exactly right: re-identifying the same customer does not move the call.
|
|
113
|
+
* `authenticateAs` is what refuses a switch to a DIFFERENT customer. Whether
|
|
114
|
+
* the call is identified and WHO it is identified as are two facts: this holds
|
|
115
|
+
* the first, `authenticatedUserId` holds the second.
|
|
115
116
|
*/
|
|
116
117
|
const callSpec = {
|
|
117
118
|
initial: "identifying",
|
|
118
119
|
states: {
|
|
119
120
|
identifying: {
|
|
120
121
|
// The instruction NAMES the two tools, because this sentence is what a
|
|
121
|
-
// refusal quotes and a refusal is the model's recovery path
|
|
122
|
-
// job the removed `NOT_AUTHENTICATED` constant did, now attached to the
|
|
123
|
-
// state that means it.
|
|
122
|
+
// refusal quotes and a refusal is the model's recovery path.
|
|
124
123
|
instruction:
|
|
125
124
|
"You do not know who this is yet. Identify the caller with " +
|
|
126
125
|
"find_user_id_by_email, or find_user_id_by_name_zip if they cannot " +
|
|
@@ -128,11 +127,25 @@ const callSpec = {
|
|
|
128
127
|
on: { IDENTIFIED: "serving", TRANSFERRED: "transferred" },
|
|
129
128
|
},
|
|
130
129
|
serving: {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
130
|
+
initial: "helping",
|
|
131
|
+
on: { TRANSFERRED: "transferred" },
|
|
132
|
+
states: {
|
|
133
|
+
helping: {
|
|
134
|
+
instruction:
|
|
135
|
+
"You are helping one identified customer, and only that one. Nothing is " +
|
|
136
|
+
"waiting on their word. A tool that changes something only STAGES it and " +
|
|
137
|
+
"hands you a sentence to read back.",
|
|
138
|
+
on: { STAGED: "awaitingConfirmation" },
|
|
139
|
+
},
|
|
140
|
+
awaitingConfirmation: {
|
|
141
|
+
instruction:
|
|
142
|
+
"A change is staged and NOTHING HAS HAPPENED YET. Read the staged sentence " +
|
|
143
|
+
"back — the order, the items, the amounts, where the money goes — and wait " +
|
|
144
|
+
"for an explicit yes. Then call confirm_change, or cancel_change if they " +
|
|
145
|
+
"say no or want to change any part of it.",
|
|
146
|
+
on: { SETTLED: "helping" },
|
|
147
|
+
},
|
|
148
|
+
},
|
|
136
149
|
},
|
|
137
150
|
transferred: {
|
|
138
151
|
final: true,
|
|
@@ -145,13 +158,19 @@ const callSpec = {
|
|
|
145
158
|
|
|
146
159
|
/**
|
|
147
160
|
* The flow. Its own slot key beside {@link retailSlot}: the flow holds the
|
|
148
|
-
* POSITION and the store holds the customer, the orders
|
|
161
|
+
* POSITION and the store holds the customer, the orders, the activity feed and
|
|
162
|
+
* the staged change itself, because an inspectable {@link PendingAction} is
|
|
163
|
+
* what `confirm_change` applies. One tool call always moves both — a staging
|
|
164
|
+
* tool writes `pending` and sends `STAGED` in the same synchronous window.
|
|
149
165
|
*/
|
|
150
166
|
export const callFlow = dialog("call", callSpec);
|
|
151
167
|
|
|
152
168
|
/** Every state a tool may run in before the call is handed to a human — i.e.
|
|
153
|
-
* everything but `transferred`. What the
|
|
154
|
-
*
|
|
169
|
+
* everything but `transferred`. What the two finders, the three catalog reads
|
|
170
|
+
* and the transfer itself declare, so the terminal state gates them without an
|
|
171
|
+
* auth gate. `"serving"` matches both of its children, which is what keeps a
|
|
172
|
+
* read legal while a change is waiting: a caller who asks "what was the total
|
|
173
|
+
* again?" mid-confirmation must be answerable. */
|
|
155
174
|
export const BEFORE_TRANSFER = ["identifying", "serving"] as const;
|
|
156
175
|
|
|
157
176
|
export function setFocus(
|
|
@@ -287,17 +306,27 @@ interface RetailToolSpec<S extends z.ZodType<Record<string, unknown>>, R> {
|
|
|
287
306
|
/**
|
|
288
307
|
* The state(s) this tool may run in, as `callFlow`'s states spell them.
|
|
289
308
|
*
|
|
290
|
-
* Replaces the `requiresAuth` boolean this spec used to carry.
|
|
291
|
-
* `"serving"
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
309
|
+
* Replaces the `requiresAuth` boolean this spec used to carry. Nine tools
|
|
310
|
+
* want `"serving"` — which matches both of its children, so a read and a
|
|
311
|
+
* staging tool are equally legal while a change waits; the two settling
|
|
312
|
+
* tools want `"serving.awaitingConfirmation"`; and the two finders, the three
|
|
313
|
+
* catalog reads and the transfer want {@link BEFORE_TRANSFER}, which is every
|
|
314
|
+
* state but the terminal one — so "does not need a customer" and "is still
|
|
315
|
+
* legal after the handoff" stopped being the same claim, and they were never
|
|
316
|
+
* the same claim.
|
|
295
317
|
*/
|
|
296
318
|
when: string | readonly string[];
|
|
297
319
|
/** The event to send once the body has succeeded, for a tool that MOVES the
|
|
298
|
-
* call.
|
|
299
|
-
* `TRANSFERRED
|
|
300
|
-
send
|
|
320
|
+
* call. Twelve do: the two finders send `IDENTIFIED`, the transfer sends
|
|
321
|
+
* `TRANSFERRED`, the seven staging tools send `STAGED`, and `confirm_change`
|
|
322
|
+
* and `cancel_change` send `SETTLED`. Nothing is sent when the body answers
|
|
323
|
+
* a `ToolFailure` — which is what stops a refused stage from moving the call
|
|
324
|
+
* into a confirmation with nothing behind it. */
|
|
325
|
+
send?:
|
|
326
|
+
| { type: "IDENTIFIED" }
|
|
327
|
+
| { type: "TRANSFERRED" }
|
|
328
|
+
| { type: "STAGED" }
|
|
329
|
+
| { type: "SETTLED" };
|
|
301
330
|
/**
|
|
302
331
|
* One line for the activity feed, from the call that SUCCEEDED.
|
|
303
332
|
*
|
|
@@ -336,7 +365,13 @@ interface RetailToolSpec<S extends z.ZodType<Record<string, unknown>>, R> {
|
|
|
336
365
|
execute: (args: z.output<S>, state: RetailState, ctx: ToolContext) => R | ToolFailure;
|
|
337
366
|
}
|
|
338
367
|
|
|
339
|
-
|
|
368
|
+
/**
|
|
369
|
+
* One line of the activity feed, and the `callSeq` bump the sidebar moves on.
|
|
370
|
+
*
|
|
371
|
+
* Exported because {@link retailTool} is no longer its only caller: `agent.ts`
|
|
372
|
+
* records the calls that never reach a body — see {@link gateFor}.
|
|
373
|
+
*/
|
|
374
|
+
export function record(state: RetailState, name: string, summary: string): void {
|
|
340
375
|
state.callSeq += 1;
|
|
341
376
|
pushCapped(
|
|
342
377
|
state.activity,
|
|
@@ -345,6 +380,24 @@ function record(state: RetailState, name: string, summary: string): void {
|
|
|
345
380
|
);
|
|
346
381
|
}
|
|
347
382
|
|
|
383
|
+
/**
|
|
384
|
+
* Which states each retail tool may run in, by the name the model calls it by.
|
|
385
|
+
*
|
|
386
|
+
* Populated as the fifteen `tools/` modules are loaded, because {@link retailTool}
|
|
387
|
+
* is what every one of them calls. It exists so `agent.ts`'s `tool.called` hook
|
|
388
|
+
* can ask the SAME question the gate asks — this is one fact evaluated twice, not
|
|
389
|
+
* two copies of a fact.
|
|
390
|
+
*/
|
|
391
|
+
const TOOL_GATES = new Map<string, readonly string[]>();
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* The states `name` may run in, or `undefined` for a tool this template did not
|
|
395
|
+
* declare (a builtin, or anything a future author adds outside the wrapper).
|
|
396
|
+
*/
|
|
397
|
+
export function gateFor(name: string): readonly string[] | undefined {
|
|
398
|
+
return TOOL_GATES.get(name);
|
|
399
|
+
}
|
|
400
|
+
|
|
348
401
|
/**
|
|
349
402
|
* Every retail tool is built through this. It owns two things no tool body may
|
|
350
403
|
* re-implement:
|
|
@@ -355,12 +408,14 @@ function record(state: RetailState, name: string, summary: string): void {
|
|
|
355
408
|
*
|
|
356
409
|
* The third thing it used to own — the authentication gate — is
|
|
357
410
|
* {@link callFlow}'s now, declared per tool as `when`. What that buys is in the
|
|
358
|
-
* machine's own doc; what it
|
|
359
|
-
* call short-circuits before this wrapper's body runs
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
* line
|
|
363
|
-
*
|
|
411
|
+
* machine's own doc; what it COST was one line of the activity feed, because a
|
|
412
|
+
* refused call short-circuits before this wrapper's body runs and so recorded
|
|
413
|
+
* nothing and bumped no `callSeq`.
|
|
414
|
+
*
|
|
415
|
+
* **That line is back, and not from here.** `agent.ts` declares a `tool.called`
|
|
416
|
+
* hook, which the runtime emits for every call the model makes INCLUDING the
|
|
417
|
+
* ones the gate goes on to refuse — a place to observe from that a tool wrapper
|
|
418
|
+
* structurally does not have. See {@link gateFor}.
|
|
364
419
|
*
|
|
365
420
|
* **`callFlow.tool` rather than `retailSlot.updateTool`**, so the body opens the
|
|
366
421
|
* store's window itself. A flow tool's own `execute` is handed `(args, ctx)`;
|
|
@@ -373,6 +428,7 @@ function record(state: RetailState, name: string, summary: string): void {
|
|
|
373
428
|
export function retailTool<S extends z.ZodType<Record<string, unknown>>, R>(
|
|
374
429
|
spec: RetailToolSpec<S, R>,
|
|
375
430
|
) {
|
|
431
|
+
TOOL_GATES.set(spec.name, typeof spec.when === "string" ? [spec.when] : spec.when);
|
|
376
432
|
return callFlow.tool({
|
|
377
433
|
description: spec.description,
|
|
378
434
|
inputSchema: spec.inputSchema,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { isToolFailure } from "@alexkroman1/aai";
|
|
2
2
|
import { describe, expect, test } from "vitest";
|
|
3
3
|
import { createDefaultState, findUser } from "./store.ts";
|
|
4
|
-
import {
|
|
4
|
+
import { applySwapLines, assertCanCoverDiff, planItemSwap, toSwapLines } from "./swap.ts";
|
|
5
5
|
|
|
6
6
|
function fixture(orderId: string) {
|
|
7
7
|
const state = createDefaultState();
|
|
@@ -152,7 +152,7 @@ describe("assertCanCoverDiff", () => {
|
|
|
152
152
|
});
|
|
153
153
|
});
|
|
154
154
|
|
|
155
|
-
describe("
|
|
155
|
+
describe("applySwapLines", () => {
|
|
156
156
|
test("each swapped line takes its OWN new price and options", () => {
|
|
157
157
|
const { state, order } = fixture("#W9311069");
|
|
158
158
|
// Two different products in one call — this is what catches the leaked
|
|
@@ -165,7 +165,7 @@ describe("applySwap", () => {
|
|
|
165
165
|
{ requireDifferent: true },
|
|
166
166
|
);
|
|
167
167
|
if (isToolFailure(plan)) throw new Error(plan.error);
|
|
168
|
-
|
|
168
|
+
applySwapLines(order, toSwapLines(plan));
|
|
169
169
|
|
|
170
170
|
const vacuum = order.items.find((i) => i.item_id === "4725166838");
|
|
171
171
|
const kettle = order.items.find((i) => i.item_id === "3909406921");
|
|
@@ -183,7 +183,7 @@ describe("applySwap", () => {
|
|
|
183
183
|
requireDifferent: true,
|
|
184
184
|
});
|
|
185
185
|
if (isToolFailure(plan)) throw new Error(plan.error);
|
|
186
|
-
|
|
186
|
+
applySwapLines(order, toSwapLines(plan));
|
|
187
187
|
expect(order.items.map((i) => i.item_id)).toEqual(["3909406921", "7292993796"]);
|
|
188
188
|
});
|
|
189
189
|
});
|
|
@@ -1,6 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Swapping items for other options of the same product — the validation both
|
|
3
|
+
* item changes share, and the two actions built on it (modifying a pending
|
|
4
|
+
* order, exchanging a delivered one) as plan/apply pairs.
|
|
5
|
+
*
|
|
6
|
+
* See `cancel.ts` for why every mutating action is split that way.
|
|
7
|
+
*/
|
|
8
|
+
|
|
1
9
|
import { isToolFailure, type ToolFailure } from "@alexkroman1/aai";
|
|
10
|
+
import { resolveOrder } from "./resolve.ts";
|
|
2
11
|
import type { Order, OrderItem, RetailState, User, Variant } from "./shared.ts";
|
|
3
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
authenticatedUser,
|
|
14
|
+
findPaymentMethod,
|
|
15
|
+
findProduct,
|
|
16
|
+
findVariant,
|
|
17
|
+
isGiftCard,
|
|
18
|
+
money,
|
|
19
|
+
} from "./store.ts";
|
|
4
20
|
|
|
5
21
|
export interface SwapPair {
|
|
6
22
|
/** Index into `order.items`. Matching by index rather than by "first item
|
|
@@ -17,12 +33,30 @@ export interface SwapPlan {
|
|
|
17
33
|
}
|
|
18
34
|
|
|
19
35
|
/**
|
|
20
|
-
*
|
|
21
|
-
* mutating anything. Shared by `modify_pending_order_items` (pending, which
|
|
22
|
-
* additionally forbids a no-op swap) and `exchange_delivered_order_items`
|
|
23
|
-
* (delivered, which does not).
|
|
36
|
+
* One priced swap, flattened to primitives.
|
|
24
37
|
*
|
|
25
|
-
*
|
|
38
|
+
* A {@link SwapPair} holds live `OrderItem` and `Variant` references into the
|
|
39
|
+
* store, which is right for a plan computed and applied inside one tool call
|
|
40
|
+
* and wrong for one that waits in a session slot for the caller to say yes —
|
|
41
|
+
* see `CancelPlan` in `cancel.ts`. This is the storable form, and it carries enough that
|
|
42
|
+
* {@link applySwapLines} needs no lookup at all.
|
|
43
|
+
*/
|
|
44
|
+
export interface SwapLine {
|
|
45
|
+
index: number;
|
|
46
|
+
fromItemId: string;
|
|
47
|
+
fromName: string;
|
|
48
|
+
fromOptions: Record<string, string>;
|
|
49
|
+
fromPrice: number;
|
|
50
|
+
toItemId: string;
|
|
51
|
+
toName: string;
|
|
52
|
+
toOptions: Record<string, string>;
|
|
53
|
+
toPrice: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Validate a proposed item swap and compute its price difference, without
|
|
58
|
+
* mutating anything. Shared by the pending-order change (which additionally
|
|
59
|
+
* forbids a no-op swap) and the delivered-order exchange (which does not).
|
|
26
60
|
*/
|
|
27
61
|
export function planItemSwap(
|
|
28
62
|
state: RetailState,
|
|
@@ -103,6 +137,23 @@ export function planItemSwap(
|
|
|
103
137
|
return { pairs, diff: money(diff) };
|
|
104
138
|
}
|
|
105
139
|
|
|
140
|
+
/** Flatten a freshly-computed plan into the form a staged action can hold. */
|
|
141
|
+
export function toSwapLines(plan: SwapPlan): SwapLine[] {
|
|
142
|
+
return plan.pairs.map((pair) => ({
|
|
143
|
+
index: pair.index,
|
|
144
|
+
fromItemId: pair.item.item_id,
|
|
145
|
+
fromName: pair.item.name,
|
|
146
|
+
fromOptions: { ...pair.item.options },
|
|
147
|
+
fromPrice: pair.item.price,
|
|
148
|
+
toItemId: pair.newVariant.item_id,
|
|
149
|
+
// Same product by construction — a swap can only reach another option of
|
|
150
|
+
// it — so the name is the item's own and the OPTIONS are what differ.
|
|
151
|
+
toName: pair.item.name,
|
|
152
|
+
toOptions: { ...pair.newVariant.options },
|
|
153
|
+
toPrice: pair.newVariant.price,
|
|
154
|
+
}));
|
|
155
|
+
}
|
|
156
|
+
|
|
106
157
|
/**
|
|
107
158
|
* Gate the price difference on the chosen payment method. Only a gift card has
|
|
108
159
|
* a balance to run out of; a negative difference is a refund and never gated.
|
|
@@ -118,14 +169,196 @@ export function assertCanCoverDiff(user: User, methodId: string, diff: number):
|
|
|
118
169
|
return null;
|
|
119
170
|
}
|
|
120
171
|
|
|
121
|
-
/** Apply
|
|
122
|
-
*
|
|
123
|
-
export function
|
|
124
|
-
for (const
|
|
125
|
-
const
|
|
126
|
-
if (!
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
172
|
+
/** Apply validated lines. Each takes its OWN new option's price and options —
|
|
173
|
+
* see the module note on tau2's leaked loop variable. */
|
|
174
|
+
export function applySwapLines(order: Order, lines: readonly SwapLine[]): void {
|
|
175
|
+
for (const line of lines) {
|
|
176
|
+
const item = order.items[line.index];
|
|
177
|
+
if (!item) continue;
|
|
178
|
+
item.item_id = line.toItemId;
|
|
179
|
+
item.price = line.toPrice;
|
|
180
|
+
item.options = { ...line.toOptions };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** "the glass 2 litre one for the stainless steel 1 litre one" — how a swap is
|
|
185
|
+
* said out loud. Item numbers are never in it; the prompt forbids reading one. */
|
|
186
|
+
function describeLine(line: SwapLine): string {
|
|
187
|
+
const from = Object.values(line.fromOptions).join(" ");
|
|
188
|
+
const to = Object.values(line.toOptions).join(" ");
|
|
189
|
+
return `the ${from} ${line.fromName} for the ${to} one`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function describeDiff(diff: number, methodId: string): string {
|
|
193
|
+
if (diff > 0) return `$${diff.toFixed(2)} charged to ${methodId}`;
|
|
194
|
+
if (diff < 0) return `$${Math.abs(diff).toFixed(2)} refunded to ${methodId}`;
|
|
195
|
+
return "no price difference";
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ─── Changing the items in a pending order ───────────────────────────────────
|
|
199
|
+
|
|
200
|
+
export interface ModifyItemsPlan {
|
|
201
|
+
readBack: string;
|
|
202
|
+
orderId: string;
|
|
203
|
+
lines: SwapLine[];
|
|
204
|
+
diff: number;
|
|
205
|
+
paymentMethodId: string;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function planModifyItems(
|
|
209
|
+
state: RetailState,
|
|
210
|
+
spokenOrderId: string,
|
|
211
|
+
itemIds: string[],
|
|
212
|
+
newItemIds: string[],
|
|
213
|
+
paymentMethodId: string,
|
|
214
|
+
): ModifyItemsPlan | ToolFailure {
|
|
215
|
+
const user = authenticatedUser(state);
|
|
216
|
+
if (isToolFailure(user)) return user;
|
|
217
|
+
|
|
218
|
+
const order = resolveOrder(state, spokenOrderId);
|
|
219
|
+
if (isToolFailure(order)) return order;
|
|
220
|
+
|
|
221
|
+
// Exactly 'pending'. A 'pending (item modified)' order has already used its
|
|
222
|
+
// one modification, which is what makes this action terminal.
|
|
223
|
+
if (order.status !== "pending") {
|
|
224
|
+
return {
|
|
225
|
+
error: `Order ${order.order_id} is ${order.status}. Items can only be changed while an order is exactly 'pending', and only once.`,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const plan = planItemSwap(state, order, itemIds, newItemIds, { requireDifferent: true });
|
|
230
|
+
if (isToolFailure(plan)) return plan;
|
|
231
|
+
|
|
232
|
+
const blocked = assertCanCoverDiff(user, paymentMethodId, plan.diff);
|
|
233
|
+
if (blocked) return blocked;
|
|
234
|
+
|
|
235
|
+
const lines = toSwapLines(plan);
|
|
236
|
+
return {
|
|
237
|
+
readBack:
|
|
238
|
+
`swap ${lines.map(describeLine).join(", and ")} on order ${order.order_id}, ` +
|
|
239
|
+
`with ${describeDiff(plan.diff, paymentMethodId)} — and this is the ONE change that order ` +
|
|
240
|
+
"allows: after it, it can no longer be cancelled or modified by anyone",
|
|
241
|
+
orderId: order.order_id,
|
|
242
|
+
lines,
|
|
243
|
+
diff: plan.diff,
|
|
244
|
+
paymentMethodId,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function applyModifyItems(state: RetailState, plan: ModifyItemsPlan) {
|
|
249
|
+
const order = state.store.orders[plan.orderId];
|
|
250
|
+
const user = order ? state.store.users[order.user_id] : undefined;
|
|
251
|
+
|
|
252
|
+
if (order && user) {
|
|
253
|
+
const method = user.payment_methods[plan.paymentMethodId];
|
|
254
|
+
if (method && isGiftCard(method)) {
|
|
255
|
+
method.balance = money(method.balance - plan.diff);
|
|
256
|
+
}
|
|
257
|
+
order.payment_history.push({
|
|
258
|
+
transaction_type: plan.diff > 0 ? "payment" : "refund",
|
|
259
|
+
amount: money(Math.abs(plan.diff)),
|
|
260
|
+
payment_method_id: plan.paymentMethodId,
|
|
261
|
+
});
|
|
262
|
+
applySwapLines(order, plan.lines);
|
|
263
|
+
order.status = "pending (item modified)";
|
|
130
264
|
}
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
order_id: plan.orderId,
|
|
268
|
+
status: "pending (item modified)" as const,
|
|
269
|
+
price_difference: plan.diff,
|
|
270
|
+
items: (order?.items ?? []).map((item) => ({
|
|
271
|
+
name: item.name,
|
|
272
|
+
item_id: item.item_id,
|
|
273
|
+
options: item.options,
|
|
274
|
+
price: item.price,
|
|
275
|
+
})),
|
|
276
|
+
message:
|
|
277
|
+
plan.diff > 0
|
|
278
|
+
? `Done. $${plan.diff.toFixed(2)} was charged to ${plan.paymentMethodId}. This order can no longer be modified or cancelled.`
|
|
279
|
+
: `Done. $${Math.abs(plan.diff).toFixed(2)} is being refunded to ${plan.paymentMethodId}. This order can no longer be modified or cancelled.`,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ─── Exchanging a delivered order ────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
export interface ExchangePlan {
|
|
286
|
+
readBack: string;
|
|
287
|
+
orderId: string;
|
|
288
|
+
lines: SwapLine[];
|
|
289
|
+
diff: number;
|
|
290
|
+
paymentMethodId: string;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function planExchange(
|
|
294
|
+
state: RetailState,
|
|
295
|
+
spokenOrderId: string,
|
|
296
|
+
itemIds: string[],
|
|
297
|
+
newItemIds: string[],
|
|
298
|
+
paymentMethodId: string,
|
|
299
|
+
): ExchangePlan | ToolFailure {
|
|
300
|
+
const user = authenticatedUser(state);
|
|
301
|
+
if (isToolFailure(user)) return user;
|
|
302
|
+
|
|
303
|
+
const order = resolveOrder(state, spokenOrderId);
|
|
304
|
+
if (isToolFailure(order)) return order;
|
|
305
|
+
|
|
306
|
+
if (order.status !== "delivered") {
|
|
307
|
+
return {
|
|
308
|
+
error: `Order ${order.order_id} is ${order.status}. Only a delivered order can be exchanged, and only once.`,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// requireDifferent is false: a zero-difference line is harmless on a
|
|
313
|
+
// delivered order, and refusing one would reject a caller who listed every
|
|
314
|
+
// item and changed their mind about only some.
|
|
315
|
+
const plan = planItemSwap(state, order, itemIds, newItemIds, { requireDifferent: false });
|
|
316
|
+
if (isToolFailure(plan)) return plan;
|
|
317
|
+
|
|
318
|
+
const blocked = assertCanCoverDiff(user, paymentMethodId, plan.diff);
|
|
319
|
+
if (blocked) return blocked;
|
|
320
|
+
|
|
321
|
+
const lines = toSwapLines(plan);
|
|
322
|
+
return {
|
|
323
|
+
readBack:
|
|
324
|
+
`exchange ${lines.map(describeLine).join(", and ")} on order ${order.order_id}, ` +
|
|
325
|
+
`with ${describeDiff(plan.diff, paymentMethodId)}`,
|
|
326
|
+
orderId: order.order_id,
|
|
327
|
+
lines,
|
|
328
|
+
diff: plan.diff,
|
|
329
|
+
paymentMethodId,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function applyExchange(state: RetailState, plan: ExchangePlan) {
|
|
334
|
+
const order = state.store.orders[plan.orderId];
|
|
335
|
+
|
|
336
|
+
// The pairing AS PRICED, in the order the caller gave it. tau2 stored these
|
|
337
|
+
// as two INDEPENDENTLY SORTED lists, which permuted the second against the
|
|
338
|
+
// first whenever more than one item was named — two sets, not a pairing, and
|
|
339
|
+
// the result had to carry the real pairing separately to avoid telling the
|
|
340
|
+
// caller a quote that was not the one they got. Nothing here is compared
|
|
341
|
+
// against a tau2 end state any more, so the fields hold the pairing itself.
|
|
342
|
+
if (order) {
|
|
343
|
+
order.status = "exchange requested";
|
|
344
|
+
order.exchange_items = plan.lines.map((line) => line.fromItemId);
|
|
345
|
+
order.exchange_new_items = plan.lines.map((line) => line.toItemId);
|
|
346
|
+
order.exchange_payment_method_id = plan.paymentMethodId;
|
|
347
|
+
order.exchange_price_difference = plan.diff;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return {
|
|
351
|
+
order_id: plan.orderId,
|
|
352
|
+
status: "exchange requested" as const,
|
|
353
|
+
price_difference: plan.diff,
|
|
354
|
+
exchanges: plan.lines.map((line) => ({
|
|
355
|
+
item_id: line.fromItemId,
|
|
356
|
+
new_item_id: line.toItemId,
|
|
357
|
+
price_difference: money(line.toPrice - line.fromPrice),
|
|
358
|
+
})),
|
|
359
|
+
message:
|
|
360
|
+
plan.diff > 0
|
|
361
|
+
? `Exchange requested on ${plan.orderId}. $${plan.diff.toFixed(2)} will be charged to ${plan.paymentMethodId}. An email with return instructions is on its way.`
|
|
362
|
+
: `Exchange requested on ${plan.orderId}. $${Math.abs(plan.diff).toFixed(2)} will be refunded to ${plan.paymentMethodId}. An email with return instructions is on its way.`,
|
|
363
|
+
};
|
|
131
364
|
}
|