@agent-cards/commerce-sdk 0.14.0 → 0.16.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
@@ -33,12 +33,13 @@
33
33
  */
34
34
  import { AgentcardApiError, AgentcardClient, cartBuildingOf, } from './client.js';
35
35
  import { buyToolDefinition, buyToolStandardSchema, openAIBuyTool, parseBuyToolInput, } from './definition.js';
36
- import { directCall } from './direct.js';
37
- import { forModel, forModelError, pickId, productPrice, productCardKey, replyAsWritten, shownProducts, shownStores, stripLinks } from './model-view.js';
36
+ import { directCall, isProductAsk } from './direct.js';
37
+ import { forModel, forModelError, pickId, productPrice, productCardKey, shownProducts, shownStores, stripLinks } from './model-view.js';
38
+ import { choiceLines, leadsIntoList, messageForShopper, messagesListChoice, replyAsWritten, textingStyle } from './texting.js';
38
39
  import { messageStream, paceOf, sleep, toMessages, typingHook } from './messages.js';
39
40
  import { isStale } from './shopper-message.js';
40
41
  import { actionLinkId, isActionLink } from './action-links.js';
41
- import { ACK_REACTION, ackAt, answersQuestion, instantAck as instantAckOf, isNewRequest, isPickText, keepsYes, namesStore, nextTemplate, slowStepLine, waitAt } from './ack.js';
42
+ import { ACK_REACTION, ackAt, answersCheckout, answersQuestion, holdsBack, instantAck as instantAckOf, isNewRequest, isPickText, keepsYes, namesStore, nextTemplate, slowStepLine, waitAt } from './ack.js';
42
43
  /** The action a product of `merchant` takes (BuyToolProductAction). */
43
44
  export function productAction(merchant) {
44
45
  if (merchant === 'reservations')
@@ -145,6 +146,70 @@ const NOTHING_TO_CONFIRM = 'There is no cart to confirm yet. Ask the shopper wha
145
146
  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.';
146
147
  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.";
147
148
  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.";
149
+ /** One cart line as the order holds it: the product, its variant, its choices and its quantity. Prices are left out. */
150
+ const lineKey = (line) => JSON.stringify([line.product_id ?? String(line.name ?? '').trim().toLowerCase(), line.variant ?? null, [...(line.options ?? [])].sort(), line.qty]);
151
+ /**
152
+ * How two matched lines' prices stand: false when a price they both name moved, true when one they both name agrees, and
153
+ * null when they name none in common, so the lines say nothing about what they cost.
154
+ */
155
+ function linePriceStands(a, b) {
156
+ const named = (x, y) => typeof x === 'number' && typeof y === 'number' ? x === y : null;
157
+ const read = [named(a.priceCents, b.priceCents), named(a.lineTotalCents, b.lineTotalCents)];
158
+ if (read.includes(false))
159
+ return false;
160
+ return read.includes(true) ? true : null;
161
+ }
162
+ /**
163
+ * True when two carts are the same order: the same store, the same items in the same quantities with the same choices,
164
+ * and the same price for those items. Only the total may differ, as when the store prices its fees, tax and delivery in
165
+ * once the shopper's account holds the cart (Mendocino Farms on DoorDash, 2026-10-27: about $15.90, then $17.89).
166
+ *
167
+ * What the items cost has to be shown to be the same: every matched line's prices agree, or both carts name a subtotal
168
+ * and it is equal. A line whose price moved, two lines whose prices swapped under one subtotal, and a cart nothing
169
+ * prices are each a different order, so the shopper is asked again (review of #11087).
170
+ */
171
+ function sameOrder(a, b) {
172
+ if (a.merchant !== b.merchant || (a.store?.id ?? null) !== (b.store?.id ?? null))
173
+ return false;
174
+ if ((a.currency ?? null) !== (b.currency ?? null))
175
+ return false;
176
+ const subtotals = typeof a.subtotalCents === 'number' && typeof b.subtotalCents === 'number' ? a.subtotalCents === b.subtotalCents : null;
177
+ if (subtotals === false)
178
+ return false;
179
+ const lines = (cart) => [...(cart.items ?? [])].sort((p, q) => (lineKey(p) < lineKey(q) ? -1 : lineKey(p) > lineKey(q) ? 1 : 0));
180
+ const [x, y] = [lines(a), lines(b)];
181
+ if (x.length === 0 || x.length !== y.length || !x.every((line, i) => lineKey(line) === lineKey(y[i])))
182
+ return false;
183
+ const prices = x.map((line, i) => linePriceStands(line, y[i]));
184
+ if (prices.includes(false))
185
+ return false;
186
+ return subtotals === true || prices.every((stands) => stands === true);
187
+ }
188
+ /** True when `now` holds an item `before` did not: the call put something new in the cart. */
189
+ function addsItem(now, before) {
190
+ if (!now || (now.items ?? []).length === 0)
191
+ return false;
192
+ const had = new Set((before && before.merchant === now.merchant ? before.items ?? [] : []).map((line) => lineKey({ ...line, qty: 0 })));
193
+ return (now.items ?? []).some((line) => !had.has(lineKey({ ...line, qty: 0 })));
194
+ }
195
+ /**
196
+ * The yes left standing when a confirm stopped for a step outside the chat, or null. The cart the confirm stopped on must
197
+ * be the same order as the one the yes covers; the total the shopper said yes to stays the one they saw.
198
+ */
199
+ function yesAfter(result, yes) {
200
+ const stopped = result.cart ?? (result.carts.length === 1 ? result.carts[0] : null);
201
+ if ('carried' in yes) {
202
+ if (stopped && !sameOrder(stopped, yes.carried.cart))
203
+ return null;
204
+ return { ...yes.carried, cart: stopped ?? yes.carried.cart, conversationId: result.conversation_id ?? yes.carried.conversationId };
205
+ }
206
+ const seen = yes.shown ?? stopped;
207
+ if (!seen || (yes.shown && stopped && !sameOrder(yes.shown, stopped)))
208
+ return null;
209
+ return { conversationId: result.conversation_id, cart: stopped ?? seen, totalCents: seen.totalCents };
210
+ }
211
+ /** The words of the call a `nextStep` turn's `direct` makes. They never reach Agentcard: the turn's calls send no ask. */
212
+ const NEXT_STEP_ASK = 'take the next step';
148
213
  /** What a call of a `nextStep` turn answers with when the shopper has not said yes to the cart on the table. */
149
214
  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.";
150
215
  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.';
@@ -201,7 +266,7 @@ export function productsOf(turn) {
201
266
  /** A product's card as `sendLink` hears it: the id the model reads and a pick takes, never the store page, which is `url`. A flight option has no page at a store: its card carries no url, and its productId picks it. */
202
267
  function productLink(product) {
203
268
  const productId = pickId(product);
204
- return { 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 };
269
+ return { 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, ...(product.availability ? { availability: product.availability } : {}) };
205
270
  }
206
271
  function productsOfCatalog(catalog) {
207
272
  return (catalog.items ?? [])
@@ -220,6 +285,7 @@ function productsOfCatalog(catalog) {
220
285
  url: item.url ?? null,
221
286
  action: productAction(catalog.merchant),
222
287
  linkSent: false,
288
+ ...(item.availability ? { availability: item.availability } : {}),
223
289
  }));
224
290
  }
225
291
  /** The stores an answer presents, as the tool reads them. */
@@ -263,6 +329,7 @@ function answerOf(turn) {
263
329
  store_pick: turn.store_pick,
264
330
  hashes: hashesOf(turn.cart, turn.carts),
265
331
  cart_building: turn.cart_building,
332
+ choice: turn.choice ?? null,
266
333
  envelope: turn.raw,
267
334
  };
268
335
  }
@@ -673,7 +740,7 @@ export function buyTool(options) {
673
740
  if (o.stream !== undefined && o.stream !== false)
674
741
  throw new TypeError('Pass stream as false to wait for each whole answer, or leave it out.');
675
742
  const onProgress = o.onProgress;
676
- for (const key of ['onStatus', 'onFound']) {
743
+ for (const key of ['onStatus', 'onFound', 'onAvailability']) {
677
744
  if (o[key] !== undefined && typeof o[key] !== 'function')
678
745
  throw new TypeError(`Pass ${key} as a function, or leave it out.`);
679
746
  }
@@ -688,6 +755,9 @@ export function buyTool(options) {
688
755
  if (o.replyAsWritten !== undefined && typeof o.replyAsWritten !== 'boolean')
689
756
  throw new TypeError('Pass replyAsWritten as true or false.');
690
757
  const writtenByDefault = o.replyAsWritten === true;
758
+ if (o.textingStyle !== undefined && typeof o.textingStyle !== 'boolean')
759
+ throw new TypeError('Pass textingStyle as true or false.');
760
+ const textingByDefault = o.textingStyle === true;
691
761
  if (o.direct !== undefined && typeof o.direct !== 'boolean')
692
762
  throw new TypeError('Pass direct as true or false.');
693
763
  const directOn = o.direct === true;
@@ -745,8 +815,10 @@ export function buyTool(options) {
745
815
  onProgress,
746
816
  onStatus: o.onStatus,
747
817
  onFound: o.onFound,
818
+ onAvailability: o.onAvailability,
748
819
  onProducts: o.onProducts,
749
820
  onText: o.onText,
821
+ texting: textingByDefault,
750
822
  onCart: o.onCart,
751
823
  links: null,
752
824
  results: null,
@@ -779,14 +851,25 @@ export function buyTool(options) {
779
851
  /** The conversation and the carts of the last answer, for a `nextStep` call that cannot read the conversation. */
780
852
  let lastConversationId = null;
781
853
  let lastCarts = [];
782
- /** The hash of the cart the last answer showed: the cart a confirm sent now confirms. */
783
- let lastShownHash = null;
854
+ /** The cart the last answer showed: the cart a confirm sent now confirms. */
855
+ let lastShownCart = null;
784
856
  /**
785
- * The yes the shopper gave in the chat to one cart, by its hash, when the confirm it sent stopped for a
786
- * step outside the chat: a card to add or a store to sign in to. A `nextStep` turn confirms that cart
787
- * once the step is done. Any shopper message that is not a plain go-ahead, `ok` or `done` takes it back.
857
+ * The yes the shopper gave in the chat to one cart, when the confirm it sent stopped for a step outside the chat: a card
858
+ * to add or a store to sign in to. A `nextStep` turn confirms the cart once the step is done, as long as it holds the
859
+ * same items at the same store (`sameOrder`): a total that moved because the store priced its fees and tax in keeps the
860
+ * yes, and the turn says the new total. Any shopper message that is not a plain go-ahead, `ok` or `done` takes it back,
861
+ * and so does a declined approval.
788
862
  */
789
863
  let carriedYes = null;
864
+ /**
865
+ * The go-ahead of the shopper's pick, when they picked what is in the cart and answered only the checkout's questions
866
+ * since (address, phone, name, email, a yes to an address read back). A `nextStep` turn after a saved card or a store
867
+ * sign-in confirms that cart as it stands, when it is the same order (`sameOrder`), whatever the Vault does: the approval
868
+ * link is the shopper's approval. Any other shopper message takes it back, and so do a cart that changed and a declined approval.
869
+ */
870
+ let pickedOrder = null;
871
+ /** How many shopper messages took a pick's go-ahead back: a call that started before one leaves no pick behind. */
872
+ let pickWithdrawals = 0;
790
873
  /** The add-card link an answer carried while it asked for the store sign-in first: the card is the step after it. */
791
874
  let cardAfterSignIn = null;
792
875
  /** The shopper's words that never ran because they were not connected yet: they run once their card is saved. */
@@ -832,7 +915,17 @@ export function buyTool(options) {
832
915
  * way a shopper message reaches the tool runs it: `turn`, `instantAck` and `instantReaction` (review of #9220).
833
916
  */
834
917
  const withdrawUnlessGoAhead = (shopperText) => {
835
- if (keepsYes(String(shopperText ?? '')))
918
+ const text = String(shopperText ?? '');
919
+ const goAhead = keepsYes(text);
920
+ // A pick's go-ahead also stands through the answers the checkout asks for, and through the answer that lets the store
921
+ // cart the pick, such as a size, while no cart holds it yet.
922
+ // Counted with no pick standing too, so a pick still running when the shopper says "no wait" leaves none behind.
923
+ // A short `cancel`, `wait` or `no` answers the question's shape, so it never counts as the answer the pick waits on.
924
+ if (!goAhead && !answersCheckout(text, { nextAction: lastNextAction, asked: lastReply }) && !(pickedOrder?.cart === null && answersQuestion(text, lastReply) && !holdsBack(text))) {
925
+ pickedOrder = null;
926
+ pickWithdrawals++;
927
+ }
928
+ if (goAhead)
836
929
  return;
837
930
  carriedYes = null;
838
931
  unconnectedAsk = null;
@@ -1320,7 +1413,7 @@ export function buyTool(options) {
1320
1413
  return;
1321
1414
  const outside = result.status === 'needs_card' || result.next_action === 'add_card' || result.next_action === 'store_login';
1322
1415
  if (call.confirm)
1323
- carriedYes = outside && call.yesHash ? { hash: call.yesHash } : null;
1416
+ carriedYes = outside ? yesAfter(result, call.yes) : null;
1324
1417
  if (result.next_action === 'store_login' && turn?.add_card_url)
1325
1418
  cardAfterSignIn = { url: turn.add_card_url, expiresAt: turn.add_card_expires_at };
1326
1419
  else if (result.next_action !== 'store_login')
@@ -1328,6 +1421,45 @@ export function buyTool(options) {
1328
1421
  if (!call.confirm && call.ask !== '')
1329
1422
  unconnectedAsk = result.error_code === 'user_not_connected' ? call.ask : null;
1330
1423
  };
1424
+ /**
1425
+ * What a shopper's own call leaves of a pick's go-ahead (`pickedOrder`). A pick, a tap on a product's card or a request
1426
+ * to buy it, that leaves one cart and no list of products to choose from starts one, with that cart; one whose store still
1427
+ * asks a question before it carts the pick starts one with no cart yet. A later answer keeps it while the cart stays the
1428
+ * same order, and notes the cart as the chat shows it now; an answer whose cart changed, another purchase, more than one
1429
+ * cart, or a confirm of the shopper's own ends it. `heard` is false for a call whose shopper message no turn read
1430
+ * (`withdrawUnlessGoAhead`): its words must then answer the checkout too.
1431
+ */
1432
+ const notePick = (result, call) => {
1433
+ if (call.pickWithdrawalsAtStart !== pickWithdrawals)
1434
+ return;
1435
+ const now = result.cart ?? (result.carts.length === 1 ? result.carts[0] : null);
1436
+ if (call.picking) {
1437
+ // A search that listed products beside a cart already there picked nothing in it; one that carted what it names did.
1438
+ const listed = shownProducts(result.products).length > 0;
1439
+ pickedOrder = result.carts.length > 1
1440
+ ? null
1441
+ : now && (!listed || call.added)
1442
+ ? { conversationId: result.conversation_id, cart: now }
1443
+ : !now && !listed && result.error_code === null
1444
+ ? { conversationId: result.conversation_id, cart: null }
1445
+ : null;
1446
+ return;
1447
+ }
1448
+ const picked = pickedOrder;
1449
+ if (!picked)
1450
+ return;
1451
+ if (call.confirm || result.carts.length > 1 || (picked.conversationId && result.conversation_id && result.conversation_id !== picked.conversationId)) {
1452
+ pickedOrder = null;
1453
+ return;
1454
+ }
1455
+ if (!call.heard && !keepsYes(call.ask) && !answersCheckout(call.ask, { nextAction: call.asked.nextAction, asked: call.asked.reply })) {
1456
+ pickedOrder = null;
1457
+ return;
1458
+ }
1459
+ if (!now)
1460
+ return;
1461
+ pickedOrder = picked.cart === null || sameOrder(now, picked.cart) ? { conversationId: picked.conversationId ?? result.conversation_id, cart: now } : null;
1462
+ };
1331
1463
  /** An answer the tool writes itself, from where the purchase stands, with nothing sent to Agentcard. */
1332
1464
  const stepResult = (fields) => ({
1333
1465
  messages: [],
@@ -1369,6 +1501,7 @@ export function buyTool(options) {
1369
1501
  const standing = { cart: one ?? carts.at(-1) ?? null, carts, hashes: carts.map((c) => c.hash), conversation_id: conversationId };
1370
1502
  if (event.kind === 'approval_declined') {
1371
1503
  carriedYes = null;
1504
+ pickedOrder = null;
1372
1505
  return { local: stepResult({ ...standing, status: 'needs_input', reply: carts.length > 0 ? STEP_AFTER_DECLINE : NO_CART_WAITING }) };
1373
1506
  }
1374
1507
  if (event.kind === 'card_added' && unconnectedAsk) {
@@ -1382,9 +1515,28 @@ export function buyTool(options) {
1382
1515
  cardAfterSignIn = null;
1383
1516
  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' }) };
1384
1517
  }
1385
- if (carriedYes && one && conversationId && one.hash === carriedYes.hash) {
1518
+ // The shopper's yes goes on past the step they just finished: the same items at the same store, in the same purchase,
1519
+ // confirmed by the cart's hash as it stands now. A total that moved because the store priced its fees and tax in keeps
1520
+ // the yes, and the answer says the new total beside the next ask; the approval link is still the shopper's consent to
1521
+ // the payment (Karen, 2026-10-27).
1522
+ const yes = carriedYes;
1523
+ const inPurchase = (at) => at === null || at === conversationId;
1524
+ if (yes && one && conversationId && inPurchase(yes.conversationId) && sameOrder(one, yes.cart)) {
1386
1525
  carriedYes = null;
1387
- return { body: { confirm: one.hash, conversation_id: conversationId, payment_source: 'vault', ...who } };
1526
+ return { body: { confirm: one.hash, conversation_id: conversationId, payment_source: 'vault', ...who }, yes: { ...yes, cart: one, conversationId } };
1527
+ }
1528
+ // The shopper picked what is in the cart and finished the step outside the chat: finishing it is their go-ahead for the
1529
+ // same order, confirmed by the cart's hash as it stands now, and the approval link stays their consent to the payment
1530
+ // (Karen, 2026-10-27: "we are again asking for confirmation after user adds a card. we should assume they want to move
1531
+ // forward"). That holds whatever the shopper's Vault does: the approval link is the approval (Karen, 2026-10-27: "That
1532
+ // is the goal of the approval link. You shouldn't be asking again. Take the approval from the approval link as the
1533
+ // approval.").
1534
+ const picked = pickedOrder;
1535
+ if (picked?.cart && one && conversationId && inPurchase(picked.conversationId) && (event.kind === 'card_added' || event.kind === 'store_connected')) {
1536
+ pickedOrder = null;
1537
+ if (sameOrder(one, picked.cart)) {
1538
+ return { body: { confirm: one.hash, conversation_id: conversationId, payment_source: 'vault', ...who }, yes: { conversationId, cart: one, totalCents: picked.cart.totalCents, picked: true } };
1539
+ }
1388
1540
  }
1389
1541
  // A checkout the shopper already moved past their yes (an approval link waiting, or the order going through) is reported as
1390
1542
  // it stands: the cart stays while it waits, and asking for another yes would contradict the link they have (review of #9220).
@@ -1441,13 +1593,22 @@ export function buyTool(options) {
1441
1593
  ...(client.company ? { external_user_id: externalUserId } : {}),
1442
1594
  };
1443
1595
  // The cart a confirm sent now confirms, for the yes it carries past a step outside the chat.
1444
- const yesHash = confirm ? lastShownHash : null;
1596
+ const yesShown = confirm ? lastShownCart : null;
1597
+ // A pick of what the shopper saw, by a product's id or its name, or a request to buy something: when its answer carts
1598
+ // it, the shopper's go-ahead for that cart (`notePick`). Read before the answer replaces what the shopper saw.
1599
+ const picksShown = !confirm && (!!pick?.product_id || pickOf(ask) !== null || isProductAsk(ask));
1600
+ const cartBefore = lastShownCart;
1601
+ const pickWithdrawalsAtStart = pickWithdrawals;
1602
+ const askedBefore = { nextAction: lastNextAction, reply: lastReply };
1445
1603
  // The first call after `instantAck` opens the turn of the message it answered, whose reply is the
1446
1604
  // app's. Any other call that starts while another runs, or right after one ended, is the model
1447
1605
  // working through the same turn. A call made through `buy.turn` belongs to that turn alone.
1606
+ // Whether a turn read the shopper's message (`withdrawUnlessGoAhead`): `buy.turn`, or the app's instant reply.
1607
+ let heard = !!explicit;
1448
1608
  if (!explicit) {
1449
1609
  const opened = appAck && Date.now() - appAck.at < APP_TURN_MS ? appAck : null;
1450
1610
  appAck = null;
1611
+ heard = !!opened;
1451
1612
  if (opened) {
1452
1613
  // Only a reply the app sent quiets onWorking. A message such as `yes` that starts a slow search
1453
1614
  // still gets a reply that names nothing, and a slow pick gets one that says it is on it.
@@ -1476,6 +1637,8 @@ export function buyTool(options) {
1476
1637
  // A confirm or a pick of what the shopper saw never hears "looking for": once the call is slow, it hears a
1477
1638
  // fixed line that names nothing they said (slowStepLine), so a 20-second checkout or menu read is never silent.
1478
1639
  const ack = t.event ? null : holdAck(ask, t, confirm ? 'confirm' : pick?.product_id ? 'product' : pick ? 'pick' : null);
1640
+ /** Says `unknown` of every card the call left `checking`; it runs once the answer's cards went out, or when the call fails. */
1641
+ let settleLeft = null;
1479
1642
  try {
1480
1643
  let turn = null;
1481
1644
  let refusal;
@@ -1486,11 +1649,68 @@ export function buyTool(options) {
1486
1649
  * and every message after it, so the shopper reads them in the order Agentcard wrote them.
1487
1650
  */
1488
1651
  const waiting = [];
1652
+ /** True when the last message the shopper read ended in a colon or a question mark, so choices that open the next one are its list. */
1653
+ let leadIn = false;
1654
+ /** Shows one of Agentcard's messages through onText, its lists as bullets (messageForShopper); `told` keeps it as Agentcard wrote it. */
1655
+ const showText = async (text, conversationId) => {
1656
+ await t.onText(messageForShopper(text, leadIn, t.texting), { conversationId });
1657
+ leadIn = leadsIntoList(text);
1658
+ told.push(text);
1659
+ };
1489
1660
  /** The products past the cards of the catalog this call heard last, for the count that follows it. */
1490
1661
  let moreFound = null;
1491
1662
  /** Whether this call's cards went out, and a count that came before them. */
1492
1663
  let cardsOut = false;
1493
1664
  let heldCount = null;
1665
+ /** The card each catalog id of this call went out as (its `productId`), and the cards still `checking`, by conversation. */
1666
+ const cardOfId = new Map();
1667
+ const checking = new Map();
1668
+ /**
1669
+ * What the live search answered for each card, so a later copy of the same place, such as a card whose first send
1670
+ * failed and goes out again with the answer, carries that answer and never goes back to `checking`.
1671
+ */
1672
+ const answered = new Map();
1673
+ const withAnswers = (products) => answered.size === 0 ? products : products.map((product) => {
1674
+ const known = product.availability ? answered.get(pickId(product) ?? '') : undefined;
1675
+ return known ? { ...product, availability: known } : product;
1676
+ });
1677
+ /**
1678
+ * Notes the card each product goes out as, and each place still `checking`, whichever way the app got it: a card
1679
+ * through `sendLink` now or on a later try, or a product past the cards that `onFound` hands over for "show more".
1680
+ */
1681
+ const noteCards = (products, conversationId) => {
1682
+ for (const product of products) {
1683
+ const productId = pickId(product);
1684
+ if (!productId)
1685
+ continue;
1686
+ cardOfId.set(product.id, productId);
1687
+ if (product.availability?.status === 'checking' && !answered.has(productId))
1688
+ checking.set(productId, conversationId);
1689
+ }
1690
+ };
1691
+ /** Hands the app what the live search said of the places, each by its card, and settles those cards. */
1692
+ settleLeft = async () => {
1693
+ if (!t.onAvailability || !early)
1694
+ return;
1695
+ const left = [...checking];
1696
+ for (const conversationId of new Set(left.map(([, c]) => c))) {
1697
+ await tellAvailability(conversationId, left.filter(([, c]) => c === conversationId).map(([productId]) => ({ productId, availability: { status: 'unknown' } })));
1698
+ }
1699
+ };
1700
+ const tellAvailability = async (conversationId, items) => {
1701
+ for (const item of items) {
1702
+ checking.delete(item.productId);
1703
+ answered.set(item.productId, item.availability);
1704
+ }
1705
+ if (!t.onAvailability || items.length === 0)
1706
+ return;
1707
+ try {
1708
+ await t.onAvailability({ conversationId, items });
1709
+ }
1710
+ catch {
1711
+ // The app's display failing never fails the call.
1712
+ }
1713
+ };
1494
1714
  /** Hands the app one count with the products past the cards of the catalog it counts. */
1495
1715
  const tellCount = async ({ conversationId, shown, found }) => {
1496
1716
  const more = moreFound?.conversationId === conversationId ? moreFound.links : [];
@@ -1546,7 +1766,9 @@ export function buyTool(options) {
1546
1766
  ack?.cancel();
1547
1767
  await t.ackOut;
1548
1768
  // The turn's own search presents its products, as a reply that lists them does.
1549
- await deliverPagesInTurn(earlyPages(update.conversationId, update.asOf, productsOfCatalog(update.catalog)), { turn: callTurn, presented: true, owner: t }, send);
1769
+ await deliverPagesInTurn(earlyPages(update.conversationId, update.asOf, withAnswers(productsOfCatalog(update.catalog))), { turn: callTurn, presented: true, owner: t }, send);
1770
+ // A place shown `checking`, on a card or behind "show more", waits for the live search's word on it.
1771
+ noteCards([...productsOfCatalog(update.catalog), ...productsOfCatalog({ ...update.catalog, items: update.more })], update.conversationId);
1550
1772
  keepMore();
1551
1773
  cardsOut = true;
1552
1774
  // A count held for these cards goes out now, unless the catalog carries its own, which follows it.
@@ -1557,6 +1779,13 @@ export function buyTool(options) {
1557
1779
  },
1558
1780
  }
1559
1781
  : {}),
1782
+ ...(t.onAvailability && early
1783
+ ? {
1784
+ onAvailability: async ({ conversationId, items }) => {
1785
+ await tellAvailability(conversationId, items.map(({ id, availability }) => ({ productId: cardOfId.get(id) ?? id, availability })));
1786
+ },
1787
+ }
1788
+ : {}),
1560
1789
  ...(t.onText
1561
1790
  ? {
1562
1791
  onSegment: async ({ text, conversationId }) => {
@@ -1569,8 +1798,7 @@ export function buyTool(options) {
1569
1798
  return;
1570
1799
  }
1571
1800
  try {
1572
- await t.onText(text, { conversationId });
1573
- told.push(text);
1801
+ await showText(text, conversationId);
1574
1802
  }
1575
1803
  catch {
1576
1804
  // A message the app failed to show was never read, so the model words the answer.
@@ -1633,8 +1861,7 @@ export function buyTool(options) {
1633
1861
  continue;
1634
1862
  }
1635
1863
  try {
1636
- await t.onText(message.text, { conversationId: message.conversationId });
1637
- told.push(message.text);
1864
+ await showText(message.text, message.conversationId);
1638
1865
  }
1639
1866
  catch {
1640
1867
  // A message the app failed to show was never read, so the model words the answer.
@@ -1643,8 +1870,7 @@ export function buyTool(options) {
1643
1870
  const stepAsk = turn?.next_action ? CHAT_STEP_ASKS[turn.next_action] : undefined;
1644
1871
  if (replaced && stepAsk && settled.reply.endsWith(stepAsk)) {
1645
1872
  try {
1646
- await t.onText(stepAsk, { conversationId: settled.conversation_id });
1647
- told.push(stepAsk);
1873
+ await showText(stepAsk, settled.conversation_id);
1648
1874
  }
1649
1875
  catch {
1650
1876
  // Not shown, so the model asks for the step.
@@ -1653,22 +1879,50 @@ export function buyTool(options) {
1653
1879
  }
1654
1880
  // Every message of Agentcard's own answer reached the shopper through onText, so the model adds nothing to it. An answer
1655
1881
  // whose card ask gave way to the named step was read the same way once every message of it, that ask last, went out.
1656
- const read = !!turn && (settled.reply === turn.reply ? readEvery(told, turn.messages) : replaced && readEvery(told, toMessages(settled.reply)));
1657
- const delivered = await deliver(withMessages(settled, turn), { turn: callTurn, presented: turn?.catalog?.presented === true, owner: t }, send);
1882
+ // A reply the shopper read that asks them to choose, with the values named inside a sentence or not at all, is followed
1883
+ // by one more message that lists every value on its own line (choiceLines), so no choice is read buried in prose.
1884
+ let choiceShown = true;
1885
+ if (t.onText && turn?.choice && told.length > 0 && !messagesListChoice(told, turn.choice)) {
1886
+ const lines = choiceLines(turn.choice);
1887
+ try {
1888
+ await t.onText(t.texting ? textingStyle(lines) : lines, { conversationId: turn.conversation_id || null });
1889
+ }
1890
+ catch {
1891
+ // Not shown, so the shopper has not read the whole answer: the model words it, with the choice it reads.
1892
+ choiceShown = false;
1893
+ }
1894
+ }
1895
+ const read = choiceShown && !!turn && (settled.reply === turn.reply ? readEvery(told, turn.messages) : replaced && readEvery(told, toMessages(settled.reply)));
1896
+ const answer = withMessages(settled, turn);
1897
+ const delivered = await deliver(answered.size > 0 ? { ...answer, products: withAnswers(answer.products) } : answer, { turn: callTurn, presented: turn?.catalog?.presented === true, owner: t }, send);
1658
1898
  // A count that came before any card (find mode puts its cards in the answer) goes out once the answer's cards did.
1659
1899
  if (heldCount && (delivered.products?.length ?? 0) > 0) {
1660
1900
  const held = heldCount;
1661
1901
  heldCount = null;
1662
1902
  await tellCount(held);
1663
1903
  }
1904
+ // A call that ends with cards still `checking`, the answer's own cards included, says `unknown` of each, so none waits
1905
+ // for an answer that is not coming.
1906
+ if (delivered.conversation_id)
1907
+ noteCards(delivered.products ?? [], delivered.conversation_id);
1908
+ await settleLeft();
1664
1909
  // The message that asks for a step link already went out, so the link follows it now instead of waiting for the model's.
1665
1910
  // The call waits for it, and one that fails to send fails the call, as a step link the turn does not hold does.
1666
1911
  if (read)
1667
1912
  await t.releaseNow?.();
1913
+ // A confirm the turn sent on the shopper's earlier yes names the total they said yes to beside the cart's total now. A
1914
+ // confirm Agentcard refused, or one whose answer holds another order or asks for a yes, carried nothing: the model asks.
1915
+ const carried = plan && 'body' in plan && plan.yes && turn && delivered.next_action !== 'confirm' ? plan.yes : null;
1916
+ const nowCart = !carried ? null : !delivered.cart ? carried.cart : sameOrder(delivered.cart, carried.cart) ? delivered.cart : null;
1917
+ const yesCarried = carried && nowCart
1918
+ ? { said_yes_to_cents: carried.totalCents, total_cents: nowCart.totalCents, currency: nowCart.currency ?? null, ...(carried.picked ? { picked: true } : {}) }
1919
+ : null;
1668
1920
  const result = {
1669
1921
  ...delivered,
1670
1922
  ...(t.ackDelivered ? { shopper_already_told_looking: true } : {}),
1671
1923
  ...(read ? { shopper_already_read_reply: true } : {}),
1924
+ ...(confirmSent ? { confirm_sent: true } : {}),
1925
+ ...(yesCarried ? { yes_carried: yesCarried } : {}),
1672
1926
  };
1673
1927
  lastNextAction = result.next_action;
1674
1928
  lastReply = result.reply;
@@ -1681,12 +1935,19 @@ export function buyTool(options) {
1681
1935
  if (result.conversation_id)
1682
1936
  lastConversationId = result.conversation_id;
1683
1937
  if (result.cart?.hash)
1684
- lastShownHash = result.cart.hash;
1938
+ lastShownCart = result.cart;
1685
1939
  // 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.
1686
1940
  if (!t.event)
1687
- noteShopperStep(result, turn, { confirm: !!confirm, yesHash, ask, withdrawalsAtStart });
1941
+ noteShopperStep(result, turn, { confirm: !!confirm, yes: { shown: yesShown }, ask, withdrawalsAtStart });
1688
1942
  else if (plan && 'body' in plan)
1689
- noteShopperStep(result, turn, { confirm: confirmSent, yesHash: typeof sent.confirm === 'string' ? sent.confirm : null, ask: sent.ask ?? '', withdrawalsAtStart });
1943
+ noteShopperStep(result, turn, { confirm: confirmSent && !!plan.yes, yes: plan.yes ? { carried: plan.yes } : { shown: null }, ask: sent.ask ?? '', withdrawalsAtStart });
1944
+ if (!t.event && turn) {
1945
+ // Words that name something to buy and put a new item in the cart picked it too, as `the chicken pesto caprese from
1946
+ // mendocino farms` carts the dish it names.
1947
+ const added = result.pick?.in_cart === true || addsItem(result.cart, cartBefore);
1948
+ const picking = !confirm && (picksShown || result.pick?.in_cart === true || (isNewRequest(ask) && added));
1949
+ notePick(result, { picking, added, confirm: !!confirm, ask, heard, asked: askedBefore, pickWithdrawalsAtStart });
1950
+ }
1690
1951
  // An answer that lists products replaces the names, even when every one is too short to keep.
1691
1952
  const shown = shownProducts(result.products);
1692
1953
  if (shown.length > 0)
@@ -1709,6 +1970,8 @@ export function buyTool(options) {
1709
1970
  return result;
1710
1971
  }
1711
1972
  finally {
1973
+ // A call that fails, Agentcard's error or a thrown handler, still settles the cards it left `checking`.
1974
+ await settleLeft?.().catch(() => { });
1712
1975
  ack?.cancel();
1713
1976
  running -= 1;
1714
1977
  lastEndedAt = Date.now();
@@ -1782,7 +2045,7 @@ export function buyTool(options) {
1782
2045
  const own = options ?? {};
1783
2046
  if (typeof own !== 'object')
1784
2047
  throw new TypeError('Pass the turn options as an object, or leave them out.');
1785
- for (const key of ['sendLink', 'onWorking', 'onReaction', 'onResult', 'onProgress', 'onStatus', 'onFound', 'onItem', 'onMessage', 'onProducts', 'onText', 'onCart']) {
2048
+ for (const key of ['sendLink', 'onWorking', 'onReaction', 'onResult', 'onProgress', 'onStatus', 'onFound', 'onAvailability', 'onItem', 'onMessage', 'onProducts', 'onText', 'onCart']) {
1786
2049
  if (own[key] !== undefined && typeof own[key] !== 'function')
1787
2050
  throw new TypeError(`Pass ${key} to the turn as a function, or leave it out.`);
1788
2051
  }
@@ -1797,6 +2060,9 @@ export function buyTool(options) {
1797
2060
  if (own.replyAsWritten !== undefined && typeof own.replyAsWritten !== 'boolean')
1798
2061
  throw new TypeError('Pass replyAsWritten to the turn as true or false, or leave it out.');
1799
2062
  const written = own.replyAsWritten ?? writtenByDefault;
2063
+ if (own.textingStyle !== undefined && typeof own.textingStyle !== 'boolean')
2064
+ throw new TypeError('Pass textingStyle to the turn as true or false, or leave it out.');
2065
+ const texting = own.textingStyle ?? textingByDefault;
1800
2066
  const hearText = own.onText ?? o.onText;
1801
2067
  // Messages are spaced only for an app that hears each one as it goes out.
1802
2068
  const pauseOf = paceOf(own.pace);
@@ -1861,10 +2127,12 @@ export function buyTool(options) {
1861
2127
  onProgress: own.onProgress ?? onProgress,
1862
2128
  onStatus: own.onStatus ?? o.onStatus,
1863
2129
  onFound: own.onFound ?? o.onFound,
2130
+ onAvailability: own.onAvailability ?? o.onAvailability,
1864
2131
  onProducts: own.onProducts ?? o.onProducts,
1865
2132
  // Agentcard's message takes its place in the turn's sequence, after the items before it, and goes to onText alone, so
1866
2133
  // an app that passes onItem too hears it once.
1867
2134
  onText: hearText ? (text, info) => feed.push({ kind: 'text', text }, () => hearText(text, info)) : undefined,
2135
+ texting,
1868
2136
  onCart: own.onCart ?? o.onCart,
1869
2137
  tell: onItem || baseOnWorking ? (text) => feed.push({ kind: 'text', text }, () => (onItem ? onItem({ kind: 'text', text }) : baseOnWorking?.(text))) : undefined,
1870
2138
  links: [],
@@ -1933,9 +2201,13 @@ export function buyTool(options) {
1933
2201
  const results = state.results;
1934
2202
  // A pick or an answer goes straight only after a turn that ended on Agentcard's answer, and never while a call still runs.
1935
2203
  const answered = previousTurn !== null && (previousTurn.results?.length ?? 0) > 0;
1936
- const direct = directOn && !event && running === 0
1937
- ? directCall(String(shopperText ?? ''), { answered, nextAction: lastNextAction, asked: lastReply, products: lastAnswerProducts, stores: lastAnswerStores })
1938
- : null;
2204
+ // A `nextStep` turn's call is the same whatever the model writes (`stepPlan` reads where the purchase stands), so it goes
2205
+ // straight too: with `replyAsWritten`, a step whose answer is a link, such as the approval after a saved card, needs no model.
2206
+ const direct = !directOn || running > 0
2207
+ ? null
2208
+ : event
2209
+ ? { input: { ask: NEXT_STEP_ASK, confirm: false, new_order: false, product_id: null, store_id: null }, reason: 'next_step' }
2210
+ : directCall(String(shopperText ?? ''), { answered, nextAction: lastNextAction, asked: lastReply, products: lastAnswerProducts, stores: lastAnswerStores });
1939
2211
  if (!event)
1940
2212
  previousTurn = state;
1941
2213
  // What Agentcard told the shopper that no history the model ran with holds yet.
@@ -1996,7 +2268,7 @@ export function buyTool(options) {
1996
2268
  links: { get: () => [...links], enumerable: false },
1997
2269
  results: { get: () => [...results], enumerable: false },
1998
2270
  direct: { value: direct, enumerable: false },
1999
- replyAsWritten: { get: () => (written && results.length > 0 ? replyAsWritten(results[results.length - 1]) : null), enumerable: false },
2271
+ replyAsWritten: { get: () => (written && results.length > 0 ? replyAsWritten(results[results.length - 1], { textingStyle: texting }) : null), enumerable: false },
2000
2272
  items: { get: () => feed.items.map((item) => (item.kind === 'link' ? { kind: 'link', link: { ...item.link } } : { ...item })), enumerable: false },
2001
2273
  // 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.
2002
2274
  // A step link held for this message goes out right after its last paragraph, once the stream ends.