@alexkroman1/aai-cli 6.11.0 → 8.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/README.md +1 -0
- package/dist/{_agent-DpH2pBJd.mjs → _agent-BTwZJu4P.mjs} +2 -2
- package/dist/{_bundler-C2BDwU7r.mjs → _bundler-CDuYl5Gb.mjs} +2 -2
- package/dist/_dev-env.d.ts +22 -2
- package/dist/{_dev-server-LLLWnFBs.mjs → _dev-server-Me06wgeF.mjs} +38 -47
- package/dist/_dev-server.d.ts +0 -17
- package/dist/{_init-C57bWAQ8.mjs → _init-BhEfQ6Yi.mjs} +3 -2
- package/dist/{_preflight-BtfaYtbE.mjs → _preflight-Co1G9Jww.mjs} +1 -1
- package/dist/_preflight.d.ts +1 -1
- package/dist/_resource-commands.d.ts +23 -0
- package/dist/{_slug-api-BxrsJXVI.mjs → _slug-api-z-USxPtL.mjs} +16 -12
- package/dist/_slug-api.d.ts +5 -2
- package/dist/{_studio-U3jhKlA3.mjs → _studio-DR5yzZ1T.mjs} +2 -1
- package/dist/{_templates-Dxb_P2Wz.mjs → _templates-4WcKOjS5.mjs} +5 -6
- package/dist/_templates.d.ts +3 -4
- package/dist/_workflow-scan.d.ts +108 -0
- package/dist/{build-Dfza2pRI.mjs → build-BBxf8vom.mjs} +6 -3
- package/dist/cli.mjs +346 -349
- 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-DRC8jWr8.mjs} +5 -5
- package/dist/{dev-Uq5ujP8a.mjs → dev-BvcctCn2.mjs} +1 -1
- package/dist/eval-DfQ8cm5p.mjs +56 -0
- package/dist/eval.d.ts +43 -0
- package/dist/{init-l6xfU4xX.mjs → init-CUo87gcI.mjs} +3 -3
- package/dist/{login-C71-qz8F.mjs → login-BBuM1sxH.mjs} +3 -4
- package/dist/{logs-BWVGSjLR.mjs → logs-CQ2T46dr.mjs} +2 -2
- package/dist/project-config.mjs +1 -1
- package/dist/scaffold/CLAUDE.md +442 -80
- package/dist/scaffold/package.json +6 -3
- package/dist/scaffold/server.mjs +11 -4
- package/dist/scaffold/tsconfig.json +0 -1
- package/dist/{secret-BuMuFR4B.mjs → secret-Ct34kdgU.mjs} +2 -2
- package/dist/{storage-DzRZ-eCw.mjs → storage-i_79ZwAR.mjs} +2 -2
- package/dist/{studio-BCNUpDgP.mjs → studio-CzTzPqe1.mjs} +4 -4
- 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/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/code-interpreter/agent.eval.test.ts +165 -0
- package/dist/templates/dispatch-center/agent.eval.test.ts +349 -0
- 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.eval.test.ts +138 -0
- package/dist/templates/embedded-assets/agent.test.ts +17 -14
- package/dist/templates/health-assistant/agent.eval.test.ts +167 -0
- package/dist/templates/health-assistant/agent.test.ts +22 -4
- package/dist/templates/infocom-adventure/agent.eval.test.ts +158 -0
- package/dist/templates/infocom-adventure/agent.test.ts +53 -24
- package/dist/templates/infocom-adventure/agent.ts +21 -0
- package/dist/templates/infocom-adventure/client.tsx +246 -164
- 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/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/math-buddy/agent.eval.test.ts +146 -0
- package/dist/templates/night-owl/agent.eval.test.ts +272 -0
- 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/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 +42 -27
- package/dist/templates/pizza-ordering/client.tsx +9 -26
- 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/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.eval.test.ts +411 -0
- package/dist/templates/podcast-digest/agent.test.ts +717 -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 +444 -0
- package/dist/templates/podcast-digest/workflows/feeds.ts +507 -0
- package/dist/templates/podcast-digest/workflows/slack.ts +88 -0
- package/dist/templates/recap-workflow/agent.eval.test.ts +366 -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.eval.test.ts +209 -0
- 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.eval.test.ts +329 -0
- 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/address.ts +87 -1
- package/dist/templates/retail/agent.eval.test.ts +314 -0
- package/dist/templates/retail/agent.test.ts +195 -112
- package/dist/templates/retail/agent.ts +35 -1
- package/dist/templates/retail/cancel.ts +116 -0
- package/dist/templates/retail/client.tsx +265 -117
- 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 +150 -7
- 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 +84 -15
- package/dist/templates/retail/store.ts +231 -48
- package/dist/templates/retail/swap.test.ts +4 -4
- package/dist/templates/retail/swap.ts +248 -15
- package/dist/templates/retail/system-prompt.md +69 -28
- package/dist/templates/retail/tools/cancel_change.ts +33 -0
- package/dist/templates/retail/tools/cancel_pending_order.ts +16 -60
- package/dist/templates/retail/tools/confirm_change.ts +42 -0
- package/dist/templates/retail/tools/exchange_delivered_order_items.ts +22 -67
- 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 +13 -25
- package/dist/templates/retail/tools/modify_pending_order_items.ts +23 -62
- package/dist/templates/retail/tools/modify_pending_order_payment.ts +16 -78
- package/dist/templates/retail/tools/modify_user_address.ts +13 -16
- package/dist/templates/retail/tools/return_delivered_order_items.ts +17 -72
- package/dist/templates/retail/tools/transfer_to_human_agents.ts +12 -6
- package/dist/templates/simple/agent.eval.test.ts +54 -0
- package/dist/templates/solo-rpg/agent.eval.test.ts +320 -0
- 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.eval.test.ts +342 -0
- 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.eval.test.ts +273 -0
- 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/system-prompt.md +2 -1
- package/dist/templates/support-line/tools/answer_question.ts +66 -4
- package/dist/templates/transcription-workflow/agent.eval.test.ts +234 -0
- 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.eval.test.ts +323 -0
- package/dist/templates/travel-concierge/agent.test.ts +174 -43
- package/dist/templates/travel-concierge/agent.ts +18 -9
- 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 +180 -12
- package/dist/templates/travel-concierge/system-prompt.md +7 -1
- package/dist/templates/travel-concierge/tools/book_car_rental.ts +8 -5
- package/dist/templates/travel-concierge/tools/book_excursion.ts +8 -5
- package/dist/templates/travel-concierge/tools/book_hotel.ts +8 -5
- package/dist/templates/travel-concierge/tools/cancel_ticket.ts +8 -5
- 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 +8 -5
- 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-CGD4r8Kc.mjs → worker-bundler-CCVEDjm1.mjs} +241 -3
- package/dist/worker-bundler.mjs +1 -1
- package/dist/{workflow-CFpxOFfQ.mjs → workflow-BflATMmU.mjs} +47 -12
- package/dist/workflow-bundler.d.ts +6 -1
- package/dist/workflow.d.ts +1 -1
- package/package.json +5 -4
- package/dist/eject-31gjtaHF.mjs +0 -116
- package/dist/eject.d.ts +0 -60
- package/dist/templates/call-audit/workflows/temp-media.ts +0 -138
- package/dist/templates/infocom-adventure/tools/game_state_history.ts +0 -14
- 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
|
@@ -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
|
+
}
|
|
@@ -4,11 +4,12 @@ import { isToolFailure, type ToolContext } from "@alexkroman1/aai";
|
|
|
4
4
|
import { createToolContext, withDiscoveredTools } from "@alexkroman1/aai/testing";
|
|
5
5
|
import { describe, expect, test } from "vitest";
|
|
6
6
|
import authoredAgent from "./agent.ts";
|
|
7
|
-
import { retailSlot } from "./store.ts";
|
|
7
|
+
import { callFlow, retailSlot } from "./store.ts";
|
|
8
8
|
|
|
9
|
-
/** Tools that legitimately run before the caller is identified
|
|
10
|
-
* else must refuse. Listed here
|
|
11
|
-
* deliberate edit to this file, not a
|
|
9
|
+
/** Tools that legitimately run before the caller is identified — the six
|
|
10
|
+
* declaring `when: BEFORE_TRANSFER`. Everything else must refuse. Listed here
|
|
11
|
+
* so ADDING an unauthenticated tool is a deliberate edit to this file, not a
|
|
12
|
+
* silent gap. */
|
|
12
13
|
const PUBLIC_TOOLS = new Set([
|
|
13
14
|
"find_user_id_by_email",
|
|
14
15
|
"find_user_id_by_name_zip",
|
|
@@ -37,11 +38,53 @@ const registry = Object.entries(retailAgent.tools);
|
|
|
37
38
|
// distinct session, which is what these per-tool cases assume.
|
|
38
39
|
const makeCtx = (): ToolContext => createToolContext();
|
|
39
40
|
|
|
41
|
+
/** A context whose call flow is in `serving.helping`, so a `when: "serving"`
|
|
42
|
+
* tool can reach its body. Moved through the FLOW rather than by writing
|
|
43
|
+
* `authenticatedUserId`, because the gate reads the machine. */
|
|
44
|
+
function servingCtx(): ToolContext {
|
|
45
|
+
const ctx = makeCtx();
|
|
46
|
+
callFlow.send(ctx, { type: "IDENTIFIED" });
|
|
47
|
+
return ctx;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The two tools legal only while a change waits on the caller's yes. */
|
|
51
|
+
const SETTLING_TOOLS = new Set(["cancel_change", "confirm_change"]);
|
|
52
|
+
|
|
53
|
+
function toolNamed(name: string) {
|
|
54
|
+
const def = retailAgent.tools[name];
|
|
55
|
+
if (!def) throw new Error(`no tool named ${name}`);
|
|
56
|
+
return def;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A context in whatever state `name` needs to reach its BODY.
|
|
61
|
+
*
|
|
62
|
+
* The two settling tools are reached by really identifying a caller and really
|
|
63
|
+
* staging a change, rather than by sending `STAGED` at the machine: they read
|
|
64
|
+
* `state.pending`, and a position with nothing staged behind it is a state this
|
|
65
|
+
* template cannot actually be in. Everything else only needs `serving`.
|
|
66
|
+
*/
|
|
67
|
+
async function bodyReachableCtx(name: string): Promise<ToolContext> {
|
|
68
|
+
if (!SETTLING_TOOLS.has(name)) return servingCtx();
|
|
69
|
+
const ctx = makeCtx();
|
|
70
|
+
await toolNamed("find_user_id_by_email").execute(
|
|
71
|
+
{ email: "aarav.anderson9752@example.com" },
|
|
72
|
+
ctx,
|
|
73
|
+
);
|
|
74
|
+
await toolNamed("cancel_pending_order").execute(
|
|
75
|
+
{ order_id: "#W9300146", reason: "no longer needed" },
|
|
76
|
+
ctx,
|
|
77
|
+
);
|
|
78
|
+
return ctx;
|
|
79
|
+
}
|
|
80
|
+
|
|
40
81
|
/** Minimal args satisfying each tool's schema. Deliberately plausible-shaped
|
|
41
82
|
* but wrong — these calls are expected to fail; what is asserted is that they
|
|
42
83
|
* still moved the UI. */
|
|
43
84
|
const SAMPLE_ARGS: Record<string, Record<string, unknown>> = {
|
|
85
|
+
cancel_change: {},
|
|
44
86
|
cancel_pending_order: { order_id: "#W0000000", reason: "no longer needed" },
|
|
87
|
+
confirm_change: {},
|
|
45
88
|
exchange_delivered_order_items: {
|
|
46
89
|
order_id: "#W0000000",
|
|
47
90
|
item_ids: ["0000000000"],
|
|
@@ -92,10 +135,17 @@ const SAMPLE_ARGS: Record<string, Record<string, unknown>> = {
|
|
|
92
135
|
};
|
|
93
136
|
|
|
94
137
|
describe("tool registry", () => {
|
|
95
|
-
test("registers all
|
|
138
|
+
test("registers all seventeen tools", () => {
|
|
139
|
+
// Fifteen of these are tau2's retail tool set, which this template used to
|
|
140
|
+
// hold verbatim. `confirm_change` and `cancel_change` are the two it does
|
|
141
|
+
// not have: tau2's tools apply on their first call, and here nothing does —
|
|
142
|
+
// see `pending.ts`. Departing from that set is what buys the confirmation
|
|
143
|
+
// gate, and it is the reason this list is no longer a fidelity claim.
|
|
96
144
|
expect(registry.map(([name]) => name).sort()).toEqual(
|
|
97
145
|
[
|
|
146
|
+
"cancel_change",
|
|
98
147
|
"cancel_pending_order",
|
|
148
|
+
"confirm_change",
|
|
99
149
|
"exchange_delivered_order_items",
|
|
100
150
|
"find_user_id_by_email",
|
|
101
151
|
"find_user_id_by_name_zip",
|
|
@@ -134,7 +184,11 @@ describe("the UI-update invariant", () => {
|
|
|
134
184
|
// This is the one that fails if a future tool is built with tool() instead of
|
|
135
185
|
// retailTool(): it would work, and the sidebar would sit still through it.
|
|
136
186
|
test.each(registry)("%s increments callSeq and logs activity", async (name, def) => {
|
|
137
|
-
|
|
187
|
+
// In whichever state lets the body run, so the flow gate is not what these
|
|
188
|
+
// calls are testing: the point is that a tool which reaches its BODY moves
|
|
189
|
+
// the sidebar. A refused call never reaches one — the hook below is what
|
|
190
|
+
// moves the sidebar for those.
|
|
191
|
+
const ctx = await bodyReachableCtx(name);
|
|
138
192
|
const before = retailSlot.get(ctx).callSeq;
|
|
139
193
|
await def.execute(SAMPLE_ARGS[name] ?? {}, ctx);
|
|
140
194
|
const state = retailSlot.get(ctx);
|
|
@@ -146,12 +200,82 @@ describe("the UI-update invariant", () => {
|
|
|
146
200
|
test.each(registry)("%s logs its own registry key as its name", async (name, def) => {
|
|
147
201
|
// Catches a copy-paste where the retailTool `name` and the registry key
|
|
148
202
|
// disagree — the activity feed would then attribute calls to the wrong tool.
|
|
149
|
-
const ctx =
|
|
203
|
+
const ctx = await bodyReachableCtx(name);
|
|
150
204
|
await def.execute(SAMPLE_ARGS[name] ?? {}, ctx);
|
|
151
205
|
expect(retailSlot.get(ctx).activity.at(-1)?.tool).toBe(name);
|
|
152
206
|
});
|
|
153
207
|
});
|
|
154
208
|
|
|
209
|
+
describe("the blocked-call hook", () => {
|
|
210
|
+
/**
|
|
211
|
+
* A `tool.called`, delivered the way the RUNTIME delivers it.
|
|
212
|
+
*
|
|
213
|
+
* The hook is a plain function on the def, so this needs no harness — and
|
|
214
|
+
* asserting on it here is the only way the blocked lines are covered at all:
|
|
215
|
+
* they are written by something no tool call executes.
|
|
216
|
+
*/
|
|
217
|
+
const called = (name: string, ctx: ToolContext) =>
|
|
218
|
+
retailAgent.events?.["tool.called"]?.(
|
|
219
|
+
{
|
|
220
|
+
type: "tool.called",
|
|
221
|
+
toolCallId: "call_1",
|
|
222
|
+
toolName: name,
|
|
223
|
+
args: {},
|
|
224
|
+
meta: { id: "evt_1", at: 0 },
|
|
225
|
+
},
|
|
226
|
+
ctx,
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
test.each(registry.filter(([name]) => !PUBLIC_TOOLS.has(name)))(
|
|
230
|
+
"%s records a blocked line when the model tries it too early",
|
|
231
|
+
(name) => {
|
|
232
|
+
const ctx = makeCtx();
|
|
233
|
+
called(name, ctx);
|
|
234
|
+
|
|
235
|
+
// The regression this closes: the gate moved out of `retailTool` and the
|
|
236
|
+
// sidebar stopped showing the most interesting calls the model makes.
|
|
237
|
+
const state = retailSlot.get(ctx);
|
|
238
|
+
expect(state.callSeq, `${name} recorded no blocked line`).toBe(1);
|
|
239
|
+
expect(state.activity.at(-1)?.tool).toBe(name);
|
|
240
|
+
expect(state.activity.at(-1)?.summary).toContain("blocked");
|
|
241
|
+
expect(state.activity.at(-1)?.summary).toContain("identifying");
|
|
242
|
+
},
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
test.each(registry.filter(([name]) => PUBLIC_TOOLS.has(name)))(
|
|
246
|
+
"%s is left to the wrapper, because it is going to run",
|
|
247
|
+
(name) => {
|
|
248
|
+
const ctx = makeCtx();
|
|
249
|
+
called(name, ctx);
|
|
250
|
+
// The double-count this avoids: a tool that reaches its body records its
|
|
251
|
+
// own line from inside it, with a real summary.
|
|
252
|
+
expect(retailSlot.get(ctx).activity, `${name} was double-recorded`).toEqual([]);
|
|
253
|
+
},
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
test("a tool this template did not declare is ignored", () => {
|
|
257
|
+
const ctx = makeCtx();
|
|
258
|
+
called("web_search", ctx);
|
|
259
|
+
// `gateFor` answers `undefined` for anything not built through `retailTool`
|
|
260
|
+
// — a builtin, or a tool a future author adds outside the wrapper. Recording
|
|
261
|
+
// those would put lines in the feed for calls this gate has no opinion on.
|
|
262
|
+
expect(retailSlot.get(ctx).activity).toEqual([]);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("the same tool stops being blocked once the caller is identified", async () => {
|
|
266
|
+
const ctx = makeCtx();
|
|
267
|
+
called("get_user_details", ctx);
|
|
268
|
+
expect(retailSlot.get(ctx).activity).toHaveLength(1);
|
|
269
|
+
|
|
270
|
+
// The hook asks the flow, so it follows the flow: same tool, same session,
|
|
271
|
+
// no line once the position allows it.
|
|
272
|
+
const authed = await bodyReachableCtx("get_user_details");
|
|
273
|
+
const before = retailSlot.get(authed).activity.length;
|
|
274
|
+
called("get_user_details", authed);
|
|
275
|
+
expect(retailSlot.get(authed).activity).toHaveLength(before);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
155
279
|
describe("the authentication gate", () => {
|
|
156
280
|
test.each(registry.filter(([name]) => !PUBLIC_TOOLS.has(name)))(
|
|
157
281
|
"%s refuses before the caller is identified",
|
|
@@ -159,6 +283,11 @@ describe("the authentication gate", () => {
|
|
|
159
283
|
const result = await def.execute(SAMPLE_ARGS[name] ?? {}, makeCtx());
|
|
160
284
|
expect(isToolFailure(result), `${name} did not refuse`).toBe(true);
|
|
161
285
|
if (!isToolFailure(result)) return;
|
|
286
|
+
// The refusal is `callFlow`'s: it names the position, and quotes the
|
|
287
|
+
// state's instruction — which names the two tools that get out of it.
|
|
288
|
+
expect(result.error, `${name} refused without naming the position`).toContain(
|
|
289
|
+
'"identifying"',
|
|
290
|
+
);
|
|
162
291
|
expect(result.error, `${name} refused for the wrong reason`).toContain(
|
|
163
292
|
"find_user_id_by_email",
|
|
164
293
|
);
|
|
@@ -197,3 +326,17 @@ describe("agent config", () => {
|
|
|
197
326
|
expect(a.store).toEqual(b.store);
|
|
198
327
|
});
|
|
199
328
|
});
|
|
329
|
+
|
|
330
|
+
describe("the transfer is terminal", () => {
|
|
331
|
+
test.each(registry)("%s refuses once the call is with a human", async (name, def) => {
|
|
332
|
+
const ctx = servingCtx();
|
|
333
|
+
callFlow.send(ctx, { type: "TRANSFERRED" });
|
|
334
|
+
|
|
335
|
+
const result = await def.execute(SAMPLE_ARGS[name] ?? {}, ctx);
|
|
336
|
+
// EVERY tool, the six public ones and `transfer_to_human_agents` itself
|
|
337
|
+
// included: nothing declares itself legal in the final state. Before the
|
|
338
|
+
// flow, the policy's "say nothing else after that" was enforced by nothing.
|
|
339
|
+
expect(isToolFailure(result), `${name} still ran after the handoff`).toBe(true);
|
|
340
|
+
expect(isToolFailure(result) && result.error, name).toContain('"transferred"');
|
|
341
|
+
});
|
|
342
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returning items from a delivered order, as a plan and an apply (see
|
|
3
|
+
* `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 } from "./store.ts";
|
|
10
|
+
|
|
11
|
+
export interface ReturnPlan {
|
|
12
|
+
readBack: string;
|
|
13
|
+
orderId: string;
|
|
14
|
+
/** In the order the caller named them — duplicates are meaningful. */
|
|
15
|
+
itemIds: string[];
|
|
16
|
+
/** Item names, positionally aligned with `itemIds`, so the readback and the
|
|
17
|
+
* result can name things rather than read ten-digit numbers aloud. */
|
|
18
|
+
itemNames: string[];
|
|
19
|
+
paymentMethodId: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function planReturn(
|
|
23
|
+
state: RetailState,
|
|
24
|
+
spokenOrderId: string,
|
|
25
|
+
itemIds: string[],
|
|
26
|
+
paymentMethodId: string,
|
|
27
|
+
): ReturnPlan | ToolFailure {
|
|
28
|
+
const user = authenticatedUser(state);
|
|
29
|
+
if (isToolFailure(user)) return user;
|
|
30
|
+
|
|
31
|
+
const order = resolveOrder(state, spokenOrderId);
|
|
32
|
+
if (isToolFailure(order)) return order;
|
|
33
|
+
|
|
34
|
+
if (order.status !== "delivered") {
|
|
35
|
+
return {
|
|
36
|
+
error: `Order ${order.order_id} is ${order.status}. Only a delivered order can be returned, and only once.`,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const method = findPaymentMethod(user, paymentMethodId);
|
|
41
|
+
if (isToolFailure(method)) return method;
|
|
42
|
+
|
|
43
|
+
const originalMethodId = order.payment_history[0]?.payment_method_id;
|
|
44
|
+
if (!isGiftCard(method) && paymentMethodId !== originalMethodId) {
|
|
45
|
+
return {
|
|
46
|
+
error: `A refund must go to the original payment method (${originalMethodId}) or to a gift card. ${paymentMethodId} is neither.`,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (itemIds.length === 0) {
|
|
51
|
+
return { error: "No items were listed to return." };
|
|
52
|
+
}
|
|
53
|
+
const held = new Map<string, number>();
|
|
54
|
+
for (const item of order.items) {
|
|
55
|
+
held.set(item.item_id, (held.get(item.item_id) ?? 0) + 1);
|
|
56
|
+
}
|
|
57
|
+
const asked = new Map<string, number>();
|
|
58
|
+
for (const itemId of itemIds) {
|
|
59
|
+
asked.set(itemId, (asked.get(itemId) ?? 0) + 1);
|
|
60
|
+
}
|
|
61
|
+
for (const [itemId, count] of asked) {
|
|
62
|
+
const available = held.get(itemId) ?? 0;
|
|
63
|
+
if (count > available) {
|
|
64
|
+
return {
|
|
65
|
+
error: `Order ${order.order_id} holds ${available} of item ${itemId}, but ${count} were listed for return.`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const itemNames = itemIds.map(
|
|
71
|
+
(id) => order.items.find((item) => item.item_id === id)?.name ?? id,
|
|
72
|
+
);
|
|
73
|
+
return {
|
|
74
|
+
readBack:
|
|
75
|
+
`return ${itemNames.join(", ")} from order ${order.order_id}, ` +
|
|
76
|
+
`with the refund going to ${paymentMethodId}`,
|
|
77
|
+
orderId: order.order_id,
|
|
78
|
+
itemIds: [...itemIds],
|
|
79
|
+
itemNames,
|
|
80
|
+
paymentMethodId,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function applyReturn(state: RetailState, plan: ReturnPlan) {
|
|
85
|
+
const order = state.store.orders[plan.orderId];
|
|
86
|
+
|
|
87
|
+
// In the order the caller named them, not sorted: tau2 sorted this list to
|
|
88
|
+
// match an expected end state, and nothing compares against one any more.
|
|
89
|
+
if (order) {
|
|
90
|
+
order.status = "return requested";
|
|
91
|
+
order.return_items = [...plan.itemIds];
|
|
92
|
+
order.return_payment_method_id = plan.paymentMethodId;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
order_id: plan.orderId,
|
|
97
|
+
status: "return requested" as const,
|
|
98
|
+
return_items: plan.itemIds,
|
|
99
|
+
refund_to: plan.paymentMethodId,
|
|
100
|
+
message: `Return requested on ${plan.orderId} for ${plan.itemNames.join(", ")}. The customer will get an email with return instructions, and the refund goes to ${plan.paymentMethodId} once the items arrive.`,
|
|
101
|
+
};
|
|
102
|
+
}
|