@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.
Files changed (234) hide show
  1. package/README.md +1 -0
  2. package/dist/{_agent-DpH2pBJd.mjs → _agent-BTwZJu4P.mjs} +2 -2
  3. package/dist/{_bundler-C2BDwU7r.mjs → _bundler-CDuYl5Gb.mjs} +2 -2
  4. package/dist/_dev-env.d.ts +22 -2
  5. package/dist/{_dev-server-LLLWnFBs.mjs → _dev-server-Me06wgeF.mjs} +38 -47
  6. package/dist/_dev-server.d.ts +0 -17
  7. package/dist/{_init-C57bWAQ8.mjs → _init-BhEfQ6Yi.mjs} +3 -2
  8. package/dist/{_preflight-BtfaYtbE.mjs → _preflight-Co1G9Jww.mjs} +1 -1
  9. package/dist/_preflight.d.ts +1 -1
  10. package/dist/_resource-commands.d.ts +23 -0
  11. package/dist/{_slug-api-BxrsJXVI.mjs → _slug-api-z-USxPtL.mjs} +16 -12
  12. package/dist/_slug-api.d.ts +5 -2
  13. package/dist/{_studio-U3jhKlA3.mjs → _studio-DR5yzZ1T.mjs} +2 -1
  14. package/dist/{_templates-Dxb_P2Wz.mjs → _templates-4WcKOjS5.mjs} +5 -6
  15. package/dist/_templates.d.ts +3 -4
  16. package/dist/_workflow-scan.d.ts +108 -0
  17. package/dist/{build-Dfza2pRI.mjs → build-BBxf8vom.mjs} +6 -3
  18. package/dist/cli.mjs +346 -349
  19. package/dist/{client-bundler-BEIqgOtd.mjs → client-bundler-CWnG42cU.mjs} +2 -1
  20. package/dist/client-bundler.mjs +1 -1
  21. package/dist/{delete-vbpjGzqs.mjs → delete-Dl_LSfvI.mjs} +3 -3
  22. package/dist/{deploy-B8lubiRT.mjs → deploy-DRC8jWr8.mjs} +5 -5
  23. package/dist/{dev-Uq5ujP8a.mjs → dev-BvcctCn2.mjs} +1 -1
  24. package/dist/eval-DfQ8cm5p.mjs +56 -0
  25. package/dist/eval.d.ts +43 -0
  26. package/dist/{init-l6xfU4xX.mjs → init-CUo87gcI.mjs} +3 -3
  27. package/dist/{login-C71-qz8F.mjs → login-BBuM1sxH.mjs} +3 -4
  28. package/dist/{logs-BWVGSjLR.mjs → logs-CQ2T46dr.mjs} +2 -2
  29. package/dist/project-config.mjs +1 -1
  30. package/dist/scaffold/CLAUDE.md +442 -80
  31. package/dist/scaffold/package.json +6 -3
  32. package/dist/scaffold/server.mjs +11 -4
  33. package/dist/scaffold/tsconfig.json +0 -1
  34. package/dist/{secret-BuMuFR4B.mjs → secret-Ct34kdgU.mjs} +2 -2
  35. package/dist/{storage-DzRZ-eCw.mjs → storage-i_79ZwAR.mjs} +2 -2
  36. package/dist/{studio-BCNUpDgP.mjs → studio-CzTzPqe1.mjs} +4 -4
  37. package/dist/templates/briefing-desk/agent.test.ts +350 -0
  38. package/dist/templates/briefing-desk/agent.ts +30 -0
  39. package/dist/templates/briefing-desk/shared.ts +200 -0
  40. package/dist/templates/briefing-desk/system-prompt.md +39 -0
  41. package/dist/templates/briefing-desk/tools/briefing_so_far.ts +35 -0
  42. package/dist/templates/briefing-desk/tools/research_topic.ts +88 -0
  43. package/dist/templates/briefing-desk/tools/verify_claim.ts +77 -0
  44. package/dist/templates/call-audit/agent.eval.test.ts +474 -0
  45. package/dist/templates/call-audit/agent.test.ts +57 -204
  46. package/dist/templates/call-audit/agent.ts +32 -19
  47. package/dist/templates/call-audit/client.tsx +17 -54
  48. package/dist/templates/call-audit/workflows/audit.ts +11 -18
  49. package/dist/templates/call-audit/workflows/ingest.ts +106 -114
  50. package/dist/templates/call-audit/workflows/media.ts +2 -12
  51. package/dist/templates/call-audit/workflows/summarize.ts +47 -52
  52. package/dist/templates/call-audit/workflows/sync-api.ts +7 -7
  53. package/dist/templates/code-interpreter/agent.eval.test.ts +165 -0
  54. package/dist/templates/dispatch-center/agent.eval.test.ts +349 -0
  55. package/dist/templates/dispatch-center/agent.test.ts +149 -25
  56. package/dist/templates/dispatch-center/client.tsx +239 -129
  57. package/dist/templates/dispatch-center/shared.ts +99 -1
  58. package/dist/templates/dispatch-center/system-prompt.md +3 -1
  59. package/dist/templates/dispatch-center/tools/incident_add_note.ts +16 -13
  60. package/dist/templates/dispatch-center/tools/incident_create.ts +19 -1
  61. package/dist/templates/dispatch-center/tools/incident_escalate.ts +68 -59
  62. package/dist/templates/dispatch-center/tools/incident_triage.ts +57 -43
  63. package/dist/templates/dispatch-center/tools/incident_update_status.ts +49 -40
  64. package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +8 -2
  65. package/dist/templates/dispatch-center/tools/resources_dispatch.ts +69 -52
  66. package/dist/templates/dispatch-center/tools/resources_update_status.ts +45 -41
  67. package/dist/templates/embedded-assets/agent.eval.test.ts +138 -0
  68. package/dist/templates/embedded-assets/agent.test.ts +17 -14
  69. package/dist/templates/health-assistant/agent.eval.test.ts +167 -0
  70. package/dist/templates/health-assistant/agent.test.ts +22 -4
  71. package/dist/templates/infocom-adventure/agent.eval.test.ts +158 -0
  72. package/dist/templates/infocom-adventure/agent.test.ts +53 -24
  73. package/dist/templates/infocom-adventure/agent.ts +21 -0
  74. package/dist/templates/infocom-adventure/client.tsx +246 -164
  75. package/dist/templates/infocom-adventure/shared.ts +20 -9
  76. package/dist/templates/infocom-adventure/system-prompt.md +1 -1
  77. package/dist/templates/infocom-adventure/tools/game_state_move.ts +3 -2
  78. package/dist/templates/link-digest/agent.eval.test.ts +195 -0
  79. package/dist/templates/link-digest/agent.test.ts +24 -19
  80. package/dist/templates/link-digest/client.tsx +47 -61
  81. package/dist/templates/link-digest/workflows/digest.ts +19 -31
  82. package/dist/templates/math-buddy/agent.eval.test.ts +146 -0
  83. package/dist/templates/night-owl/agent.eval.test.ts +272 -0
  84. package/dist/templates/night-owl/agent.test.ts +70 -19
  85. package/dist/templates/night-owl/agent.ts +5 -0
  86. package/dist/templates/night-owl/client.tsx +56 -56
  87. package/dist/templates/night-owl/shared.ts +24 -0
  88. package/dist/templates/night-owl/tools/recommend.ts +18 -6
  89. package/dist/templates/personal-finance/agent.eval.test.ts +168 -0
  90. package/dist/templates/personal-finance/system-prompt.md +4 -0
  91. package/dist/templates/pipeline-simple/agent.eval.test.ts +78 -0
  92. package/dist/templates/pipeline-simple/agent.ts +2 -2
  93. package/dist/templates/pizza-ordering/agent.eval.test.ts +171 -0
  94. package/dist/templates/pizza-ordering/agent.test.ts +42 -27
  95. package/dist/templates/pizza-ordering/client.tsx +9 -26
  96. package/dist/templates/pizza-ordering/shared.ts +20 -1
  97. package/dist/templates/pizza-ordering/system-prompt.md +6 -0
  98. package/dist/templates/plan-and-execute/agent.eval.test.ts +209 -0
  99. package/dist/templates/plan-and-execute/agent.test.ts +99 -58
  100. package/dist/templates/plan-and-execute/agent.ts +1 -1
  101. package/dist/templates/plan-and-execute/client.tsx +12 -15
  102. package/dist/templates/plan-and-execute/shared.ts +71 -2
  103. package/dist/templates/plan-and-execute/tools/plan_status.ts +18 -6
  104. package/dist/templates/plan-and-execute/tools/revise_plan.ts +18 -10
  105. package/dist/templates/plan-and-execute/tools/start_plan.ts +13 -2
  106. package/dist/templates/plan-and-execute/tools/work_next_step.ts +70 -22
  107. package/dist/templates/podcast-digest/agent.eval.test.ts +411 -0
  108. package/dist/templates/podcast-digest/agent.test.ts +717 -0
  109. package/dist/templates/podcast-digest/agent.ts +139 -0
  110. package/dist/templates/podcast-digest/client.tsx +154 -0
  111. package/dist/templates/podcast-digest/workflows/digest.ts +444 -0
  112. package/dist/templates/podcast-digest/workflows/feeds.ts +507 -0
  113. package/dist/templates/podcast-digest/workflows/slack.ts +88 -0
  114. package/dist/templates/recap-workflow/agent.eval.test.ts +366 -0
  115. package/dist/templates/recap-workflow/agent.test.ts +97 -94
  116. package/dist/templates/recap-workflow/tools/recap_progress.ts +9 -12
  117. package/dist/templates/recap-workflow/tools/recap_status.ts +8 -9
  118. package/dist/templates/recap-workflow/tools/request_recap.ts +2 -1
  119. package/dist/templates/recap-workflow/workflows/recap.ts +36 -40
  120. package/dist/templates/redline/agent.eval.test.ts +209 -0
  121. package/dist/templates/redline/agent.test.ts +17 -15
  122. package/dist/templates/redline/client.tsx +12 -12
  123. package/dist/templates/redline/workflows/redline.ts +19 -31
  124. package/dist/templates/research-workflow/agent.eval.test.ts +329 -0
  125. package/dist/templates/research-workflow/agent.test.ts +60 -59
  126. package/dist/templates/research-workflow/tools/research_progress.ts +9 -12
  127. package/dist/templates/research-workflow/tools/research_status.ts +9 -11
  128. package/dist/templates/research-workflow/workflows/research.ts +44 -61
  129. package/dist/templates/retail/address.ts +87 -1
  130. package/dist/templates/retail/agent.eval.test.ts +314 -0
  131. package/dist/templates/retail/agent.test.ts +195 -112
  132. package/dist/templates/retail/agent.ts +35 -1
  133. package/dist/templates/retail/cancel.ts +116 -0
  134. package/dist/templates/retail/client.tsx +265 -117
  135. package/dist/templates/retail/payment.ts +107 -0
  136. package/dist/templates/retail/pending.test.ts +196 -0
  137. package/dist/templates/retail/pending.ts +137 -0
  138. package/dist/templates/retail/registry.test.ts +150 -7
  139. package/dist/templates/retail/returns.ts +102 -0
  140. package/dist/templates/retail/shared.test.ts +42 -0
  141. package/dist/templates/retail/shared.ts +35 -0
  142. package/dist/templates/retail/store.test.ts +84 -15
  143. package/dist/templates/retail/store.ts +231 -48
  144. package/dist/templates/retail/swap.test.ts +4 -4
  145. package/dist/templates/retail/swap.ts +248 -15
  146. package/dist/templates/retail/system-prompt.md +69 -28
  147. package/dist/templates/retail/tools/cancel_change.ts +33 -0
  148. package/dist/templates/retail/tools/cancel_pending_order.ts +16 -60
  149. package/dist/templates/retail/tools/confirm_change.ts +42 -0
  150. package/dist/templates/retail/tools/exchange_delivered_order_items.ts +22 -67
  151. package/dist/templates/retail/tools/find_user_id_by_email.ts +8 -5
  152. package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +4 -5
  153. package/dist/templates/retail/tools/get_item_details.ts +3 -4
  154. package/dist/templates/retail/tools/get_order_details.ts +3 -4
  155. package/dist/templates/retail/tools/get_product_details.ts +3 -4
  156. package/dist/templates/retail/tools/get_user_details.ts +2 -2
  157. package/dist/templates/retail/tools/list_all_product_types.ts +5 -5
  158. package/dist/templates/retail/tools/modify_pending_order_address.ts +13 -25
  159. package/dist/templates/retail/tools/modify_pending_order_items.ts +23 -62
  160. package/dist/templates/retail/tools/modify_pending_order_payment.ts +16 -78
  161. package/dist/templates/retail/tools/modify_user_address.ts +13 -16
  162. package/dist/templates/retail/tools/return_delivered_order_items.ts +17 -72
  163. package/dist/templates/retail/tools/transfer_to_human_agents.ts +12 -6
  164. package/dist/templates/simple/agent.eval.test.ts +54 -0
  165. package/dist/templates/solo-rpg/agent.eval.test.ts +320 -0
  166. package/dist/templates/solo-rpg/agent.test.ts +227 -48
  167. package/dist/templates/solo-rpg/client.tsx +38 -37
  168. package/dist/templates/solo-rpg/shared.ts +145 -19
  169. package/dist/templates/solo-rpg/system-prompt.md +3 -2
  170. package/dist/templates/solo-rpg/tools/action_roll.ts +82 -63
  171. package/dist/templates/solo-rpg/tools/burn_momentum.ts +85 -50
  172. package/dist/templates/solo-rpg/tools/check_state.ts +24 -7
  173. package/dist/templates/solo-rpg/tools/load_game.ts +13 -1
  174. package/dist/templates/solo-rpg/tools/save_game.ts +16 -7
  175. package/dist/templates/solo-rpg/tools/setup_character.ts +22 -2
  176. package/dist/templates/solo-rpg/tools/update_state.ts +113 -100
  177. package/dist/templates/spoken-summary/agent.eval.test.ts +342 -0
  178. package/dist/templates/spoken-summary/agent.test.ts +68 -97
  179. package/dist/templates/spoken-summary/agent.ts +30 -17
  180. package/dist/templates/spoken-summary/client.tsx +10 -50
  181. package/dist/templates/spoken-summary/workflows/summarize.ts +17 -22
  182. package/dist/templates/spoken-summary/workflows/transcribe.ts +19 -26
  183. package/dist/templates/support-line/agent.eval.test.ts +273 -0
  184. package/dist/templates/support-line/agent.test.ts +11 -16
  185. package/dist/templates/support-line/agent.ts +1 -1
  186. package/dist/templates/support-line/client.tsx +9 -9
  187. package/dist/templates/support-line/nodes.ts +100 -0
  188. package/dist/templates/support-line/procedure.ts +407 -0
  189. package/dist/templates/support-line/system-prompt.md +2 -1
  190. package/dist/templates/support-line/tools/answer_question.ts +66 -4
  191. package/dist/templates/transcription-workflow/agent.eval.test.ts +234 -0
  192. package/dist/templates/transcription-workflow/agent.test.ts +94 -194
  193. package/dist/templates/transcription-workflow/agent.ts +1 -1
  194. package/dist/templates/transcription-workflow/client.tsx +17 -42
  195. package/dist/templates/transcription-workflow/workflows/batch.ts +19 -22
  196. package/dist/templates/transcription-workflow/workflows/normalize.ts +90 -186
  197. package/dist/templates/transcription-workflow/workflows/stitch.ts +0 -11
  198. package/dist/templates/transcription-workflow/workflows/stream.ts +4 -5
  199. package/dist/templates/transcription-workflow/workflows/sync-api.ts +6 -6
  200. package/dist/templates/transcription-workflow/workflows/transcribe.ts +32 -26
  201. package/dist/templates/transcription-workflow/workflows/wav.ts +0 -31
  202. package/dist/templates/travel-concierge/agent.eval.test.ts +323 -0
  203. package/dist/templates/travel-concierge/agent.test.ts +174 -43
  204. package/dist/templates/travel-concierge/agent.ts +18 -9
  205. package/dist/templates/travel-concierge/client.tsx +11 -23
  206. package/dist/templates/travel-concierge/routing.ts +34 -15
  207. package/dist/templates/travel-concierge/shared.ts +180 -12
  208. package/dist/templates/travel-concierge/system-prompt.md +7 -1
  209. package/dist/templates/travel-concierge/tools/book_car_rental.ts +8 -5
  210. package/dist/templates/travel-concierge/tools/book_excursion.ts +8 -5
  211. package/dist/templates/travel-concierge/tools/book_hotel.ts +8 -5
  212. package/dist/templates/travel-concierge/tools/cancel_ticket.ts +8 -5
  213. package/dist/templates/travel-concierge/tools/search_car_rentals.ts +9 -5
  214. package/dist/templates/travel-concierge/tools/search_excursions.ts +9 -5
  215. package/dist/templates/travel-concierge/tools/search_flights.ts +9 -6
  216. package/dist/templates/travel-concierge/tools/search_hotels.ts +9 -5
  217. package/dist/templates/travel-concierge/tools/update_ticket.ts +8 -5
  218. package/dist/templates/web-researcher/agent.eval.test.ts +121 -0
  219. package/dist/templates/web-researcher/system-prompt.md +15 -4
  220. package/dist/{test-9kPKJI-w.mjs → test-BvyT6sAc.mjs} +24 -10
  221. package/dist/test.d.ts +29 -5
  222. package/dist/{worker-bundler-CGD4r8Kc.mjs → worker-bundler-CCVEDjm1.mjs} +241 -3
  223. package/dist/worker-bundler.mjs +1 -1
  224. package/dist/{workflow-CFpxOFfQ.mjs → workflow-BflATMmU.mjs} +47 -12
  225. package/dist/workflow-bundler.d.ts +6 -1
  226. package/dist/workflow.d.ts +1 -1
  227. package/package.json +5 -4
  228. package/dist/eject-31gjtaHF.mjs +0 -116
  229. package/dist/eject.d.ts +0 -60
  230. package/dist/templates/call-audit/workflows/temp-media.ts +0 -138
  231. package/dist/templates/infocom-adventure/tools/game_state_history.ts +0 -14
  232. package/dist/templates/support-line/graph.ts +0 -224
  233. package/dist/{_config-D_s09e7g.mjs → _config-CmJOFsAP.mjs} +1 -1
  234. /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. Everything
10
- * else must refuse. Listed here so ADDING an unauthenticated tool is a
11
- * deliberate edit to this file, not a silent gap. */
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 fifteen tau2 retail tools", () => {
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
- const ctx = makeCtx();
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 = makeCtx();
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
+ }