@agent-cards/commerce-sdk 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/buy-tool.js CHANGED
@@ -31,11 +31,19 @@
31
31
  * messages and links as one sequence, in the order they happened, and spaces
32
32
  * the messages like someone texting.
33
33
  */
34
- import { AgentcardApiError, AgentcardClient, } from './client.js';
34
+ import { AgentcardApiError, AgentcardClient, cartBuildingOf, } from './client.js';
35
35
  import { buyToolDefinition, buyToolStandardSchema, openAIBuyTool, parseBuyToolInput, } from './definition.js';
36
- import { forModel, forModelError, money, pickId, productCardKey, shownProducts, shownStores, stripLinks } from './model-view.js';
36
+ import { forModel, forModelError, pickId, productPrice, productCardKey, replyAsWritten, shownProducts, shownStores, stripLinks } from './model-view.js';
37
37
  import { messageStream, paceOf, sleep, toMessages, typingHook } from './messages.js';
38
- import { ACK_REACTION, ackAt, answersQuestion, instantAck as instantAckOf, isNewRequest, isPickText, namesStore, nextTemplate, slowStepLine, waitAt } from './ack.js';
38
+ import { isStale } from './shopper-message.js';
39
+ import { actionLinkId, isActionLink } from './action-links.js';
40
+ import { ACK_REACTION, ackAt, answersQuestion, instantAck as instantAckOf, isNewRequest, isPickText, keepsYes, namesStore, nextTemplate, slowStepLine, waitAt } from './ack.js';
41
+ /** The action a product of `merchant` takes (BuyToolProductAction). */
42
+ export function productAction(merchant) {
43
+ if (merchant === 'reservations')
44
+ return 'reserve';
45
+ return merchant === 'flights' || merchant === 'hotels' || merchant === 'tickets' || merchant === 'classes' ? 'book' : 'buy';
46
+ }
39
47
  const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
40
48
  /**
41
49
  * The model's messages for a turn: the history, what Agentcard told the shopper, then the shopper's
@@ -112,7 +120,18 @@ function productWords(text) {
112
120
  }
113
121
  /** How long after `instantAck` the model's first call still belongs to the turn it acknowledged. */
114
122
  const APP_TURN_MS = 120_000;
123
+ /** The links that ask the shopper to take a step: they go out after the message that asks for it. */
124
+ const STEP_LINK_KINDS = new Set(['vault', 'approval', 'store_login']);
125
+ /** The longest a step link waits for its message: an app that never writes one still sends the link. */
126
+ const STEP_LINK_HOLD_MS = 20_000;
115
127
  const STILL_WORKING = "Agentcard is still working on the shopper's last request. Call this tool again in a moment.";
128
+ const STILL_BUILDING = "The store is still building the cart for the shopper's last request. Its cart and total reach the shopper in a message from Agentcard: once that message has arrived, call this tool again with the shopper's words.";
129
+ const CART_NOT_READY = 'The store is still building the cart, so it has no total yet, and nothing was confirmed. Its cart and total reach the shopper in a message from Agentcard: confirm it only after the shopper says yes to that total.';
130
+ /** What the shopper hears while their message waits for the cart the store is still building. */
131
+ const stillBuildingLine = (store) => `still building your cart at ${store}, i'll get to this as soon as it's in`;
132
+ /** How often, and how many times, a message held for a cart being built reads whether the cart is in. */
133
+ const CART_HOLD_EVERY_MS = 2_000;
134
+ const CART_HOLD_READS = 45;
116
135
  const CARD_NEEDED = 'The shopper adds a card from a Vault link before Agentcard can buy for them.';
117
136
  const STILL_PLACING = 'The shopper approved, and the order is still going through.';
118
137
  const NOT_PLACED = 'The order did not go through.';
@@ -125,6 +144,40 @@ const NOTHING_TO_CONFIRM = 'There is no cart to confirm yet. Ask the shopper wha
125
144
  const ADDRESS_CHANGED = 'The delivery address changed since this cart was built. Ask for the cart again, and Agentcard prices it for the new address.';
126
145
  const STORE_PICK_GONE = "The store the shopper picked is no longer among the stores Agentcard found, so no store was chosen. Call this tool again with the shopper's words and store_id null, and Agentcard searches again.";
127
146
  const PICK_GONE = "The product the shopper picked is no longer among the products Agentcard found, so nothing was added. Call this tool again with the shopper's words and product_id null, and Agentcard searches again.";
147
+ /** What a call of a `nextStep` turn answers with when the shopper has not said yes to the cart on the table. */
148
+ const ASK_FOR_YES = "The cart is ready, and the shopper has not said yes to it in the chat, so nothing was confirmed. Say the cart's total in one short line and ask for their yes.";
149
+ const STEP_AFTER_DECLINE = 'The shopper declined the approval, so nothing was charged and no order was placed. The cart is still here. Say so in one short line and offer the way forward: their yes sends a new approval link, or they can change the order.';
150
+ const CARD_AFTER_SIGN_IN = 'The shopper is signed in to the store. A card is the next step: ask them to add one from the link.';
151
+ const NO_CART_WAITING = 'No cart is waiting. Carry on with what the shopper last asked for, in one short message.';
152
+ const STEP_APPROVAL_WAITING = 'The shopper already has the link to approve this payment, and nothing was charged yet. Ask for nothing else: in one short line, remind them to approve it from the link they have.';
153
+ const STEP_PLACEMENT_RUNNING = 'The order is going through now. Ask for nothing: say in one short line that it is being placed and that you will tell them once it is.';
154
+ /** What the shopper just did outside the chat, for the line that opens a `nextStep` turn. */
155
+ function stepDone(message) {
156
+ const store = message.facts.merchantName;
157
+ switch (message.kind) {
158
+ case 'card_added':
159
+ return 'The shopper just saved a card outside the chat.';
160
+ case 'store_connected':
161
+ return `The shopper just signed in to ${store ?? 'the store'} outside the chat.`;
162
+ case 'approval_declined':
163
+ return `The shopper just declined the approval${store ? ` for the ${store} order` : ''} outside the chat. Nothing was charged and no order was placed.`;
164
+ default:
165
+ return 'The shopper just finished a step outside the chat.';
166
+ }
167
+ }
168
+ /**
169
+ * The words a `nextStep` turn hands the model as the user's message: the event, marked as no one's
170
+ * typing, and the instruction to take the next step now in one short message, without the filler of
171
+ * a step announced and a question about what comes next.
172
+ */
173
+ export function nextStepPrompt(message) {
174
+ const note = String(message.note ?? '').trim();
175
+ return [
176
+ note || `[event: ${stepDone(message)} This note was generated by the system, not typed by the user.]`,
177
+ `${stepDone(message)} The shopper has not written anything since: this turn starts on its own. Take the next step now, as your next message to them. Call the buy tool once, with ask "next step", and do what its answer says, in one short message.`,
178
+ "Don't tell them they're all set or ready, and don't ask what's next. Never say an order is placed or a card charged unless the buy tool's answer says so.",
179
+ ].join('\n\n');
180
+ }
128
181
  /**
129
182
  * The pick as a later read of the conversation shows it: a pick the early answer found missing is in
130
183
  * the cart once a line carries its catalog id, as its product id or, for a retail product whose id is
@@ -152,11 +205,14 @@ function productsOfCatalog(catalog) {
152
205
  ref: item.ref,
153
206
  name: item.name,
154
207
  priceCents: item.priceCents,
208
+ ...(item.price_from ? { priceFrom: true } : {}),
155
209
  currency: item.currency,
156
210
  imageUrl: item.image_url,
211
+ images: item.image_urls ?? (item.image_url ? [item.image_url] : []),
157
212
  ...(item.flight ? { flight: item.flight } : {}),
158
213
  store: item.store?.name ?? item.store?.id ?? catalog.merchant_name ?? null,
159
214
  url: item.url ?? null,
215
+ action: productAction(catalog.merchant),
160
216
  linkSent: false,
161
217
  }));
162
218
  }
@@ -170,6 +226,14 @@ function storeLoginOf(turn) {
170
226
  return [];
171
227
  return [{ kind: 'store_login', url: turn.store_login_url, expiresAt: turn.store_login_expires_at, store: turn.store_login_merchant_name }];
172
228
  }
229
+ /**
230
+ * The approval link for a cart, naming the cart's store in `store` when the answer names one, so a
231
+ * message about another store's cart never ends this approval (`actionLinkOutcome`).
232
+ */
233
+ function approvalLink(url, expiresAt, cart) {
234
+ const store = cart?.merchant_name;
235
+ return { kind: 'approval', url, expiresAt, ...(store ? { store } : {}) };
236
+ }
173
237
  /** The answer before its status is read: every field the purchase answer carries. */
174
238
  function answerOf(turn) {
175
239
  return {
@@ -192,6 +256,7 @@ function answerOf(turn) {
192
256
  stores: storesOf(turn.stores),
193
257
  store_pick: turn.store_pick,
194
258
  hashes: hashesOf(turn.cart, turn.carts),
259
+ cart_building: turn.cart_building,
195
260
  envelope: turn.raw,
196
261
  };
197
262
  }
@@ -259,7 +324,7 @@ function resultOfStatus(turn) {
259
324
  return {
260
325
  ...answer,
261
326
  status: 'approval_sent',
262
- links: [...answer.links, ...(turn.approval_url ? [{ kind: 'approval', url: turn.approval_url, expiresAt: turn.approval_expires_at }] : [])],
327
+ links: [...answer.links, ...(turn.approval_url ? [approvalLink(turn.approval_url, turn.approval_expires_at, turn.cart)] : [])],
263
328
  shopper_need_not_reply: turn.placement_announced === true,
264
329
  };
265
330
  }
@@ -292,16 +357,19 @@ function resultOfRefusal(error) {
292
357
  stores: [],
293
358
  store_pick: null,
294
359
  hashes: hashesOf(null, carts),
360
+ cart_building: cartBuildingOf(error.body.cart_building),
295
361
  envelope: error.body,
296
362
  });
297
363
  if (error.code.startsWith('delivery_address'))
298
364
  return answer(ADDRESS_CHANGED, []);
365
+ if (error.code === 'cart_not_ready')
366
+ return answer(CART_NOT_READY, []);
299
367
  if (error.code === 'cart_changed')
300
368
  return answer(CART_CHANGED, error.carts);
301
369
  if (error.code === 'nothing_to_confirm' || error.code === 'conversation_closed')
302
370
  return answer(NOTHING_TO_CONFIRM, []);
303
371
  if (error.code === 'turn_in_progress')
304
- return answer(STILL_WORKING, []);
372
+ return answer(cartBuildingOf(error.body.cart_building) ? STILL_BUILDING : STILL_WORKING, []);
305
373
  if (error.code === 'pick_not_found')
306
374
  return answer(PICK_GONE, []);
307
375
  if (error.code === 'store_pick_not_found')
@@ -435,15 +503,29 @@ function feedReply(text) {
435
503
  };
436
504
  return { add, settled };
437
505
  }
438
- /** A message stream for a turn's feed: the messages it cuts are one reply, `feedReply`'s. */
439
- function feedStream(text) {
506
+ /**
507
+ * A message stream for a turn's feed: the messages it cuts are one reply, `feedReply`'s. `ended` runs
508
+ * once the last message is cut and has its place in the feed, before it went out, so what it adds
509
+ * follows the reply. `end` settles once the reply and what `ended` added went out, and rejects with
510
+ * the first failure.
511
+ */
512
+ function feedStream(text, ended) {
440
513
  const reply = feedReply(text);
441
514
  const inner = messageStream({ onMessage: reply.add, pace: false });
442
515
  return {
443
516
  push: (delta) => inner.push(delta),
444
517
  end: async () => {
445
- await inner.end();
518
+ let added = Promise.resolve();
519
+ try {
520
+ await inner.end();
521
+ }
522
+ finally {
523
+ added = ended();
524
+ // Read below, after the reply: a failure here is never left unhandled while the reply settles.
525
+ added.catch(() => { });
526
+ }
446
527
  await reply.settled();
528
+ await added;
447
529
  },
448
530
  };
449
531
  }
@@ -529,6 +611,9 @@ export function buyTool(options) {
529
611
  throw new TypeError('Pass onResult as a function that takes each answer.');
530
612
  }
531
613
  const onResult = o.onResult;
614
+ if (o.replyAsWritten !== undefined && typeof o.replyAsWritten !== 'boolean')
615
+ throw new TypeError('Pass replyAsWritten as true or false.');
616
+ const writtenByDefault = o.replyAsWritten === true;
532
617
  const wait = o.wait ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
533
618
  if (typeof wait !== 'function')
534
619
  throw new TypeError('Pass wait as a function of milliseconds that resolves after the pause.');
@@ -578,12 +663,14 @@ export function buyTool(options) {
578
663
  sendLink,
579
664
  onWorking,
580
665
  onReaction,
666
+ tell: o.onWorking,
581
667
  onResult,
582
668
  onProgress,
583
669
  links: null,
584
670
  results: null,
585
671
  pages: null,
586
672
  storePages: null,
673
+ event: null,
587
674
  });
588
675
  /**
589
676
  * The turn the calls made straight on the tool belong to, which the tool tells apart by timing. A call
@@ -600,6 +687,31 @@ export function buyTool(options) {
600
687
  let lastNextAction = null;
601
688
  /** The last reply Agentcard wrote: a message that answers its question for an address or a code gets no instant reply. */
602
689
  let lastReply = null;
690
+ /** How many carts the last answer held: with two or more, a new order goes to the same purchase (answerIn). */
691
+ let lastCartCount = 0;
692
+ /** The conversation and the carts of the last answer, for a `nextStep` call that cannot read the conversation. */
693
+ let lastConversationId = null;
694
+ let lastCarts = [];
695
+ /** The hash of the cart the last answer showed: the cart a confirm sent now confirms. */
696
+ let lastShownHash = null;
697
+ /**
698
+ * The yes the shopper gave in the chat to one cart, by its hash, when the confirm it sent stopped for a
699
+ * step outside the chat: a card to add or a store to sign in to. A `nextStep` turn confirms that cart
700
+ * once the step is done. Any shopper message that is not a plain go-ahead, `ok` or `done` takes it back.
701
+ */
702
+ let carriedYes = null;
703
+ /** The add-card link an answer carried while it asked for the store sign-in first: the card is the step after it. */
704
+ let cardAfterSignIn = null;
705
+ /** The shopper's words that never ran because they were not connected yet: they run once their card is saved. */
706
+ let unconnectedAsk = null;
707
+ /** The events this tool already took a `nextStep` turn for. */
708
+ const stepped = new Set();
709
+ /**
710
+ * How many shopper messages took back what the shopper asked for before them (any message that is not a plain go-ahead).
711
+ * A call that started before one of them leaves no yes and no words for a later step: its answer can land after the
712
+ * shopper said no, and a yes it carried would be confirmed by the next card or sign-in (review of #9220).
713
+ */
714
+ let withdrawals = 0;
603
715
  /** The names of the products the last answer showed, which a pick takes by `product_id`: a message that names one picks it. */
604
716
  let shownNames = [];
605
717
  const namesShownProduct = (text) => {
@@ -623,8 +735,21 @@ export function buyTool(options) {
623
735
  lastTemplate = nextTemplate(lastTemplate);
624
736
  return { picked, text: said ? ackAt(lastTemplate, text) : null };
625
737
  };
738
+ /**
739
+ * A shopper message that is not a plain go-ahead takes back a yes the shopper gave before it, and words of theirs that
740
+ * never ran because they were not connected yet ("never mind" after them): a card saved later replays neither. Every
741
+ * way a shopper message reaches the tool runs it: `turn`, `instantAck` and `instantReaction` (review of #9220).
742
+ */
743
+ const withdrawUnlessGoAhead = (shopperText) => {
744
+ if (keepsYes(String(shopperText ?? '')))
745
+ return;
746
+ carriedYes = null;
747
+ unconnectedAsk = null;
748
+ withdrawals++;
749
+ };
626
750
  /** The app's own instant reply: the turn its message opens sends no `onWorking` reply. */
627
751
  const instantAck = (shopperText) => {
752
+ withdrawUnlessGoAhead(shopperText);
628
753
  const { picked, text } = ackOfMessage(shopperText);
629
754
  appAck = { at: Date.now(), said: text !== null, picked };
630
755
  return text;
@@ -714,6 +839,40 @@ export function buyTool(options) {
714
839
  },
715
840
  };
716
841
  };
842
+ /**
843
+ * A message sent while the store builds the cart the last request asked for (`turn_in_progress` with `cart_building`) is
844
+ * never refused for it: the shopper hears the store is still building their cart, once, and the message waits until
845
+ * the cart is in, reading the conversation every two seconds for at most 90. True once the cart is in and the message
846
+ * can go; false for any other refusal, and when the wait gave up.
847
+ */
848
+ const holdForCart = async (error, t, quiet) => {
849
+ if (!(error instanceof AgentcardApiError) || error.status !== 409 || error.code !== 'turn_in_progress' || !error.conversationId)
850
+ return false;
851
+ const building = cartBuildingOf(error.body.cart_building);
852
+ if (!building)
853
+ return false;
854
+ // The acknowledgement this message was due goes nowhere: the shopper hears the cart is still building instead, and no
855
+ // search runs meanwhile for it to speak of (Greptile on #9652).
856
+ quiet?.();
857
+ try {
858
+ await t.tell?.(stillBuildingLine(building.store));
859
+ }
860
+ catch {
861
+ // The app's display failing never fails the purchase call.
862
+ }
863
+ // The 90 seconds hold however slow a read is: each read gets only the time left, so a read that hangs ends the wait.
864
+ const deadline = Date.now() + CART_HOLD_READS * CART_HOLD_EVERY_MS;
865
+ for (let i = 0; i < CART_HOLD_READS && Date.now() < deadline; i++) {
866
+ await wait(CART_HOLD_EVERY_MS);
867
+ const left = deadline - Date.now();
868
+ if (left <= 0)
869
+ break;
870
+ const conversation = await readWithin(error.conversationId, left);
871
+ if (conversation && !conversation.cart_building && !conversation.turn_in_progress)
872
+ return true;
873
+ }
874
+ return false;
875
+ };
717
876
  /** One read of the conversation, or null when it fails: a read only ever improves an answer the tool already has. */
718
877
  const read = async (conversationId) => {
719
878
  try {
@@ -723,6 +882,17 @@ export function buyTool(options) {
723
882
  return null;
724
883
  }
725
884
  };
885
+ /** One read that gives up after `ms`, as a failed read does: the read itself is no longer waited for. */
886
+ const readWithin = async (conversationId, ms) => {
887
+ let timer;
888
+ const late = new Promise((resolve) => { timer = setTimeout(() => resolve(null), ms); });
889
+ try {
890
+ return await Promise.race([read(conversationId), late]);
891
+ }
892
+ finally {
893
+ clearTimeout(timer);
894
+ }
895
+ };
726
896
  /** The order this purchase placed, or the reason it stopped when the ledger says it did. */
727
897
  const placed = (base, order, conversation) => {
728
898
  const conversationId = conversation.conversation_id || base.conversation_id;
@@ -809,7 +979,7 @@ export function buyTool(options) {
809
979
  ...base,
810
980
  status: 'approval_sent',
811
981
  reply: APPROVAL_WAITING,
812
- links: [{ kind: 'approval', url: last.approval_url, expiresAt: last.approval_expires_at }],
982
+ links: [approvalLink(last.approval_url, last.approval_expires_at, base.cart)],
813
983
  approval_expires_at: last.approval_expires_at,
814
984
  next_action: 'approve',
815
985
  shopper_need_not_reply: announced,
@@ -975,7 +1145,7 @@ export function buyTool(options) {
975
1145
  // The id the model reads and a pick takes, never the store page: the page is `url`.
976
1146
  const productId = pickId(product);
977
1147
  // A flight option has no page at a store: its card carries no url, and its productId picks it.
978
- await send({ kind: 'product', url: product.url || null, title: product.name, price: money(product.priceCents, product.currency), ...(productId ? { productId } : {}), imageUrl: product.imageUrl, expiresAt: null });
1148
+ await send({ kind: 'product', url: product.url || null, title: product.name, price: productPrice(product), ...(productId ? { productId } : {}), action: product.action ?? 'buy', imageUrl: product.imageUrl, images: product.images ?? (product.imageUrl ? [product.imageUrl] : []), expiresAt: null });
979
1149
  }
980
1150
  catch {
981
1151
  continue;
@@ -1037,37 +1207,148 @@ export function buyTool(options) {
1037
1207
  };
1038
1208
  /** The Vault or approval link first, because the purchase waits on it, then the store pages, each to the call's own turn. */
1039
1209
  const deliver = async (result, call, send) => {
1040
- let delivered = result;
1041
- if (result.links.length > 0 && send) {
1042
- for (const link of result.links)
1210
+ // Each step link carries its stable id, in what the shopper is sent and in what the call returns.
1211
+ const stamped = result.links.some((link) => isActionLink(link) && !link.id)
1212
+ ? { ...result, links: result.links.map((link) => (isActionLink(link) && !link.id ? { ...link, id: actionLinkId(link) } : link)) }
1213
+ : result;
1214
+ let delivered = stamped;
1215
+ if (stamped.links.length > 0 && send) {
1216
+ for (const link of stamped.links)
1043
1217
  await send({ ...link });
1044
- delivered = { ...result, link_sent: true };
1218
+ delivered = { ...stamped, link_sent: true };
1045
1219
  }
1046
1220
  return deliverPagesInTurn(delivered, call, send);
1047
1221
  };
1222
+ /**
1223
+ * What a shopper's own call leaves for a later `nextStep` turn: the yes a confirm gave, while the
1224
+ * confirm stopped for a card or a store sign-in; the add-card link an answer held back while it asked
1225
+ * for the sign-in first; and the words of a shopper who was not connected yet.
1226
+ */
1227
+ const noteShopperStep = (result, turn, call) => {
1228
+ // A shopper message that took things back while this call ran wins over what the call carried, and a newer call's
1229
+ // state stays as it is: an answer from before the withdrawal changes nothing here (review of #9220).
1230
+ if (call.withdrawalsAtStart !== withdrawals)
1231
+ return;
1232
+ const outside = result.status === 'needs_card' || result.next_action === 'add_card' || result.next_action === 'store_login';
1233
+ if (call.confirm)
1234
+ carriedYes = outside && call.yesHash ? { hash: call.yesHash } : null;
1235
+ if (result.next_action === 'store_login' && turn?.add_card_url)
1236
+ cardAfterSignIn = { url: turn.add_card_url, expiresAt: turn.add_card_expires_at };
1237
+ else if (result.next_action !== 'store_login')
1238
+ cardAfterSignIn = null;
1239
+ if (!call.confirm && call.ask !== '')
1240
+ unconnectedAsk = result.error_code === 'user_not_connected' ? call.ask : null;
1241
+ };
1242
+ /** An answer the tool writes itself, from where the purchase stands, with nothing sent to Agentcard. */
1243
+ const stepResult = (fields) => ({
1244
+ messages: [],
1245
+ cart: null,
1246
+ carts: [],
1247
+ products: [],
1248
+ links: [],
1249
+ link_sent: false,
1250
+ approval_expires_at: null,
1251
+ shopper_need_not_reply: false,
1252
+ conversation_id: null,
1253
+ order_id: null,
1254
+ decline_code: null,
1255
+ error_code: null,
1256
+ next_action: null,
1257
+ pick: null,
1258
+ stores: [],
1259
+ store_pick: null,
1260
+ hashes: [],
1261
+ envelope: {},
1262
+ ...fields,
1263
+ });
1264
+ /**
1265
+ * How a call of a `nextStep` turn goes. It never sends the model's ask, which Agentcard could read as
1266
+ * the shopper's go-ahead. It sends the shopper's own words that never ran because they were not
1267
+ * connected yet, once their card is saved, or a confirm of the one cart the shopper already said yes
1268
+ * to, by that cart's hash. Every other call answers from a read of the conversation: the card link the
1269
+ * sign-in held back, the cart and the ask for the shopper's yes, or the way forward after a decline.
1270
+ */
1271
+ const stepPlan = async (event) => {
1272
+ const conversationId = event.conversationId ?? lastConversationId;
1273
+ const conversation = conversationId ? await read(conversationId) : null;
1274
+ const carts = conversation ? conversation.carts : lastCarts;
1275
+ const one = carts.length === 1 ? carts[0] : null;
1276
+ const who = {
1277
+ ...(o.deliveryAddress ? { delivery_address: o.deliveryAddress } : {}),
1278
+ ...(client.company ? { external_user_id: externalUserId } : {}),
1279
+ };
1280
+ const standing = { cart: one ?? carts.at(-1) ?? null, carts, hashes: carts.map((c) => c.hash), conversation_id: conversationId };
1281
+ if (event.kind === 'approval_declined') {
1282
+ carriedYes = null;
1283
+ return { local: stepResult({ ...standing, status: 'needs_input', reply: carts.length > 0 ? STEP_AFTER_DECLINE : NO_CART_WAITING }) };
1284
+ }
1285
+ if (event.kind === 'card_added' && unconnectedAsk) {
1286
+ const ask = unconnectedAsk;
1287
+ unconnectedAsk = null;
1288
+ // Every purchase through the tool pays from the Vault, so a replayed ask names it too and its carts are quoted that way.
1289
+ return { body: { resume: true, ask, payment_source: 'vault', ...who } };
1290
+ }
1291
+ if (event.kind === 'store_connected' && cardAfterSignIn && !(Date.parse(cardAfterSignIn.expiresAt ?? '') <= Date.now())) {
1292
+ const link = cardAfterSignIn;
1293
+ cardAfterSignIn = null;
1294
+ return { local: stepResult({ ...standing, status: 'needs_card', reply: CARD_AFTER_SIGN_IN, links: [{ kind: 'vault', url: link.url, expiresAt: link.expiresAt }], next_action: 'add_card' }) };
1295
+ }
1296
+ if (carriedYes && one && conversationId && one.hash === carriedYes.hash) {
1297
+ carriedYes = null;
1298
+ return { body: { confirm: one.hash, conversation_id: conversationId, payment_source: 'vault', ...who } };
1299
+ }
1300
+ // A checkout the shopper already moved past their yes (an approval link waiting, or the order going through) is reported as
1301
+ // it stands: the cart stays while it waits, and asking for another yes would contradict the link they have (review of #9220).
1302
+ const last = conversation?.last_checkout ?? null;
1303
+ const approvalOpen = !!last?.approval_url && !(Date.parse(last.approval_expires_at ?? '') <= Date.now());
1304
+ // A checkout whose order the store already took (`pending` while the charge settles, with its order id) is reported as
1305
+ // placed, with that order id, as the recovery path does, never as still going through (review of #9220).
1306
+ const order = conversation && last?.order_id ? placedOrderOf(conversation, 0) : null;
1307
+ if (conversation && order)
1308
+ return { local: placed(stepResult({ ...standing, status: 'needs_input', reply: '' }), order, conversation) };
1309
+ if (conversation && placementRunning(conversation))
1310
+ return { local: stepResult({ ...standing, status: 'needs_input', reply: STEP_PLACEMENT_RUNNING }) };
1311
+ if (approvalOpen)
1312
+ return { local: stepResult({ ...standing, status: 'needs_input', reply: STEP_APPROVAL_WAITING, next_action: 'approve' }) };
1313
+ return { local: stepResult({ ...standing, status: 'needs_input', reply: carts.length > 0 ? ASK_FOR_YES : NO_CART_WAITING, next_action: carts.length > 0 ? 'confirm' : null }) };
1314
+ };
1048
1315
  /**
1049
1316
  * One call, for the turn `explicit` names, or, made straight on the tool, for the turn the tool reads
1050
1317
  * from its timing. Every link, answer and instant reply of the call goes to the turn it started in.
1051
1318
  */
1052
1319
  const answerIn = async (raw, explicit) => {
1053
1320
  const startedAt = Date.now();
1321
+ // A step link held for the message that introduces it goes out before this call hands over any of its own.
1322
+ explicit?.release?.();
1323
+ const withdrawalsAtStart = withdrawals;
1054
1324
  const input = parseBuyToolInput(raw);
1055
- // A new order is never a confirm: a model that sets both drops the cart and places nothing.
1325
+ // With carts at more than one store waiting, a new order would drop every one of them: "cancel the
1326
+ // reservation, keep the flowers" started a new conversation and lost the flowers cart (2026-10-20).
1327
+ // The ask goes to the same purchase instead, and Agentcard drops only the cart it names.
1328
+ const newOrder = input.new_order && lastCartCount < 2;
1329
+ // A new order is never a confirm: a model that sets both drops the cart and places nothing, even
1330
+ // when the ask stays in the same purchase ("cancel the reservation" never checks out a cart).
1056
1331
  const confirm = input.confirm && !input.new_order;
1057
1332
  const ask = input.ask.trim();
1058
1333
  if (!confirm && ask === '')
1059
1334
  throw new TypeError("Put the shopper's words in ask.");
1060
1335
  // A pick chooses from the products or stores this conversation showed: never on a confirm, and never on a new order, which shows none yet.
1061
- const picks = !confirm && !input.new_order;
1336
+ const picks = !confirm && !newOrder;
1062
1337
  const pick = picks && input.product_id ? { product_id: input.product_id } : picks && input.store_id ? { store_id: input.store_id } : null;
1063
1338
  const body = {
1064
1339
  resume: true,
1065
- ...(confirm ? { confirm: true, payment_source: 'vault' } : { ask }),
1066
- ...(input.new_order ? { new_order: true } : {}),
1340
+ ...(confirm ? { confirm: true } : { ask }),
1341
+ // Every purchase through the tool pays from the shopper's Vault, so every call says so: an ask's carts are then
1342
+ // quoted with the fees the Vault payment charges, and the estimate the shopper sees is the one the confirm pays
1343
+ // (2026-10-21: a cart read $15.67 on its asks and $17.09 at its confirm).
1344
+ payment_source: 'vault',
1345
+ ...(newOrder ? { new_order: true } : {}),
1067
1346
  ...(pick ? { pick } : {}),
1068
1347
  ...(o.deliveryAddress ? { delivery_address: o.deliveryAddress } : {}),
1069
1348
  ...(client.company ? { external_user_id: externalUserId } : {}),
1070
1349
  };
1350
+ // The cart a confirm sent now confirms, for the yes it carries past a step outside the chat.
1351
+ const yesHash = confirm ? lastShownHash : null;
1071
1352
  // The first call after `instantAck` opens the turn of the message it answered, whose reply is the
1072
1353
  // app's. Any other call that starts while another runs, or right after one ended, is the model
1073
1354
  // working through the same turn. A call made through `buy.turn` belongs to that turn alone.
@@ -1085,16 +1366,21 @@ export function buyTool(options) {
1085
1366
  }
1086
1367
  // The call keeps the turn it started in, whichever turn opens while it runs.
1087
1368
  const t = explicit ?? implicit;
1369
+ // A turn the tool took on its own sends no ask: it reads where the purchase stands, and only the
1370
+ // shopper's own yes to this exact cart goes in, for that cart's hash.
1371
+ const plan = t.event ? await stepPlan(t.event) : null;
1372
+ const sent = plan && 'body' in plan ? plan.body : body;
1373
+ const confirmSent = plan ? 'body' in plan && sent.confirm !== undefined : confirm;
1088
1374
  const callTurn = t.number;
1089
1375
  const send = senderOf(t);
1090
1376
  const progressOf = t.onProgress;
1091
1377
  // A confirm streams as a search does, and only a search's products go out early.
1092
1378
  const streams = o.stream !== false && ((earlyCards && !!send) || !!progressOf);
1093
- const early = earlyCards && !!send && !confirm;
1379
+ const early = earlyCards && !!send && !confirmSent;
1094
1380
  running += 1;
1095
1381
  // A confirm or a pick of what the shopper saw never hears "looking for": once the call is slow, it hears a
1096
1382
  // fixed line that names nothing they said (slowStepLine), so a 20-second checkout or menu read is never silent.
1097
- const ack = holdAck(ask, t, confirm ? 'confirm' : pick?.product_id ? 'product' : pick ? 'pick' : null);
1383
+ const ack = t.event ? null : holdAck(ask, t, confirm ? 'confirm' : pick?.product_id ? 'product' : pick ? 'pick' : null);
1098
1384
  try {
1099
1385
  let turn = null;
1100
1386
  let refusal;
@@ -1118,19 +1404,49 @@ export function buyTool(options) {
1118
1404
  : {}),
1119
1405
  }
1120
1406
  : undefined;
1121
- try {
1122
- turn = await client.buy(body, handlers);
1407
+ if (plan && 'local' in plan) {
1408
+ // Nothing went to Agentcard: the answer is where the purchase stands.
1123
1409
  }
1124
- catch (error) {
1125
- refusal = error;
1410
+ else {
1411
+ try {
1412
+ turn = await client.buy(sent, handlers);
1413
+ }
1414
+ catch (error) {
1415
+ refusal = error;
1416
+ }
1417
+ }
1418
+ // The store was still building the last request's cart: once it is in, the request that was refused goes as it was
1419
+ // sent, a step's own request too (a saved ask is no longer saved once its step sent it).
1420
+ if (!turn && !confirmSent && (await holdForCart(refusal, t, () => ack?.cancel()))) {
1421
+ try {
1422
+ turn = await client.buy(sent, handlers);
1423
+ refusal = undefined;
1424
+ }
1425
+ catch (error) {
1426
+ refusal = error;
1427
+ }
1126
1428
  }
1127
1429
  ack?.cancel();
1128
- const settled = turn ? await settleTurn(turn, startedAt) : await settleRefusal(refusal, confirm, startedAt);
1430
+ const settled = plan && 'local' in plan ? plan.local : turn ? await settleTurn(turn, startedAt) : await settleRefusal(refusal, confirmSent, startedAt);
1129
1431
  await t.ackOut;
1130
1432
  const delivered = await deliver(withMessages(settled, turn), { turn: callTurn, presented: turn?.catalog?.presented === true, owner: t }, send);
1131
1433
  const result = t.ackDelivered ? { ...delivered, shopper_already_told_looking: true } : delivered;
1132
1434
  lastNextAction = result.next_action;
1133
1435
  lastReply = result.reply;
1436
+ // A refusal reports no carts, so only a turn's answer says how many wait.
1437
+ if (turn) {
1438
+ lastCartCount = result.carts.length;
1439
+ lastCarts = result.carts;
1440
+ }
1441
+ if (result.conversation_id)
1442
+ lastConversationId = result.conversation_id;
1443
+ if (result.cart?.hash)
1444
+ lastShownHash = result.cart.hash;
1445
+ // A shopper's own call, or the shopper's yes or words a turn taken on its own sent, leaves what the next step needs.
1446
+ if (!t.event)
1447
+ noteShopperStep(result, turn, { confirm: !!confirm, yesHash, ask, withdrawalsAtStart });
1448
+ else if (plan && 'body' in plan)
1449
+ noteShopperStep(result, turn, { confirm: confirmSent, yesHash: typeof sent.confirm === 'string' ? sent.confirm : null, ask: sent.ask ?? '', withdrawalsAtStart });
1134
1450
  // An answer that lists products replaces the names, even when every one is too short to keep.
1135
1451
  const shown = shownProducts(result.products);
1136
1452
  if (shown.length > 0)
@@ -1194,10 +1510,37 @@ export function buyTool(options) {
1194
1510
  * with another message's, and it sends its instant reply at once, as `instantAck` would read it.
1195
1511
  */
1196
1512
  const turn = (shopperText, options) => {
1513
+ withdrawUnlessGoAhead(String(shopperText ?? ''));
1514
+ return openTurn(String(shopperText ?? ''), options, null);
1515
+ };
1516
+ /**
1517
+ * The turn the agent takes on its own after a step the shopper finished outside the chat, or null
1518
+ * when there is none to take (`BuyTool.nextStep`). The message is answered by the model's words, so
1519
+ * the client never hands its text to a later turn as words the shopper read.
1520
+ */
1521
+ const nextStep = (message, options) => {
1522
+ if (!message || typeof message !== 'object' || typeof message.kind !== 'string')
1523
+ throw new TypeError('Pass the message onMessage got.');
1524
+ if (message.advances !== true || isStale(message))
1525
+ return null;
1526
+ if (client.company && message.externalUserId !== externalUserId)
1527
+ return null;
1528
+ if (message.eventId && stepped.has(message.eventId))
1529
+ return null;
1530
+ // A call for the shopper's own message is still running: its answer tells the model where the purchase stands.
1531
+ if (running > 0)
1532
+ return null;
1533
+ if (message.eventId)
1534
+ stepped.add(message.eventId);
1535
+ if (client.company)
1536
+ client.answered(externalUserId, message);
1537
+ return openTurn(nextStepPrompt(message), options, message);
1538
+ };
1539
+ const openTurn = (shopperText, options, event) => {
1197
1540
  const own = options ?? {};
1198
1541
  if (typeof own !== 'object')
1199
1542
  throw new TypeError('Pass the turn options as an object, or leave them out.');
1200
- for (const key of ['sendLink', 'onWorking', 'onReaction', 'onResult', 'onProgress', 'onItem']) {
1543
+ for (const key of ['sendLink', 'onWorking', 'onReaction', 'onResult', 'onProgress', 'onItem', 'onMessage']) {
1201
1544
  if (own[key] !== undefined && typeof own[key] !== 'function')
1202
1545
  throw new TypeError(`Pass ${key} to the turn as a function, or leave it out.`);
1203
1546
  }
@@ -1205,30 +1548,97 @@ export function buyTool(options) {
1205
1548
  if (onItem && (own.sendLink !== undefined || own.onWorking !== undefined)) {
1206
1549
  throw new TypeError('Pass onItem, or sendLink and onWorking, to the turn, not both: onItem hears every link and message of the turn.');
1207
1550
  }
1551
+ if (onItem && own.onMessage !== undefined) {
1552
+ throw new TypeError('Pass onItem or onMessage to the turn, not both: onItem hears every message of the turn.');
1553
+ }
1554
+ const onMessage = onItem ? undefined : own.onMessage;
1555
+ if (own.replyAsWritten !== undefined && typeof own.replyAsWritten !== 'boolean')
1556
+ throw new TypeError('Pass replyAsWritten to the turn as true or false, or leave it out.');
1557
+ const written = own.replyAsWritten ?? writtenByDefault;
1208
1558
  // Messages are spaced only for an app that hears each one as it goes out.
1209
1559
  const pauseOf = paceOf(own.pace);
1210
- const feed = itemFeed(onItem ? pauseOf : null, typingHook(own.onTyping));
1560
+ const feed = itemFeed(onItem || onMessage ? pauseOf : null, typingHook(own.onTyping));
1211
1561
  // With onItem, every link and message goes to it alone, in order. Without, each goes where it went before, and the turn lists it.
1212
1562
  const baseSendLink = onItem ? undefined : own.sendLink ?? sendLink;
1213
1563
  const baseOnWorking = onItem ? undefined : own.onWorking ?? onWorking;
1214
1564
  // `ack: false` sends no instant reply, whoever would hear it.
1215
1565
  const acks = o.ack !== false && (!!onItem || !!baseOnWorking);
1566
+ /**
1567
+ * Step links held for the message that asks for them. A turn whose model writes through it
1568
+ * (`onItem`, or `messageStream()`/`say()` once called) holds each step link a call hands over until
1569
+ * that message went out: the tool answers before the model writes, so the link used to reach the
1570
+ * chat above the words that explain it (2026-10-21). Product and store cards are what the message
1571
+ * is about, and go out at once. A held link goes out once the turn's next message ends, when the
1572
+ * next call starts, or after STEP_LINK_HOLD_MS, so no link is ever kept from the shopper.
1573
+ */
1574
+ const held = [];
1575
+ // `onMessage` says the app writes the model through the turn, so its links wait for the message too (review of #9234).
1576
+ let writesThroughTurn = !!onItem || !!onMessage;
1577
+ let holdTimer = null;
1578
+ let linkNow = () => { };
1579
+ // A held link that failed to go out when no reply was ending (its timer ran out, or the next call started): the next
1580
+ // reply that ends rejects with it, so the app learns the shopper is missing a link (review of #9234).
1581
+ let lateFailure = null;
1582
+ /** Every held link joins the feed now, in order: settled once each went out, rejected with the first failure. */
1583
+ const release = () => {
1584
+ if (holdTimer)
1585
+ clearTimeout(holdTimer);
1586
+ holdTimer = null;
1587
+ const sends = held.splice(0).map((link) => {
1588
+ try {
1589
+ return Promise.resolve(linkNow(link));
1590
+ }
1591
+ catch (error) {
1592
+ return Promise.reject(error);
1593
+ }
1594
+ });
1595
+ return Promise.all(sends).then(() => undefined);
1596
+ };
1597
+ const releaseLate = () => {
1598
+ release().catch((error) => {
1599
+ lateFailure ??= { error };
1600
+ });
1601
+ };
1602
+ /** The release a reply ends with: it waits for the held links, and fails with theirs or an earlier late failure. */
1603
+ const releaseForReply = async () => {
1604
+ await release();
1605
+ if (lateFailure) {
1606
+ const { error } = lateFailure;
1607
+ lateFailure = null;
1608
+ throw error;
1609
+ }
1610
+ };
1216
1611
  const state = {
1217
1612
  ...toolTurn(++modelTurn),
1613
+ event,
1218
1614
  sendLink: (link) => feed.push({ kind: 'link', link }, () => (onItem ? onItem({ kind: 'link', link: { ...link } }) : baseSendLink?.(link))),
1219
1615
  // A reaction goes to onReaction even with onItem: it sits on the shopper's bubble, not in the turn's sequence.
1220
1616
  ...ackRoutes(acks ? (text) => feed.push({ kind: 'text', text }, () => (onItem ? onItem({ kind: 'text', text }) : baseOnWorking?.(text))) : undefined, own.onReaction ?? o.onReaction),
1221
1617
  onResult: own.onResult ?? onResult,
1222
1618
  onProgress: own.onProgress ?? onProgress,
1619
+ tell: onItem || baseOnWorking ? (text) => feed.push({ kind: 'text', text }, () => (onItem ? onItem({ kind: 'text', text }) : baseOnWorking?.(text))) : undefined,
1223
1620
  links: [],
1224
1621
  results: [],
1225
1622
  };
1226
- /** A message the app's model wrote: it goes to onItem, after every item before it, and the turn lists it. */
1227
- const text = (message, skip) => feed.push({ kind: 'text', text: message }, () => onItem?.({ kind: 'text', text: message }), skip);
1623
+ linkNow = state.sendLink;
1624
+ state.release = releaseLate;
1625
+ state.sendLink = (link) => {
1626
+ if (!writesThroughTurn || !STEP_LINK_KINDS.has(link.kind))
1627
+ return linkNow(link);
1628
+ held.push({ ...link });
1629
+ if (!holdTimer) {
1630
+ holdTimer = setTimeout(releaseLate, STEP_LINK_HOLD_MS);
1631
+ holdTimer.unref?.();
1632
+ }
1633
+ return Promise.resolve();
1634
+ };
1635
+ /** A message the app's model wrote: it goes to onItem, else onMessage, after every item before it, and the turn lists it. */
1636
+ const text = (message, skip) => feed.push({ kind: 'text', text: message }, () => (onItem ? onItem({ kind: 'text', text: message }) : onMessage?.(message)), skip);
1228
1637
  let ack = null;
1229
1638
  const working = state.onWorking;
1230
1639
  const reacting = state.onReaction;
1231
- if (working || reacting) {
1640
+ // A turn the agent takes on its own answers no shopper message, so nothing gets a 👀 or an instant reply.
1641
+ if (!event && (working || reacting)) {
1232
1642
  const message = String(shopperText ?? '');
1233
1643
  const read = ackOfMessage(message);
1234
1644
  let text = read.text;
@@ -1310,24 +1720,47 @@ export function buyTool(options) {
1310
1720
  };
1311
1721
  return toolOf((raw) => answerIn(raw, state), {}, {
1312
1722
  agentcardMessages: { get: () => [...(handed ?? kept)], enumerable: false },
1723
+ event: { value: event, enumerable: false },
1724
+ release: {
1725
+ value: () => {
1726
+ if (!event)
1727
+ return;
1728
+ if (event.eventId)
1729
+ stepped.delete(event.eventId);
1730
+ if (client.company)
1731
+ client.unanswered(externalUserId, event);
1732
+ },
1733
+ enumerable: false,
1734
+ },
1313
1735
  messages: { value: messagesFor, enumerable: false },
1314
1736
  ack: { value: ack, enumerable: false },
1315
1737
  reaction: { get: () => state.reaction, enumerable: false },
1316
1738
  links: { get: () => [...links], enumerable: false },
1317
1739
  results: { get: () => [...results], enumerable: false },
1740
+ replyAsWritten: { get: () => (written && results.length > 0 ? replyAsWritten(results[results.length - 1]) : null), enumerable: false },
1318
1741
  items: { get: () => feed.items.map((item) => (item.kind === 'link' ? { kind: 'link', link: { ...item.link } } : { ...item })), enumerable: false },
1319
1742
  // Each message joins the feed the moment it is cut, so a link the tool hands over during a pause never overtakes text already written.
1320
- messageStream: { value: () => feedStream(text), enumerable: false },
1743
+ // A step link held for this message goes out right after its last paragraph, once the stream ends.
1744
+ messageStream: {
1745
+ value: () => {
1746
+ writesThroughTurn = true;
1747
+ return feedStream(text, releaseForReply);
1748
+ },
1749
+ enumerable: false,
1750
+ },
1321
1751
  say: {
1322
1752
  value: (whole) => {
1753
+ writesThroughTurn = true;
1323
1754
  const reply = feedReply(text);
1324
1755
  for (const message of toMessages(whole))
1325
1756
  reply.add(message);
1326
- return reply.settled();
1757
+ const released = releaseForReply();
1758
+ released.catch(() => { });
1759
+ return reply.settled().then(() => released);
1327
1760
  },
1328
1761
  enumerable: false,
1329
1762
  },
1330
1763
  });
1331
1764
  };
1332
- return toolOf(answer, { instantAck, instantReaction, turn }, {});
1765
+ return toolOf(answer, { instantAck, instantReaction, turn, nextStep }, {});
1333
1766
  }