@agent-cards/commerce-sdk 0.13.0 → 0.15.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,6 +33,7 @@
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';
36
37
  import { forModel, forModelError, pickId, productPrice, productCardKey, replyAsWritten, shownProducts, shownStores, stripLinks } from './model-view.js';
37
38
  import { messageStream, paceOf, sleep, toMessages, typingHook } from './messages.js';
38
39
  import { isStale } from './shopper-message.js';
@@ -197,6 +198,11 @@ export function productsOf(turn) {
197
198
  return turn.catalog ? productsOfCatalog(turn.catalog) : [];
198
199
  }
199
200
  /** The products of a search, as the tool reads them. */
201
+ /** 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
+ function productLink(product) {
203
+ 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, ...(product.availability ? { availability: product.availability } : {}) };
205
+ }
200
206
  function productsOfCatalog(catalog) {
201
207
  return (catalog.items ?? [])
202
208
  .filter((item) => item.name)
@@ -214,6 +220,7 @@ function productsOfCatalog(catalog) {
214
220
  url: item.url ?? null,
215
221
  action: productAction(catalog.merchant),
216
222
  linkSent: false,
223
+ ...(item.availability ? { availability: item.availability } : {}),
217
224
  }));
218
225
  }
219
226
  /** The stores an answer presents, as the tool reads them. */
@@ -299,9 +306,67 @@ function withMessages(result, turn) {
299
306
  const sent = turn && result.reply === turn.reply && turn.messages.length > 0 ? turn.messages : [result.reply];
300
307
  return { ...result, messages: sent.flatMap(toMessages) };
301
308
  }
309
+ /** True when the app showed every message of an answer that has one, each as written and in order. */
310
+ function readEvery(told, messages) {
311
+ const written = messages.map((message) => message.trim()).filter((message) => message !== '');
312
+ return written.length > 0 && written.length === told.length && written.every((message, i) => message === told[i].trim());
313
+ }
314
+ /**
315
+ * The steps the shopper answers in the chat, none of which has a link, and the line that asks the shopper for one when
316
+ * a reply asks for something else instead. Each is written for the shopper, as Agentcard's messages are: an app that
317
+ * shows Agentcard's messages as they stream shows it in place of the ask it replaces.
318
+ */
319
+ const CHAT_STEP_ASKS = {
320
+ shipping_details: "What's the shipping address for this order?",
321
+ guest_details: 'What name, email and phone should it go under?',
322
+ verification_code: "What's the verification code the store just sent you?",
323
+ };
324
+ /** Every web address in a text, with or without its scheme. */
325
+ const URL_IN_TEXT = /\b(?:https?:\/\/|www\.)[^\s<>"'`)\]]+/gi;
326
+ /** An ask for a payment card: "add your card", "save a card", "add a payment card". "Added the gift card" is a cart line. */
327
+ const CARD_ASK = /\b(?:add|save|link|enter|attach)\s+(?:a|your|the)\s+(?:(?:payment|credit|debit)\s+)?card\b/i;
328
+ /**
329
+ * Whether a streamed message waits for the answer before the shopper reads it: it asks for a card or points at a link,
330
+ * which `askingForNamedStep` replaces when Agentcard names a step that comes first. Every message after it waits behind
331
+ * it, so the shopper reads them in order; a message before it goes out at once.
332
+ */
333
+ function waitsForAnswer(message) {
334
+ return CARD_ASK.test(message) || new RegExp(URL_IN_TEXT.source, 'i').test(message);
335
+ }
336
+ /**
337
+ * The answer with its reply asking for the step Agentcard named, when that step is answered in the chat (an address, a
338
+ * guest's details, a code) and a paragraph of the reply asks for a card, or sends the shopper to a link the answer does
339
+ * not hand over. Agentcard holds the add-card link back while the address comes first, yet its reply can still ask for
340
+ * the card, with the link pasted in or with none: every link is taken out of a reply, so the shopper read "add your card
341
+ * to place the order:" and no link came (2026-10-26, Commerce SDK playground, a Walmart cart with no shipping address).
342
+ * The paragraph gives way to a line that asks the shopper for the named step, so the reply is no longer Agentcard's as
343
+ * written and the model words it. A streamed message that asks for a card or points at a link waits for the answer
344
+ * (`waitsForAnswer`), so an app showing Agentcard's messages as they stream shows that line in its place. A link to a product, store or cart line the answer lists is not held back, and leaves the reply
345
+ * as it is, as does a cart line about a card that is a product ("added the gift card").
346
+ */
347
+ function askingForNamedStep(result, next) {
348
+ const ask = next ? CHAT_STEP_ASKS[next] : undefined;
349
+ if (!ask || typeof result.reply !== 'string')
350
+ return result;
351
+ const known = [
352
+ ...result.links.map((link) => link.url),
353
+ ...result.products.map((product) => product.url),
354
+ ...result.stores.map((store) => store.url),
355
+ ...result.carts.flatMap((c) => c.items.map((line) => line.url)),
356
+ ...(result.cart?.items.map((line) => line.url) ?? []),
357
+ ].filter((url) => typeof url === 'string' && url !== '');
358
+ // The answer hands over no add-card link beside a step that comes first (onlyNextStepLinks), so a card ask is a dead end.
359
+ const held = (paragraph) => CARD_ASK.test(paragraph) ||
360
+ (paragraph.match(URL_IN_TEXT) ?? []).some((url) => !known.some((page) => page === url || page.startsWith(url) || url.startsWith(page)));
361
+ const paragraphs = result.reply.split(/\n\s*\n/);
362
+ if (!paragraphs.some(held))
363
+ return result;
364
+ const kept = paragraphs.filter((paragraph) => !held(paragraph) && paragraph.trim() !== '');
365
+ return { ...result, reply: [...kept, ask].join('\n\n') };
366
+ }
302
367
  /** A purchase answer, read for its status. A status Agentcard adds later reads as `needs_input`. */
303
368
  function resultOfTurn(turn) {
304
- return onlyNextStepLinks(resultOfStatus(turn), turn.next_action);
369
+ return askingForNamedStep(onlyNextStepLinks(resultOfStatus(turn), turn.next_action), turn.next_action);
305
370
  }
306
371
  /** A purchase answer, read for its status, with every link the answer carries. */
307
372
  function resultOfStatus(turn) {
@@ -600,13 +665,23 @@ export function buyTool(options) {
600
665
  const sendLink = o.sendLink;
601
666
  if (o.onProgress !== undefined && typeof o.onProgress !== 'function')
602
667
  throw new TypeError('Pass onProgress as a function that takes each stage of a call.');
668
+ if (o.onProducts !== undefined && typeof o.onProducts !== 'function')
669
+ throw new TypeError('Pass onProducts as a function that takes the products a search found.');
670
+ if (o.onText !== undefined && typeof o.onText !== 'function')
671
+ throw new TypeError('Pass onText as a function that takes each message Agentcard writes.');
672
+ if (o.onCart !== undefined && typeof o.onCart !== 'function')
673
+ throw new TypeError('Pass onCart as a function that takes the carts each time a call builds or changes one.');
603
674
  if (o.stream !== undefined && o.stream !== false)
604
675
  throw new TypeError('Pass stream as false to wait for each whole answer, or leave it out.');
605
676
  const onProgress = o.onProgress;
677
+ for (const key of ['onStatus', 'onFound', 'onAvailability']) {
678
+ if (o[key] !== undefined && typeof o[key] !== 'function')
679
+ throw new TypeError(`Pass ${key} as a function, or leave it out.`);
680
+ }
606
681
  if (o.earlyCards !== undefined && typeof o.earlyCards !== 'boolean')
607
682
  throw new TypeError('Pass earlyCards as true or false.');
608
- // A call streams when it has someone to tell: a stage for onProgress, or early cards for the links of its turn.
609
- const earlyCards = o.earlyCards === true;
683
+ // A search's cards go out the moment it answers, unless the app waits for the answer that names them.
684
+ const earlyCards = o.earlyCards !== false;
610
685
  if (o.onResult !== undefined && typeof o.onResult !== 'function') {
611
686
  throw new TypeError('Pass onResult as a function that takes each answer.');
612
687
  }
@@ -614,6 +689,9 @@ export function buyTool(options) {
614
689
  if (o.replyAsWritten !== undefined && typeof o.replyAsWritten !== 'boolean')
615
690
  throw new TypeError('Pass replyAsWritten as true or false.');
616
691
  const writtenByDefault = o.replyAsWritten === true;
692
+ if (o.direct !== undefined && typeof o.direct !== 'boolean')
693
+ throw new TypeError('Pass direct as true or false.');
694
+ const directOn = o.direct === true;
617
695
  const wait = o.wait ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
618
696
  if (typeof wait !== 'function')
619
697
  throw new TypeError('Pass wait as a function of milliseconds that resolves after the pause.');
@@ -666,6 +744,12 @@ export function buyTool(options) {
666
744
  tell: o.onWorking,
667
745
  onResult,
668
746
  onProgress,
747
+ onStatus: o.onStatus,
748
+ onFound: o.onFound,
749
+ onAvailability: o.onAvailability,
750
+ onProducts: o.onProducts,
751
+ onText: o.onText,
752
+ onCart: o.onCart,
669
753
  links: null,
670
754
  results: null,
671
755
  pages: null,
@@ -689,6 +773,11 @@ export function buyTool(options) {
689
773
  let lastReply = null;
690
774
  /** How many carts the last answer held: with two or more, a new order goes to the same purchase (answerIn). */
691
775
  let lastCartCount = 0;
776
+ /**
777
+ * True when the last answer was a checkout that ended with no order and no cart waiting (`declined`): a new order then
778
+ * has no cart to drop, so it goes to the same purchase too (answerIn).
779
+ */
780
+ let declinedNoCart = false;
692
781
  /** The conversation and the carts of the last answer, for a `nextStep` call that cannot read the conversation. */
693
782
  let lastConversationId = null;
694
783
  let lastCarts = [];
@@ -718,6 +807,10 @@ export function buyTool(options) {
718
807
  const said = productWords(text);
719
808
  return said !== '' && shownNames.some((name) => said.includes(name));
720
809
  };
810
+ /** What the last answer showed, products and stores with the ids a pick sends, and the turn the shopper opened last. */
811
+ let lastAnswerProducts = [];
812
+ let lastAnswerStores = [];
813
+ let previousTurn = null;
721
814
  /** The names of the stores the last answer that listed stores showed: a message that names one, as a tap on its card does, picks it. */
722
815
  let shownStoreNames = [];
723
816
  /** True for a pick of a product or a store the shopper saw, by its name. */
@@ -1143,9 +1236,7 @@ export function buyTool(options) {
1143
1236
  if (!before.has(key)) {
1144
1237
  try {
1145
1238
  // The id the model reads and a pick takes, never the store page: the page is `url`.
1146
- const productId = pickId(product);
1147
- // A flight option has no page at a store: its card carries no url, and its productId picks it.
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 });
1239
+ await send(productLink(product));
1149
1240
  }
1150
1241
  catch {
1151
1242
  continue;
@@ -1325,7 +1416,11 @@ export function buyTool(options) {
1325
1416
  // With carts at more than one store waiting, a new order would drop every one of them: "cancel the
1326
1417
  // reservation, keep the flowers" started a new conversation and lost the flowers cart (2026-10-20).
1327
1418
  // The ask goes to the same purchase instead, and Agentcard drops only the cart it names.
1328
- const newOrder = input.new_order && lastCartCount < 2;
1419
+ // After a checkout that ended with no order and no cart, a new order has no cart to drop: the shopper's yes to
1420
+ // trying again went out as one, and the new conversation searched again and asked for the delivery address a
1421
+ // second time (Commerce SDK playground, duralexusa.com, 2026-10-27). The ask stays in the purchase that holds
1422
+ // the product, the size and the address, until the next answer.
1423
+ const newOrder = input.new_order && lastCartCount < 2 && !declinedNoCart;
1329
1424
  // A new order is never a confirm: a model that sets both drops the cart and places nothing, even
1330
1425
  // when the ask stays in the same purchase ("cancel the reservation" never checks out a cart).
1331
1426
  const confirm = input.confirm && !input.new_order;
@@ -1374,31 +1469,186 @@ export function buyTool(options) {
1374
1469
  const callTurn = t.number;
1375
1470
  const send = senderOf(t);
1376
1471
  const progressOf = t.onProgress;
1377
- // A confirm streams as a search does, and only a search's products go out early.
1378
- const streams = o.stream !== false && ((earlyCards && !!send) || !!progressOf);
1472
+ // Every call streams unless the app waits for whole answers, so each part reaches the app as Agentcard produces it. A
1473
+ // confirm streams as a search does, and only a search's products go out early. A call with no callback to hear any part
1474
+ // has no handler, and the client asks for the whole answer.
1475
+ const streams = o.stream !== false;
1379
1476
  const early = earlyCards && !!send && !confirmSent;
1380
1477
  running += 1;
1381
1478
  // A confirm or a pick of what the shopper saw never hears "looking for": once the call is slow, it hears a
1382
1479
  // fixed line that names nothing they said (slowStepLine), so a 20-second checkout or menu read is never silent.
1383
1480
  const ack = t.event ? null : holdAck(ask, t, confirm ? 'confirm' : pick?.product_id ? 'product' : pick ? 'pick' : null);
1481
+ /** Says `unknown` of every card the call left `checking`; it runs once the answer's cards went out, or when the call fails. */
1482
+ let settleLeft = null;
1384
1483
  try {
1385
1484
  let turn = null;
1386
1485
  let refusal;
1387
- // Streamed: the stages go to onProgress, and a search's store pages go out the moment it answers.
1486
+ /** Each message of Agentcard's answer the app showed through onText, in the order the shopper read them: every one means the shopper read the reply. */
1487
+ const told = [];
1488
+ /**
1489
+ * The streamed messages that wait for the answer: the first that asks for a card or points at a link (waitsForAnswer),
1490
+ * and every message after it, so the shopper reads them in the order Agentcard wrote them.
1491
+ */
1492
+ const waiting = [];
1493
+ /** The products past the cards of the catalog this call heard last, for the count that follows it. */
1494
+ let moreFound = null;
1495
+ /** Whether this call's cards went out, and a count that came before them. */
1496
+ let cardsOut = false;
1497
+ let heldCount = null;
1498
+ /** The card each catalog id of this call went out as (its `productId`), and the cards still `checking`, by conversation. */
1499
+ const cardOfId = new Map();
1500
+ const checking = new Map();
1501
+ /**
1502
+ * What the live search answered for each card, so a later copy of the same place, such as a card whose first send
1503
+ * failed and goes out again with the answer, carries that answer and never goes back to `checking`.
1504
+ */
1505
+ const answered = new Map();
1506
+ const withAnswers = (products) => answered.size === 0 ? products : products.map((product) => {
1507
+ const known = product.availability ? answered.get(pickId(product) ?? '') : undefined;
1508
+ return known ? { ...product, availability: known } : product;
1509
+ });
1510
+ /**
1511
+ * Notes the card each product goes out as, and each place still `checking`, whichever way the app got it: a card
1512
+ * through `sendLink` now or on a later try, or a product past the cards that `onFound` hands over for "show more".
1513
+ */
1514
+ const noteCards = (products, conversationId) => {
1515
+ for (const product of products) {
1516
+ const productId = pickId(product);
1517
+ if (!productId)
1518
+ continue;
1519
+ cardOfId.set(product.id, productId);
1520
+ if (product.availability?.status === 'checking' && !answered.has(productId))
1521
+ checking.set(productId, conversationId);
1522
+ }
1523
+ };
1524
+ /** Hands the app what the live search said of the places, each by its card, and settles those cards. */
1525
+ settleLeft = async () => {
1526
+ if (!t.onAvailability || !early)
1527
+ return;
1528
+ const left = [...checking];
1529
+ for (const conversationId of new Set(left.map(([, c]) => c))) {
1530
+ await tellAvailability(conversationId, left.filter(([, c]) => c === conversationId).map(([productId]) => ({ productId, availability: { status: 'unknown' } })));
1531
+ }
1532
+ };
1533
+ const tellAvailability = async (conversationId, items) => {
1534
+ for (const item of items) {
1535
+ checking.delete(item.productId);
1536
+ answered.set(item.productId, item.availability);
1537
+ }
1538
+ if (!t.onAvailability || items.length === 0)
1539
+ return;
1540
+ try {
1541
+ await t.onAvailability({ conversationId, items });
1542
+ }
1543
+ catch {
1544
+ // The app's display failing never fails the call.
1545
+ }
1546
+ };
1547
+ /** Hands the app one count with the products past the cards of the catalog it counts. */
1548
+ const tellCount = async ({ conversationId, shown, found }) => {
1549
+ const more = moreFound?.conversationId === conversationId ? moreFound.links : [];
1550
+ moreFound = null;
1551
+ try {
1552
+ await t.onFound({ conversationId, shown, found, more });
1553
+ }
1554
+ catch {
1555
+ // The app's display failing never fails the call.
1556
+ }
1557
+ };
1558
+ // Streamed: the stages go to onProgress, a search's products to onProducts and its store pages out the moment it
1559
+ // answers, and each message and cart to the app as Agentcard writes it.
1388
1560
  const handlers = streams
1389
1561
  ? {
1390
1562
  ...(progressOf ? { onProgress: (progress) => { try {
1391
1563
  progressOf(progress);
1392
1564
  }
1393
1565
  catch { /* the app's display failing never fails the call */ } } } : {}),
1394
- ...(early
1566
+ ...(t.onStatus ? { onStatus: (status) => { try {
1567
+ t.onStatus(status);
1568
+ }
1569
+ catch { /* the app's display failing never fails the call */ } } } : {}),
1570
+ // A count counts the cards the shopper already holds, so it comes only where the cards go out early (earlyCards):
1571
+ // with the cards held for the answer, the answer can list fewer than the count would say.
1572
+ ...(t.onFound && early
1573
+ ? {
1574
+ onFound: async ({ conversationId, shown, found }) => {
1575
+ // A count that comes before any card of this call (find mode's) waits for the cards it counts.
1576
+ if (!cardsOut) {
1577
+ heldCount = { conversationId, shown, found };
1578
+ return;
1579
+ }
1580
+ await tellCount({ conversationId, shown, found });
1581
+ },
1582
+ }
1583
+ : {}),
1584
+ ...(early || t.onProducts
1395
1585
  ? {
1396
1586
  onCatalog: async (update) => {
1587
+ try {
1588
+ await t.onProducts?.(productsOfCatalog(update.catalog), { conversationId: update.conversationId, asOf: update.asOf, merchantName: update.catalog.merchant_name });
1589
+ }
1590
+ catch {
1591
+ // The app's display failing never fails the call.
1592
+ }
1593
+ // The products past the cards are read after the cards went out, so the first card never waits for them.
1594
+ const keepMore = () => { if (t.onFound && early)
1595
+ moreFound = { conversationId: update.conversationId, links: productsOfCatalog({ ...update.catalog, items: update.more }).map(productLink) }; };
1596
+ if (!early)
1597
+ return;
1397
1598
  // Products arrived before the instant reply was due: they are the reply.
1398
1599
  ack?.cancel();
1399
1600
  await t.ackOut;
1400
1601
  // The turn's own search presents its products, as a reply that lists them does.
1401
- await deliverPagesInTurn(earlyPages(update.conversationId, update.asOf, productsOfCatalog(update.catalog)), { turn: callTurn, presented: true, owner: t }, send);
1602
+ await deliverPagesInTurn(earlyPages(update.conversationId, update.asOf, withAnswers(productsOfCatalog(update.catalog))), { turn: callTurn, presented: true, owner: t }, send);
1603
+ // A place shown `checking`, on a card or behind "show more", waits for the live search's word on it.
1604
+ noteCards([...productsOfCatalog(update.catalog), ...productsOfCatalog({ ...update.catalog, items: update.more })], update.conversationId);
1605
+ keepMore();
1606
+ cardsOut = true;
1607
+ // A count held for these cards goes out now, unless the catalog carries its own, which follows it.
1608
+ const held = heldCount;
1609
+ heldCount = null;
1610
+ if (held && update.found === null)
1611
+ await tellCount(held);
1612
+ },
1613
+ }
1614
+ : {}),
1615
+ ...(t.onAvailability && early
1616
+ ? {
1617
+ onAvailability: async ({ conversationId, items }) => {
1618
+ await tellAvailability(conversationId, items.map(({ id, availability }) => ({ productId: cardOfId.get(id) ?? id, availability })));
1619
+ },
1620
+ }
1621
+ : {}),
1622
+ ...(t.onText
1623
+ ? {
1624
+ onSegment: async ({ text, conversationId }) => {
1625
+ // A message that lands before the instant reply was due is the reply; one after it follows it.
1626
+ ack?.cancel();
1627
+ await t.ackOut;
1628
+ // A card ask or a link waits for the answer, which may name a step that comes first, and every message after it waits behind it.
1629
+ if (waiting.length > 0 || waitsForAnswer(text)) {
1630
+ waiting.push({ text, conversationId });
1631
+ return;
1632
+ }
1633
+ try {
1634
+ await t.onText(text, { conversationId });
1635
+ told.push(text);
1636
+ }
1637
+ catch {
1638
+ // A message the app failed to show was never read, so the model words the answer.
1639
+ }
1640
+ },
1641
+ }
1642
+ : {}),
1643
+ ...(t.onCart
1644
+ ? {
1645
+ onCart: async ({ cart, carts, conversationId }) => {
1646
+ try {
1647
+ await t.onCart(cart, { carts, conversationId });
1648
+ }
1649
+ catch {
1650
+ // The app's display failing never fails the call.
1651
+ }
1402
1652
  },
1403
1653
  }
1404
1654
  : {}),
@@ -1418,6 +1668,9 @@ export function buyTool(options) {
1418
1668
  // The store was still building the last request's cart: once it is in, the request that was refused goes as it was
1419
1669
  // sent, a step's own request too (a saved ask is no longer saved once its step sent it).
1420
1670
  if (!turn && !confirmSent && (await holdForCart(refusal, t, () => ack?.cancel()))) {
1671
+ // Only the answer of the request sent again counts toward what the shopper read.
1672
+ told.length = 0;
1673
+ waiting.length = 0;
1421
1674
  try {
1422
1675
  turn = await client.buy(sent, handlers);
1423
1676
  refusal = undefined;
@@ -1429,14 +1682,69 @@ export function buyTool(options) {
1429
1682
  ack?.cancel();
1430
1683
  const settled = plan && 'local' in plan ? plan.local : turn ? await settleTurn(turn, startedAt) : await settleRefusal(refusal, confirmSent, startedAt);
1431
1684
  await t.ackOut;
1432
- const delivered = await deliver(withMessages(settled, turn), { turn: callTurn, presented: turn?.catalog?.presented === true, owner: t }, send);
1433
- const result = t.ackDelivered ? { ...delivered, shopper_already_told_looking: true } : delivered;
1685
+ // The streamed messages that waited for the answer go out in order: each the answer kept as written, and a card ask or
1686
+ // a link the answer replaced gives way to its ask for the step Agentcard named, once, after them. All of them go out
1687
+ // before any link of the answer.
1688
+ let replaced = false;
1689
+ if (waiting.length > 0 && t.onText) {
1690
+ const rewritten = !!turn && settled.reply !== turn.reply;
1691
+ const kept = new Set((rewritten ? toMessages(settled.reply) : []).map((message) => message.trim()));
1692
+ for (const message of waiting.splice(0)) {
1693
+ if (rewritten && waitsForAnswer(message.text) && !kept.has(message.text.trim())) {
1694
+ replaced = true;
1695
+ continue;
1696
+ }
1697
+ try {
1698
+ await t.onText(message.text, { conversationId: message.conversationId });
1699
+ told.push(message.text);
1700
+ }
1701
+ catch {
1702
+ // A message the app failed to show was never read, so the model words the answer.
1703
+ }
1704
+ }
1705
+ const stepAsk = turn?.next_action ? CHAT_STEP_ASKS[turn.next_action] : undefined;
1706
+ if (replaced && stepAsk && settled.reply.endsWith(stepAsk)) {
1707
+ try {
1708
+ await t.onText(stepAsk, { conversationId: settled.conversation_id });
1709
+ told.push(stepAsk);
1710
+ }
1711
+ catch {
1712
+ // Not shown, so the model asks for the step.
1713
+ }
1714
+ }
1715
+ }
1716
+ // Every message of Agentcard's own answer reached the shopper through onText, so the model adds nothing to it. An answer
1717
+ // whose card ask gave way to the named step was read the same way once every message of it, that ask last, went out.
1718
+ const read = !!turn && (settled.reply === turn.reply ? readEvery(told, turn.messages) : replaced && readEvery(told, toMessages(settled.reply)));
1719
+ const answer = withMessages(settled, turn);
1720
+ const delivered = await deliver(answered.size > 0 ? { ...answer, products: withAnswers(answer.products) } : answer, { turn: callTurn, presented: turn?.catalog?.presented === true, owner: t }, send);
1721
+ // A count that came before any card (find mode puts its cards in the answer) goes out once the answer's cards did.
1722
+ if (heldCount && (delivered.products?.length ?? 0) > 0) {
1723
+ const held = heldCount;
1724
+ heldCount = null;
1725
+ await tellCount(held);
1726
+ }
1727
+ // A call that ends with cards still `checking`, the answer's own cards included, says `unknown` of each, so none waits
1728
+ // for an answer that is not coming.
1729
+ if (delivered.conversation_id)
1730
+ noteCards(delivered.products ?? [], delivered.conversation_id);
1731
+ await settleLeft();
1732
+ // The message that asks for a step link already went out, so the link follows it now instead of waiting for the model's.
1733
+ // The call waits for it, and one that fails to send fails the call, as a step link the turn does not hold does.
1734
+ if (read)
1735
+ await t.releaseNow?.();
1736
+ const result = {
1737
+ ...delivered,
1738
+ ...(t.ackDelivered ? { shopper_already_told_looking: true } : {}),
1739
+ ...(read ? { shopper_already_read_reply: true } : {}),
1740
+ };
1434
1741
  lastNextAction = result.next_action;
1435
1742
  lastReply = result.reply;
1436
- // A refusal reports no carts, so only a turn's answer says how many wait.
1743
+ // A refusal reports no carts, so only a turn's answer says how many wait, and whether a checkout just ended with none.
1437
1744
  if (turn) {
1438
1745
  lastCartCount = result.carts.length;
1439
1746
  lastCarts = result.carts;
1747
+ declinedNoCart = result.status === 'declined' && result.carts.length === 0;
1440
1748
  }
1441
1749
  if (result.conversation_id)
1442
1750
  lastConversationId = result.conversation_id;
@@ -1455,6 +1763,8 @@ export function buyTool(options) {
1455
1763
  const storesShown = shownStores(result.stores);
1456
1764
  if (storesShown.length > 0)
1457
1765
  shownStoreNames = storesShown.map((store) => store.name);
1766
+ lastAnswerProducts = shown.map((product) => ({ productId: pickId(product), name: product.name }));
1767
+ lastAnswerStores = storesShown.map((store) => ({ storeId: store.id, name: store.name }));
1458
1768
  t.results?.push(result);
1459
1769
  if (t.onResult) {
1460
1770
  try {
@@ -1467,6 +1777,8 @@ export function buyTool(options) {
1467
1777
  return result;
1468
1778
  }
1469
1779
  finally {
1780
+ // A call that fails, Agentcard's error or a thrown handler, still settles the cards it left `checking`.
1781
+ await settleLeft?.().catch(() => { });
1470
1782
  ack?.cancel();
1471
1783
  running -= 1;
1472
1784
  lastEndedAt = Date.now();
@@ -1540,7 +1852,7 @@ export function buyTool(options) {
1540
1852
  const own = options ?? {};
1541
1853
  if (typeof own !== 'object')
1542
1854
  throw new TypeError('Pass the turn options as an object, or leave them out.');
1543
- for (const key of ['sendLink', 'onWorking', 'onReaction', 'onResult', 'onProgress', 'onItem', 'onMessage']) {
1855
+ for (const key of ['sendLink', 'onWorking', 'onReaction', 'onResult', 'onProgress', 'onStatus', 'onFound', 'onAvailability', 'onItem', 'onMessage', 'onProducts', 'onText', 'onCart']) {
1544
1856
  if (own[key] !== undefined && typeof own[key] !== 'function')
1545
1857
  throw new TypeError(`Pass ${key} to the turn as a function, or leave it out.`);
1546
1858
  }
@@ -1555,9 +1867,10 @@ export function buyTool(options) {
1555
1867
  if (own.replyAsWritten !== undefined && typeof own.replyAsWritten !== 'boolean')
1556
1868
  throw new TypeError('Pass replyAsWritten to the turn as true or false, or leave it out.');
1557
1869
  const written = own.replyAsWritten ?? writtenByDefault;
1870
+ const hearText = own.onText ?? o.onText;
1558
1871
  // Messages are spaced only for an app that hears each one as it goes out.
1559
1872
  const pauseOf = paceOf(own.pace);
1560
- const feed = itemFeed(onItem || onMessage ? pauseOf : null, typingHook(own.onTyping));
1873
+ const feed = itemFeed(onItem || onMessage || hearText ? pauseOf : null, typingHook(own.onTyping));
1561
1874
  // With onItem, every link and message goes to it alone, in order. Without, each goes where it went before, and the turn lists it.
1562
1875
  const baseSendLink = onItem ? undefined : own.sendLink ?? sendLink;
1563
1876
  const baseOnWorking = onItem ? undefined : own.onWorking ?? onWorking;
@@ -1616,12 +1929,21 @@ export function buyTool(options) {
1616
1929
  ...ackRoutes(acks ? (text) => feed.push({ kind: 'text', text }, () => (onItem ? onItem({ kind: 'text', text }) : baseOnWorking?.(text))) : undefined, own.onReaction ?? o.onReaction),
1617
1930
  onResult: own.onResult ?? onResult,
1618
1931
  onProgress: own.onProgress ?? onProgress,
1932
+ onStatus: own.onStatus ?? o.onStatus,
1933
+ onFound: own.onFound ?? o.onFound,
1934
+ onAvailability: own.onAvailability ?? o.onAvailability,
1935
+ onProducts: own.onProducts ?? o.onProducts,
1936
+ // Agentcard's message takes its place in the turn's sequence, after the items before it, and goes to onText alone, so
1937
+ // an app that passes onItem too hears it once.
1938
+ onText: hearText ? (text, info) => feed.push({ kind: 'text', text }, () => hearText(text, info)) : undefined,
1939
+ onCart: own.onCart ?? o.onCart,
1619
1940
  tell: onItem || baseOnWorking ? (text) => feed.push({ kind: 'text', text }, () => (onItem ? onItem({ kind: 'text', text }) : baseOnWorking?.(text))) : undefined,
1620
1941
  links: [],
1621
1942
  results: [],
1622
1943
  };
1623
1944
  linkNow = state.sendLink;
1624
1945
  state.release = releaseLate;
1946
+ state.releaseNow = releaseForReply;
1625
1947
  state.sendLink = (link) => {
1626
1948
  if (!writesThroughTurn || !STEP_LINK_KINDS.has(link.kind))
1627
1949
  return linkNow(link);
@@ -1680,6 +2002,13 @@ export function buyTool(options) {
1680
2002
  }
1681
2003
  const links = state.links;
1682
2004
  const results = state.results;
2005
+ // A pick or an answer goes straight only after a turn that ended on Agentcard's answer, and never while a call still runs.
2006
+ const answered = previousTurn !== null && (previousTurn.results?.length ?? 0) > 0;
2007
+ const direct = directOn && !event && running === 0
2008
+ ? directCall(String(shopperText ?? ''), { answered, nextAction: lastNextAction, asked: lastReply, products: lastAnswerProducts, stores: lastAnswerStores })
2009
+ : null;
2010
+ if (!event)
2011
+ previousTurn = state;
1683
2012
  // What Agentcard told the shopper that no history the model ran with holds yet.
1684
2013
  const kept = client.company ? client.keptMessages(externalUserId) : [];
1685
2014
  let handed = null;
@@ -1737,6 +2066,7 @@ export function buyTool(options) {
1737
2066
  reaction: { get: () => state.reaction, enumerable: false },
1738
2067
  links: { get: () => [...links], enumerable: false },
1739
2068
  results: { get: () => [...results], enumerable: false },
2069
+ direct: { value: direct, enumerable: false },
1740
2070
  replyAsWritten: { get: () => (written && results.length > 0 ? replyAsWritten(results[results.length - 1]) : null), enumerable: false },
1741
2071
  items: { get: () => feed.items.map((item) => (item.kind === 'link' ? { kind: 'link', link: { ...item.link } } : { ...item })), enumerable: false },
1742
2072
  // 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.