@agent-cards/commerce-sdk 0.16.0 → 0.18.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
@@ -36,10 +36,10 @@ import { buyToolDefinition, buyToolStandardSchema, openAIBuyTool, parseBuyToolIn
36
36
  import { directCall, isProductAsk } from './direct.js';
37
37
  import { forModel, forModelError, pickId, productPrice, productCardKey, shownProducts, shownStores, stripLinks } from './model-view.js';
38
38
  import { choiceLines, leadsIntoList, messageForShopper, messagesListChoice, replyAsWritten, textingStyle } from './texting.js';
39
- import { messageStream, paceOf, sleep, toMessages, typingHook } from './messages.js';
39
+ import { joinLeadIns, leadInJoiner, messageStream, paceOf, paragraphsOf, sleep, toMessages, typingHook } from './messages.js';
40
40
  import { isStale } from './shopper-message.js';
41
41
  import { actionLinkId, isActionLink } from './action-links.js';
42
- import { ACK_REACTION, ackAt, answersCheckout, answersQuestion, holdsBack, 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, readsAsAckLine, slowStepLine } from './ack.js';
43
43
  /** The action a product of `merchant` takes (BuyToolProductAction). */
44
44
  export function productAction(merchant) {
45
45
  if (merchant === 'reservations')
@@ -75,6 +75,10 @@ export function turnMessages(history, told, shopperText) {
75
75
  out.push({ role: 'user', content: shopperText });
76
76
  return out;
77
77
  }
78
+ /** The note a message carries to the model when Agentcard already answered it and left the words to the model (`handled()`). */
79
+ export function handedNote(answer) {
80
+ return `[Agentcard already answered this message with the buy tool. Its answer, for you to tell the shopper in your words, without calling the tool again for it: ${answer}]`;
81
+ }
78
82
  /** What the model reads of a message Agentcard posted into the chat: its text with every link taken out, as `forModel` takes them out. */
79
83
  const modelText = (message) => stripLinks(String(message.text ?? ''));
80
84
  /** How often the history held a message's text when the message first went in to the model. */
@@ -110,8 +114,8 @@ const RESUME_SKEW_MS = 2 * 60_000;
110
114
  const STOPPED_ORDER = new Set(['failed', 'cancelled']);
111
115
  /** The refusals a confirm gets once the order took the cart, or while the placement still runs. */
112
116
  const AFTER_PLACEMENT = new Set(['nothing_to_confirm', 'conversation_closed', 'turn_in_progress']);
113
- /** How long Agentcard has to answer before the instant reply goes out. */
114
- const ACK_DELAY_MS = 1_500;
117
+ /** How long a turn shows the shopper nothing before the line that says it is on it goes out. */
118
+ const ACK_DELAY_MS = 1_000;
115
119
  /** A call that starts this soon after the last one ended belongs to the same model turn, which gets one instant reply. */
116
120
  const SAME_TURN_MS = 15_000;
117
121
  /** A product name shorter than this, such as `AA`, is too short to tell a pick from a search. */
@@ -370,7 +374,8 @@ function onlyNextStepLinks(result, next) {
370
374
  */
371
375
  function withMessages(result, turn) {
372
376
  const sent = turn && result.reply === turn.reply && turn.messages.length > 0 ? turn.messages : [result.reply];
373
- return { ...result, messages: sent.flatMap(toMessages) };
377
+ // One joiner across every message, so a message that is only a lead-in goes with the next one rather than out of the list.
378
+ return { ...result, messages: joinLeadIns(sent.flatMap(paragraphsOf)) };
374
379
  }
375
380
  /** True when the app showed every message of an answer that has one, each as written and in order. */
376
381
  function readEvery(told, messages) {
@@ -532,6 +537,13 @@ function needsCard(link, body) {
532
537
  envelope: body,
533
538
  };
534
539
  }
540
+ /**
541
+ * One shopper message's turn: the calls the model makes for it share its number, its instant reply and
542
+ * the callbacks its links and answers go to. `links` and `results` collect for a turn `buy.turn` opened,
543
+ * and are null for a call made straight on the tool.
544
+ */
545
+ /** What a call throws when Agentcard handed the message back to the app's model (`BuyToolTurn.handled`). */
546
+ const HANDED_BACK = Symbol('handed back');
535
547
  /** The way a turn hands over a link, or undefined when it has nowhere to send one. */
536
548
  function senderOf(t) {
537
549
  if (!t.sendLink && !t.links)
@@ -550,6 +562,8 @@ function senderOf(t) {
550
562
  */
551
563
  function itemFeed(pauseOf, typing) {
552
564
  const items = [];
565
+ // How many items joined the feed, sent or still going out: one means the shopper has something to read.
566
+ let pushed = 0;
553
567
  // The item going out now, or null when none is: an item pushed while none is goes out at once.
554
568
  let tail = null;
555
569
  let lastAt = null;
@@ -584,6 +598,7 @@ function itemFeed(pauseOf, typing) {
584
598
  * unlisted, with no pause, as for the rest of a reply whose earlier message failed.
585
599
  */
586
600
  const push = (item, send, skip = () => false) => {
601
+ pushed += 1;
587
602
  // Each message's pause is chosen once, when it joins the feed, so a pace that varies never makes it wait twice.
588
603
  const pause = item.kind === 'text' && pauseOf ? pauseOf(item.text) : 0;
589
604
  const waitFor = () => (pause > 0 && lastAt !== null ? lastAt + pause - Date.now() : 0);
@@ -611,7 +626,7 @@ function itemFeed(pauseOf, typing) {
611
626
  tail = settled;
612
627
  return run;
613
628
  };
614
- return { items, push };
629
+ return { items, push, pushed: () => pushed };
615
630
  }
616
631
  /**
617
632
  * One reply's messages in a turn's feed: each one takes its place in the feed the moment it is cut,
@@ -748,6 +763,8 @@ export function buyTool(options) {
748
763
  throw new TypeError('Pass earlyCards as true or false.');
749
764
  // A search's cards go out the moment it answers, unless the app waits for the answer that names them.
750
765
  const earlyCards = o.earlyCards !== false;
766
+ // Every call streams unless the app waits for whole answers (`stream: false`).
767
+ const streamCalls = o.stream !== false;
751
768
  if (o.onResult !== undefined && typeof o.onResult !== 'function') {
752
769
  throw new TypeError('Pass onResult as a function that takes each answer.');
753
770
  }
@@ -890,6 +907,8 @@ export function buyTool(options) {
890
907
  };
891
908
  /** What the last answer showed, products and stores with the ids a pick sends, and the turn the shopper opened last. */
892
909
  let lastAnswerProducts = [];
910
+ /** Every product card of the last answer's call the shopper can tap by name, each once (the answer's and the streamed). */
911
+ let lastTappable = [];
893
912
  let lastAnswerStores = [];
894
913
  let previousTurn = null;
895
914
  /** 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. */
@@ -964,8 +983,42 @@ export function buyTool(options) {
964
983
  const holdAck = (ask, t, step = null) => {
965
984
  const onWorking = t.onWorking;
966
985
  const reacting = t.onReaction;
967
- if ((!onWorking && !reacting) || t.acked)
986
+ // A turn the app ended (`turn.end()`) holds no acknowledgement for a call it still runs.
987
+ if ((!onWorking && !reacting) || t.acked || t.ended)
968
988
  return null;
989
+ const model = t.modelTurn;
990
+ if (model && onWorking && model.writesThrough()) {
991
+ // The app's model called the tool. When it wrote its own line first, that line is the turn's acknowledgement; when
992
+ // its first output is this call, the shopper hears the wait line, no sooner than ackDelayMs after the turn opened.
993
+ model.callStarted = true;
994
+ if (model.spoke || model.shown())
995
+ return null;
996
+ t.acked = true;
997
+ const line = slowStepLine(step === 'confirm' ? 'confirm' : step ?? t.picked ?? 'wait');
998
+ let waiting = true;
999
+ const waitTimer = setTimeout(() => {
1000
+ waiting = false;
1001
+ // The model may have written its own line while this call ran: that line is the turn's acknowledgement, so the
1002
+ // wait line is dropped and the turn's reply is free for a later call.
1003
+ if (model.spoke || model.shown() || t.ended) {
1004
+ t.acked = false;
1005
+ return;
1006
+ }
1007
+ t.waitLineOut = true;
1008
+ t.ackOut = Promise.resolve()
1009
+ .then(() => onWorking(line))
1010
+ .then(() => { t.ackDelivered = true; }, () => { });
1011
+ }, Math.max(0, model.openedAt + ackDelayMs - Date.now()));
1012
+ return {
1013
+ cancel: () => {
1014
+ if (!waiting)
1015
+ return;
1016
+ waiting = false;
1017
+ clearTimeout(waitTimer);
1018
+ t.acked = false;
1019
+ },
1020
+ };
1021
+ }
969
1022
  // A confirm, or a pick of what the shopper saw, gets fixed words that never say `looking for`.
970
1023
  const slow = step ?? t.picked ?? (!t.neutral && (namesShown(ask) || isPickText(ask)) ? 'pick' : null);
971
1024
  // A short answer to the question the last reply asked, such as `medium` after `what size?`, is no
@@ -974,10 +1027,19 @@ export function buyTool(options) {
974
1027
  // A slow call of any message may get the 👀, which reads nothing back; only a text reply needs one of these.
975
1028
  if (!reacting && !slow && !neutral && !isNewRequest(ask))
976
1029
  return null;
1030
+ // A streamed search's line is Agentcard's (onAck), written from the conversation while it searches: the tool adds no
1031
+ // template of its own, unless the app writes the words.
1032
+ if (!reacting && !slow && !neutral && streamCalls && !writeAck)
1033
+ return null;
977
1034
  t.acked = true;
978
1035
  let held = true;
979
1036
  const timer = setTimeout(() => {
980
1037
  held = false;
1038
+ // The app ended the turn while this call ran, as after its model failed: no 👀 and no line follow its error.
1039
+ if (t.ended) {
1040
+ t.acked = false;
1041
+ return;
1042
+ }
981
1043
  // With reactions, a slow call puts the message's one 👀 on it, and never adds a line of text.
982
1044
  if (reacting) {
983
1045
  react(t, reacting);
@@ -1002,7 +1064,7 @@ export function buyTool(options) {
1002
1064
  lastTemplate = nextTemplate(lastTemplate);
1003
1065
  // A neutral turn's message, such as `yes` or `ok lets checkout`, is no search: its reply says the
1004
1066
  // tool is working, and never that it is looking for anything.
1005
- text = neutral ? waitAt(lastTemplate) : ackAt(lastTemplate, ask);
1067
+ text = neutral ? slowStepLine('wait') : ackAt(lastTemplate, ask);
1006
1068
  }
1007
1069
  if (typeof text !== 'string' || text.trim() === '') {
1008
1070
  t.acked = false;
@@ -1623,20 +1685,33 @@ export function buyTool(options) {
1623
1685
  // A turn the tool took on its own sends no ask: it reads where the purchase stands, and only the
1624
1686
  // shopper's own yes to this exact cart goes in, for that cart's hash.
1625
1687
  const plan = t.event ? await stepPlan(t.event) : null;
1626
- const sent = plan && 'body' in plan ? plan.body : body;
1627
- const confirmSent = plan ? 'body' in plan && sent.confirm !== undefined : confirm;
1688
+ const handBack = t.handBack ?? null;
1689
+ t.handBack = null;
1690
+ const planned = plan && 'body' in plan ? plan.body : handBack ? { ...body, hand_back: true, last_answer: handBack } : body;
1691
+ const confirmSent = plan ? 'body' in plan && planned.confirm !== undefined : confirm;
1692
+ // Agentcard writes the line that says what it is looking for (onAck) only where the shopper can read it before the
1693
+ // cards and has read nothing yet: a streamed ask, with somewhere to show it, that no reaction, reply or model's words
1694
+ // acknowledged already, in an app that asked for no acknowledgement of its own (`ack: 'none'` or `ack: false` gets
1695
+ // none, there as here) and acknowledges in text: an app that reacts (`ack: 'reaction'`, or an `onReaction` the turn
1696
+ // routes to) reads no line of text, even through `onText`. Every other ask says so, and Agentcard spends no model call on it.
1697
+ const agentcardAcks = streamCalls && ackMode !== 'none' && ackMode !== 'reaction' && !t.onReaction && !t.event && !confirmSent && !pick && !t.acked && (!!t.onWorking || !!t.onText) && (t.agentcardAcks?.(ask) ?? false);
1698
+ /** What goes to Agentcard: the planned request, with `ack: false` when the shopper reads no line of Agentcard's. */
1699
+ let sent = planned;
1628
1700
  const callTurn = t.number;
1629
1701
  const send = senderOf(t);
1630
1702
  const progressOf = t.onProgress;
1631
1703
  // Every call streams unless the app waits for whole answers, so each part reaches the app as Agentcard produces it. A
1632
1704
  // confirm streams as a search does, and only a search's products go out early. A call with no callback to hear any part
1633
1705
  // has no handler, and the client asks for the whole answer.
1634
- const streams = o.stream !== false;
1706
+ const streams = streamCalls;
1635
1707
  const early = earlyCards && !!send && !confirmSent;
1636
1708
  running += 1;
1709
+ t.called = true;
1637
1710
  // A confirm or a pick of what the shopper saw never hears "looking for": once the call is slow, it hears a
1638
1711
  // fixed line that names nothing they said (slowStepLine), so a 20-second checkout or menu read is never silent.
1639
1712
  const ack = t.event ? null : holdAck(ask, t, confirm ? 'confirm' : pick?.product_id ? 'product' : pick ? 'pick' : null);
1713
+ if (ack)
1714
+ (t.holds ??= new Set()).add(ack.cancel);
1640
1715
  /** Says `unknown` of every card the call left `checking`; it runs once the answer's cards went out, or when the call fails. */
1641
1716
  let settleLeft = null;
1642
1717
  try {
@@ -1651,16 +1726,51 @@ export function buyTool(options) {
1651
1726
  const waiting = [];
1652
1727
  /** 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
1728
  let leadIn = false;
1654
- /** Shows one of Agentcard's messages through onText, its lists as bullets (messageForShopper); `told` keeps it as Agentcard wrote it. */
1729
+ /** A message that leads in with a colon waits for the one after it (leadInJoiner), and `heldTexts` keeps it as Agentcard wrote it. */
1730
+ let leads = leadInJoiner();
1731
+ let heldTexts = [];
1732
+ /** Shows a message through onText, its lists as bullets (messageForShopper); `told` keeps each of Agentcard's messages in it as written. */
1733
+ const showMessage = async (message, texts, conversationId) => {
1734
+ await t.onText(messageForShopper(message, leadIn, t.texting), { conversationId });
1735
+ leadIn = leadsIntoList(message);
1736
+ told.push(...texts);
1737
+ };
1738
+ /**
1739
+ * Shows one of Agentcard's messages through onText. One that ends on a colon, such as `two options came in under $20:`,
1740
+ * goes out with the message after it, so the shopper never reads a bubble ending on a colon.
1741
+ */
1655
1742
  const showText = async (text, conversationId) => {
1656
- await t.onText(messageForShopper(text, leadIn, t.texting), { conversationId });
1657
- leadIn = leadsIntoList(text);
1658
- told.push(text);
1743
+ const message = leads.add(text);
1744
+ heldTexts.push({ text, conversationId });
1745
+ if (message === null)
1746
+ return;
1747
+ const texts = heldTexts.map((held) => held.text);
1748
+ heldTexts = [];
1749
+ await showMessage(message, texts, conversationId);
1750
+ };
1751
+ /**
1752
+ * A lead-in no message followed, once the answer is in: its list went (the cards show it), so it goes out less its colon
1753
+ * sentence, and not at all when that was all of it. It counts as read when anything was, so the model never words again
1754
+ * what the shopper read.
1755
+ */
1756
+ const showLeftLead = async () => {
1757
+ if (heldTexts.length === 0)
1758
+ return;
1759
+ const rest = leads.end();
1760
+ const texts = heldTexts.map((held) => held.text);
1761
+ const conversationId = heldTexts[0].conversationId;
1762
+ heldTexts = [];
1763
+ if (rest)
1764
+ await showMessage(rest, texts, conversationId);
1765
+ else if (told.length > 0)
1766
+ told.push(...texts);
1659
1767
  };
1660
1768
  /** The products past the cards of the catalog this call heard last, for the count that follows it. */
1661
1769
  let moreFound = null;
1662
1770
  /** Whether this call's cards went out, and a count that came before them. */
1663
1771
  let cardsOut = false;
1772
+ /** The product cards this call's searches sent out as they answered, which the shopper can tap whatever the answer lists. */
1773
+ const streamedCards = [];
1664
1774
  let heldCount = null;
1665
1775
  /** The card each catalog id of this call went out as (its `productId`), and the cards still `checking`, by conversation. */
1666
1776
  const cardOfId = new Map();
@@ -1769,6 +1879,7 @@ export function buyTool(options) {
1769
1879
  await deliverPagesInTurn(earlyPages(update.conversationId, update.asOf, withAnswers(productsOfCatalog(update.catalog))), { turn: callTurn, presented: true, owner: t }, send);
1770
1880
  // A place shown `checking`, on a card or behind "show more", waits for the live search's word on it.
1771
1881
  noteCards([...productsOfCatalog(update.catalog), ...productsOfCatalog({ ...update.catalog, items: update.more })], update.conversationId);
1882
+ streamedCards.push(...productsOfCatalog(update.catalog));
1772
1883
  keepMore();
1773
1884
  cardsOut = true;
1774
1885
  // A count held for these cards goes out now, unless the catalog carries its own, which follows it.
@@ -1806,6 +1917,24 @@ export function buyTool(options) {
1806
1917
  },
1807
1918
  }
1808
1919
  : {}),
1920
+ ...(agentcardAcks
1921
+ ? {
1922
+ // Agentcard's line for the search, such as `one sec, checking target for la croix`: it goes out as the turn's
1923
+ // instant reply, through onWorking, else as Agentcard's message, and only while the shopper has read nothing
1924
+ // else of the turn. The model then reads `shopper_already_told_looking`.
1925
+ onAck: async ({ text, conversationId }) => {
1926
+ if (t.acked || cardsOut || told.length > 0 || waiting.length > 0)
1927
+ return;
1928
+ ack?.cancel();
1929
+ t.acked = true;
1930
+ const line = text.trim();
1931
+ t.ackOut = Promise.resolve()
1932
+ .then(() => (t.onWorking ? t.onWorking(line) : t.onText?.(line, { conversationId })))
1933
+ .then(() => { t.ackDelivered = true; }, () => { });
1934
+ await t.ackOut;
1935
+ },
1936
+ }
1937
+ : {}),
1809
1938
  ...(t.onCart
1810
1939
  ? {
1811
1940
  onCart: async ({ cart, carts, conversationId }) => {
@@ -1820,6 +1949,9 @@ export function buyTool(options) {
1820
1949
  : {}),
1821
1950
  }
1822
1951
  : undefined;
1952
+ // A streamed ask for something new that the tool shows no line of Agentcard's for says so. A pick never gets one.
1953
+ const streamed = !!handlers && Object.values(handlers).some((handler) => typeof handler === 'function');
1954
+ sent = planned.ask !== undefined && !planned.pick && streamed && !agentcardAcks ? { ...planned, ack: false } : planned;
1823
1955
  if (plan && 'local' in plan) {
1824
1956
  // Nothing went to Agentcard: the answer is where the purchase stands.
1825
1957
  }
@@ -1830,6 +1962,9 @@ export function buyTool(options) {
1830
1962
  catch (error) {
1831
1963
  refusal = error;
1832
1964
  }
1965
+ // Agentcard read the message as your model's: nothing ran, so the call ends here and the turn's model answers it.
1966
+ if (turn?.handed_back)
1967
+ throw HANDED_BACK;
1833
1968
  }
1834
1969
  // The store was still building the last request's cart: once it is in, the request that was refused goes as it was
1835
1970
  // sent, a step's own request too (a saved ask is no longer saved once its step sent it).
@@ -1837,6 +1972,8 @@ export function buyTool(options) {
1837
1972
  // Only the answer of the request sent again counts toward what the shopper read.
1838
1973
  told.length = 0;
1839
1974
  waiting.length = 0;
1975
+ leads = leadInJoiner();
1976
+ heldTexts = [];
1840
1977
  try {
1841
1978
  turn = await client.buy(sent, handlers);
1842
1979
  refusal = undefined;
@@ -1854,7 +1991,8 @@ export function buyTool(options) {
1854
1991
  let replaced = false;
1855
1992
  if (waiting.length > 0 && t.onText) {
1856
1993
  const rewritten = !!turn && settled.reply !== turn.reply;
1857
- const kept = new Set((rewritten ? toMessages(settled.reply) : []).map((message) => message.trim()));
1994
+ // Paragraph for paragraph, as `told` keeps Agentcard's messages, before any lead-in joins the one after it.
1995
+ const kept = new Set((rewritten ? paragraphsOf(settled.reply) : []).map((message) => message.trim()));
1858
1996
  for (const message of waiting.splice(0)) {
1859
1997
  if (rewritten && waitsForAnswer(message.text) && !kept.has(message.text.trim())) {
1860
1998
  replaced = true;
@@ -1877,6 +2015,14 @@ export function buyTool(options) {
1877
2015
  }
1878
2016
  }
1879
2017
  }
2018
+ if (t.onText) {
2019
+ try {
2020
+ await showLeftLead();
2021
+ }
2022
+ catch {
2023
+ // A message the app failed to show was never read, so the model words the answer.
2024
+ }
2025
+ }
1880
2026
  // Every message of Agentcard's own answer reached the shopper through onText, so the model adds nothing to it. An answer
1881
2027
  // whose card ask gave way to the named step was read the same way once every message of it, that ask last, went out.
1882
2028
  // A reply the shopper read that asks them to choose, with the values named inside a sentence or not at all, is followed
@@ -1892,7 +2038,7 @@ export function buyTool(options) {
1892
2038
  choiceShown = false;
1893
2039
  }
1894
2040
  }
1895
- const read = choiceShown && !!turn && (settled.reply === turn.reply ? readEvery(told, turn.messages) : replaced && readEvery(told, toMessages(settled.reply)));
2041
+ const read = choiceShown && !!turn && (settled.reply === turn.reply ? readEvery(told, turn.messages) : replaced && readEvery(told, paragraphsOf(settled.reply)));
1896
2042
  const answer = withMessages(settled, turn);
1897
2043
  const delivered = await deliver(answered.size > 0 ? { ...answer, products: withAnswers(answer.products) } : answer, { turn: callTurn, presented: turn?.catalog?.presented === true, owner: t }, send);
1898
2044
  // A count that came before any card (find mode puts its cards in the answer) goes out once the answer's cards did.
@@ -1956,7 +2102,23 @@ export function buyTool(options) {
1956
2102
  const storesShown = shownStores(result.stores);
1957
2103
  if (storesShown.length > 0)
1958
2104
  shownStoreNames = storesShown.map((store) => store.name);
2105
+ // The cards the shopper can tap by name are the answer's and the ones this call's searches already sent: a restaurant turn that
2106
+ // read the Burrito's options answered with the Burrito alone, beside the eight menu cards it sent first, and a tap on the
2107
+ // Burrito Bowl then found no card of its name. A place ("the last one") reads the answer's own list, in its order, and never
2108
+ // this one, so it picks the product shown in that place (Greptile on #11163).
2109
+ // Each card once, by its id: a call that sends one search's products again (a refreshed catalog) would otherwise hold the
2110
+ // same card twice, and a tap on it would read as two cards named alike (Greptile on #11163).
2111
+ const tappable = [];
2112
+ const held = new Set();
2113
+ for (const product of [...shown, ...shownProducts(streamedCards)]) {
2114
+ const id = pickId(product) ?? `name:${product.name}`;
2115
+ if (held.has(id))
2116
+ continue;
2117
+ held.add(id);
2118
+ tappable.push(product);
2119
+ }
1959
2120
  lastAnswerProducts = shown.map((product) => ({ productId: pickId(product), name: product.name }));
2121
+ lastTappable = tappable.map((product) => ({ productId: pickId(product), name: product.name }));
1960
2122
  lastAnswerStores = storesShown.map((store) => ({ storeId: store.id, name: store.name }));
1961
2123
  t.results?.push(result);
1962
2124
  if (t.onResult) {
@@ -1973,6 +2135,9 @@ export function buyTool(options) {
1973
2135
  // A call that fails, Agentcard's error or a thrown handler, still settles the cards it left `checking`.
1974
2136
  await settleLeft?.().catch(() => { });
1975
2137
  ack?.cancel();
2138
+ if (ack)
2139
+ t.holds?.delete(ack.cancel);
2140
+ t.dropWaitLine?.();
1976
2141
  running -= 1;
1977
2142
  lastEndedAt = Date.now();
1978
2143
  }
@@ -2152,7 +2317,29 @@ export function buyTool(options) {
2152
2317
  return Promise.resolve();
2153
2318
  };
2154
2319
  /** A message the app's model wrote: it goes to onItem, else onMessage, after every item before it, and the turn lists it. */
2155
- const text = (message, skip) => feed.push({ kind: 'text', text: message }, () => (onItem ? onItem({ kind: 'text', text: message }) : onMessage?.(message)), skip);
2320
+ let modelWrote = false;
2321
+ /** Whether the model's first words came before any call of the turn: set by its first delta or `say`, since a paragraph is cut later. */
2322
+ let wordsBeforeCall = null;
2323
+ const firstWords = (words) => {
2324
+ if (wordsBeforeCall === null && /\S/.test(String(words ?? '')))
2325
+ wordsBeforeCall = !state.called;
2326
+ };
2327
+ const text = (message, skip) => {
2328
+ const first = !modelWrote;
2329
+ modelWrote = true;
2330
+ const show = (left) => feed.push({ kind: 'text', text: message }, () => (onItem ? onItem({ kind: 'text', text: message }) : onMessage?.(message)), left);
2331
+ // The wait line already told the shopper the turn is on it: the model's own acknowledgement, its first message
2332
+ // written before any call, such as `on it, looking for a dryer under $20`, would be a second line, so it stays in
2333
+ // the model's own history and never reaches the shopper. A first message that asks, answers or says what a search
2334
+ // came to still goes out, and so does every message after it. (2026-10-27: "dryer under 20" read `one moment`,
2335
+ // then `on it, looking for a dryer under $20`.)
2336
+ // A wait line the app failed to send reached nobody (`ackDelivered`), so the model's own line goes out in its
2337
+ // place, in the feed's order: a failed send costs that line alone, never both.
2338
+ if (first && state.waitLineOut && (wordsBeforeCall ?? !state.called) && readsAsAckLine(message)) {
2339
+ return show(() => state.ackDelivered || (skip?.() ?? false));
2340
+ }
2341
+ return show(skip);
2342
+ };
2156
2343
  let ack = null;
2157
2344
  const working = state.onWorking;
2158
2345
  const reacting = state.onReaction;
@@ -2182,7 +2369,9 @@ export function buyTool(options) {
2182
2369
  // A search gets its 👀 the moment the turn opens, and no text: the reaction reads nothing back.
2183
2370
  react(state, reacting);
2184
2371
  }
2185
- else if (text !== null && working) {
2372
+ else if (text !== null && working && (writeAck || !streamCalls)) {
2373
+ // A streamed search's line is Agentcard's to write (onAck), from the conversation, while it searches; the
2374
+ // tool's template goes out at once only for an answer the app waits for whole, or in the app's own words.
2186
2375
  ack = text;
2187
2376
  state.acked = true;
2188
2377
  let sent;
@@ -2207,7 +2396,71 @@ export function buyTool(options) {
2207
2396
  ? null
2208
2397
  : event
2209
2398
  ? { 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 });
2399
+ : directCall(String(shopperText ?? ''), { answered, nextAction: lastNextAction, asked: lastReply, products: lastAnswerProducts, tappable: lastTappable, stores: lastAnswerStores });
2400
+ // Agentcard writes the line for a search the turn sends straight. A turn the app's model runs gets the model's own line,
2401
+ // or the wait line below.
2402
+ state.agentcardAcks = (ask) => direct !== null && direct.reason === 'product_ask' && ask.trim() === direct.input.ask.trim();
2403
+ /** Says the turn is on it, once, in fixed words that name nothing the shopper said, unless something went out first. */
2404
+ const sayWaiting = (line) => {
2405
+ if (state.acked || state.ended || feed.pushed() > 0 || !writesThroughTurn)
2406
+ return;
2407
+ state.acked = true;
2408
+ state.waitLineOut = true;
2409
+ state.ackOut = Promise.resolve()
2410
+ .then(() => working(line))
2411
+ .then(() => { state.ackDelivered = true; }, () => { });
2412
+ };
2413
+ // Only where the tool sees what the shopper read: the app writes the model's words through the turn.
2414
+ if (!event && working && !reacting && direct !== null && direct.reason !== 'product_ask') {
2415
+ // A pick, a confirm or an answer the turn sends straight that has shown nothing after ackDelayMs says it is on it. A
2416
+ // product ask hears Agentcard's line (onAck).
2417
+ const waitTimer = setTimeout(() => sayWaiting(slowStepLine(direct.reason === 'confirm' ? 'confirm' : state.picked ?? 'wait')), ackDelayMs);
2418
+ waitTimer.unref?.();
2419
+ // A call that ended, by its answer or by its error, leaves the turn to its reply: no wait line follows a failed pick.
2420
+ state.dropWaitLine = () => clearTimeout(waitTimer);
2421
+ }
2422
+ else if (!event && working && !reacting && direct !== null) {
2423
+ // A search the turn sends straight hears Agentcard's line (onAck). When that line never comes, as when Agentcard's
2424
+ // model writes none or its line is dropped, and the shopper has seen nothing, no line and no card, by twice
2425
+ // ackDelayMs, the wait line goes out then, the same rule as a model that has written nothing and called nothing.
2426
+ // Once it went out, a line of Agentcard's that comes later is dropped (onAck reads `acked`), so the shopper reads one
2427
+ // line. A call that already answered leaves the turn to its reply. (2026-10-27: "i want one called ardor" showed
2428
+ // nothing for 9.1 s.)
2429
+ const waitTimer = setTimeout(() => {
2430
+ if (results.length === 0)
2431
+ sayWaiting(slowStepLine('wait'));
2432
+ }, 2 * ackDelayMs);
2433
+ waitTimer.unref?.();
2434
+ // A call that ended, by its answer or by its error, leaves the turn to its reply: no wait line follows a failed search.
2435
+ state.dropWaitLine = () => clearTimeout(waitTimer);
2436
+ }
2437
+ else if (!event && working && !reacting && direct === null) {
2438
+ // The app's model runs this turn, and its first output decides: its own line first means no wait line; a call first
2439
+ // gets the wait line from that call (holdAck); and a model that has written nothing and called nothing after twice
2440
+ // ackDelayMs gets it then.
2441
+ const model = { openedAt: Date.now(), spoke: false, callStarted: false, writesThrough: () => writesThroughTurn, shown: () => feed.pushed() > 0 };
2442
+ state.modelTurn = model;
2443
+ const waitTimer = setTimeout(() => {
2444
+ if (!model.spoke && !model.callStarted)
2445
+ sayWaiting(slowStepLine(state.picked ?? 'wait'));
2446
+ }, 2 * ackDelayMs);
2447
+ waitTimer.unref?.();
2448
+ // `turn.end()`, as when the app's model failed before it wrote or called anything, stops it: no `one moment` follows
2449
+ // the app's own error. A call that ends stops it too, since a call started means the timer would send nothing.
2450
+ state.dropWaitLine = () => clearTimeout(waitTimer);
2451
+ }
2452
+ // What Agentcard last showed and asked when the turn opened, for `handled()`: its call changes them once it answers.
2453
+ const lastAnswer = {
2454
+ answered,
2455
+ next_action: lastNextAction ?? null,
2456
+ asked: lastReply ?? null,
2457
+ products: lastAnswerProducts.map((product) => ({ product_id: product.productId, name: product.name })),
2458
+ stores: lastAnswerStores.map((store) => ({ store_id: store.storeId, name: store.name })),
2459
+ };
2460
+ const openedWhileRunning = running > 0;
2461
+ const openedNextAction = lastNextAction;
2462
+ const openedProducts = lastAnswerProducts;
2463
+ const openedStores = lastAnswerStores;
2211
2464
  if (!event)
2212
2465
  previousTurn = state;
2213
2466
  // What Agentcard told the shopper that no history the model ran with holds yet.
@@ -2246,9 +2499,70 @@ export function buyTool(options) {
2246
2499
  if (saved.length > 0)
2247
2500
  client.forgetMessages(externalUserId, saved);
2248
2501
  handed = fresh;
2249
- return turnMessages(history, fresh.map(modelText), shopperWords);
2502
+ // A message Agentcard answered for the model to word goes in with that answer, so the model words it and calls nothing again.
2503
+ return turnMessages(history, fresh.map(modelText), handedAnswer === null ? shopperWords : `${shopperWords}\n\n${handedNote(handedAnswer)}`);
2504
+ };
2505
+ /** Says a whole text as the turn's messages, after every item before it (`say()`). */
2506
+ const sayWhole = (whole) => {
2507
+ writesThroughTurn = true;
2508
+ firstWords(whole);
2509
+ if (state.modelTurn && /\S/.test(String(whole ?? '')))
2510
+ state.modelTurn.spoke = true;
2511
+ const reply = feedReply(text);
2512
+ for (const message of toMessages(whole))
2513
+ reply.add(message);
2514
+ const released = releaseForReply();
2515
+ released.catch(() => { });
2516
+ return reply.settled().then(() => released);
2517
+ };
2518
+ /** Agentcard's answer, as the model reads it, when `handled()` left the words to the model. */
2519
+ let handedAnswer = null;
2520
+ let handledRun = null;
2521
+ /**
2522
+ * One call to Agentcard with the shopper's message (`BuyToolTurn.handled`). A pick by its id or a yes to the total the
2523
+ * tool reads itself goes as that call; every other message goes as its words, with `hand_back`, and Agentcard decides.
2524
+ */
2525
+ const runHandled = async () => {
2526
+ const words = String(shopperText ?? '');
2527
+ if (event || openedWhileRunning || words.trim() === '')
2528
+ return false;
2529
+ const local = directCall(words, { answered, nextAction: openedNextAction, asked: lastAnswer.asked, products: openedProducts, stores: openedStores });
2530
+ const exact = !!local && (local.input.confirm || local.input.product_id !== null || local.input.store_id !== null);
2531
+ const input = exact ? local.input : { ask: words, confirm: false, new_order: false, product_id: null, store_id: null };
2532
+ if (!exact) {
2533
+ state.handBack = lastAnswer;
2534
+ // Agentcard writes the line that says what it is looking for on a search this call starts, as on a search the turn
2535
+ // sends straight (`direct`); a message it hands back gets none, since nothing runs.
2536
+ state.agentcardAcks = (ask) => ask.trim() === words.trim();
2537
+ }
2538
+ let result;
2539
+ try {
2540
+ result = await answerIn(input, state);
2541
+ }
2542
+ catch (error) {
2543
+ state.handBack = null;
2544
+ if (error === HANDED_BACK)
2545
+ return false;
2546
+ throw error;
2547
+ }
2548
+ if (result.shopper_already_read_reply === true)
2549
+ return true;
2550
+ const reply = replyAsWritten(result, { textingStyle: texting });
2551
+ // The reply goes out where the app hears the turn's messages: onItem or onMessage, else onText. With none of them, the
2552
+ // shopper would read nothing, so the app's model words the answer instead.
2553
+ if (reply !== null && (onItem || onMessage)) {
2554
+ await sayWhole(reply);
2555
+ return true;
2556
+ }
2557
+ if (reply !== null && state.onText) {
2558
+ await state.onText(reply, { conversationId: result.conversation_id });
2559
+ return true;
2560
+ }
2561
+ handedAnswer = JSON.stringify(forModel(result));
2562
+ return false;
2250
2563
  };
2251
2564
  return toolOf((raw) => answerIn(raw, state), {}, {
2565
+ handled: { value: () => (handledRun ??= runHandled()), enumerable: false },
2252
2566
  agentcardMessages: { get: () => [...(handed ?? kept)], enumerable: false },
2253
2567
  event: { value: event, enumerable: false },
2254
2568
  release: {
@@ -2262,6 +2576,17 @@ export function buyTool(options) {
2262
2576
  },
2263
2577
  enumerable: false,
2264
2578
  },
2579
+ end: {
2580
+ value: () => {
2581
+ state.ended = true;
2582
+ state.dropWaitLine?.();
2583
+ // The acknowledgement a call still running holds, a 👀 or a line for a slow pick, confirm or answer, is cancelled too.
2584
+ for (const cancel of state.holds ?? [])
2585
+ cancel();
2586
+ state.holds?.clear();
2587
+ },
2588
+ enumerable: false,
2589
+ },
2265
2590
  messages: { value: messagesFor, enumerable: false },
2266
2591
  ack: { value: ack, enumerable: false },
2267
2592
  reaction: { get: () => state.reaction, enumerable: false },
@@ -2275,22 +2600,21 @@ export function buyTool(options) {
2275
2600
  messageStream: {
2276
2601
  value: () => {
2277
2602
  writesThroughTurn = true;
2278
- return feedStream(text, releaseForReply);
2279
- },
2280
- enumerable: false,
2281
- },
2282
- say: {
2283
- value: (whole) => {
2284
- writesThroughTurn = true;
2285
- const reply = feedReply(text);
2286
- for (const message of toMessages(whole))
2287
- reply.add(message);
2288
- const released = releaseForReply();
2289
- released.catch(() => { });
2290
- return reply.settled().then(() => released);
2603
+ const stream = feedStream(text, releaseForReply);
2604
+ return {
2605
+ push: (delta) => {
2606
+ firstWords(delta);
2607
+ // The model's first words: its own line is the turn's acknowledgement, so no wait line follows.
2608
+ if (state.modelTurn && /\S/.test(String(delta ?? '')))
2609
+ state.modelTurn.spoke = true;
2610
+ stream.push(delta);
2611
+ },
2612
+ end: () => stream.end(),
2613
+ };
2291
2614
  },
2292
2615
  enumerable: false,
2293
2616
  },
2617
+ say: { value: sayWhole, enumerable: false },
2294
2618
  });
2295
2619
  };
2296
2620
  return toolOf(answer, { instantAck, instantReaction, turn, nextStep }, {});