@agent-cards/commerce-sdk 0.18.4 → 0.18.6

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
@@ -1,44 +1,12 @@
1
- /**
2
- * The buy tool: one call that buys for one shopper, in one object that every
3
- * agent framework takes. `answer` sends the model's input to Agentcard and
4
- * returns the whole answer for your code, with the status, Agentcard's
5
- * reply, the cart and its hash, the products and the links. `forModel` turns
6
- * that answer into what the model may read, with every link taken out, and
7
- * `run`, `execute` and `handle` return that view as JSON text. A link
8
- * reaches the shopper through `sendLink`, and each call waits for it before
9
- * it returns: the Vault and approval links, then the store page of each
10
- * product the model reads.
11
- *
12
- * Agentcard keeps the conversation: every call continues the shopper's open
13
- * purchase (`resume: true`), and a confirm places the cart Agentcard last
14
- * showed (`confirm: true`). The tool keeps one thing between calls: the store
15
- * pages it sent last. Agentcard repeats the last search in each answer for a
16
- * while, so a list it shows again in the same conversation does not reach
17
- * the shopper twice. A tool built for each message buys the same way, and
18
- * sends such a list again.
19
- *
20
- * Two answers make the tool wait before it returns. A placement Agentcard is
21
- * still finishing gets one 12-second pause and one read of the conversation.
22
- * A turn that outlived Agentcard's budget gets a read every 5 seconds for up
23
- * to a minute, until the turn ends.
24
- *
25
- * `turn` binds the tool to one shopper message: the calls the model makes
26
- * through it hand their links, answers and instant reply to that turn's own
27
- * callbacks and lists, so a shopper who sends a second message before the
28
- * first is answered never gets one message's cards beside the other's reply.
29
- * Every turn of one tool still shares the shopper's conversation state, such
30
- * as the store pages already sent. A turn with `onItem` hands the app its
31
- * messages and links as one sequence, in the order they happened, and spaces
32
- * the messages like someone texting.
33
- */
34
1
  import { AgentcardApiError, AgentcardClient, cartBuildingOf, } from './client.js';
35
2
  import { buyToolDefinition, buyToolStandardSchema, openAIBuyTool, parseBuyToolInput, } from './definition.js';
36
3
  import { directCall, isProductAsk } from './direct.js';
37
4
  import { forModel, forModelError, pickId, productPrice, productCardKey, shownProducts, shownStores, stripLinks } from './model-view.js';
38
5
  import { choiceLines, leadsIntoList, messageForShopper, messagesListChoice, replyAsWritten, textingStyle } from './texting.js';
39
6
  import { joinLeadIns, leadInJoiner, messageStream, paceOf, paragraphsOf, sleep, toMessages, typingHook } from './messages.js';
40
- import { isStale } from './shopper-message.js';
7
+ import { isStale, messageInChat } from './shopper-message.js';
41
8
  import { actionLinkId, isActionLink } from './action-links.js';
9
+ import { checkStoreWait, storeWaitFields, withLateCards } from './store-wait.js';
42
10
  import { ACK_REACTION, ackAt, answersCheckout, answersQuestion, asksForSimilar, holdsBack, instantAck as instantAckOf, isNewRequest, isPickText, keepsYes, namesStore, nextTemplate, readsAsAckLine, slowStepLine } from './ack.js';
43
11
  /** The action a product of `merchant` takes (BuyToolProductAction). */
44
12
  export function productAction(merchant) {
@@ -81,7 +49,7 @@ export function handedNote(answer) {
81
49
  }
82
50
  /** What the model reads of a message Agentcard posted into the chat: its text with every link taken out, as `forModel` takes them out. */
83
51
  const modelText = (message) => stripLinks(String(message.text ?? ''));
84
- /** How often the history held a message's text when the message first went in to the model. */
52
+ /** How often each chat's history held a message's text when the message first went in to its model, by `threadId` ('' for none). */
85
53
  const offeredAt = new WeakMap();
86
54
  /** How many times `needle` appears in `haystack`, none overlapping. */
87
55
  function occurrences(haystack, needle) {
@@ -151,6 +119,8 @@ const CART_CHANGED = 'The cart changed since the shopper saw it. Show the shoppe
151
119
  const NOTHING_TO_CONFIRM = 'There is no cart to confirm yet. Ask the shopper what they want.';
152
120
  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.';
153
121
  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.";
122
+ /** The most product cards a tool keeps from earlier answers for a tap on an older card. */
123
+ const SHOWN_BEFORE_MAX = 200;
154
124
  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.";
155
125
  /** One cart line as the order holds it: the product, its variant, its choices and its quantity. Prices are left out. */
156
126
  const lineKey = (line) => JSON.stringify([line.product_id ?? String(line.name ?? '').trim().toLowerCase(), line.variant ?? null, [...(line.options ?? [])].sort(), line.qty]);
@@ -352,6 +322,7 @@ function answerOf(turn) {
352
322
  hashes: hashesOf(turn.cart, turn.carts),
353
323
  cart_building: turn.cart_building,
354
324
  choice: turn.choice ?? null,
325
+ plan: turn.plan ?? null,
355
326
  envelope: turn.raw,
356
327
  };
357
328
  }
@@ -419,6 +390,13 @@ const CARD_ASK = /\b(?:add|save|link|enter|attach)\s+(?:a|your|the)\s+(?:(?:paym
419
390
  * which `askingForNamedStep` replaces when Agentcard names a step that comes first. Every message after it waits behind
420
391
  * it, so the shopper reads them in order; a message before it goes out at once.
421
392
  */
393
+ /**
394
+ * Agentcard's messages of an answer, as written, that the shopper read in full through the early reply (onWrittenText)
395
+ * before the call ended, by the answer they belong to. `turn.replyAsWritten` hands them back for the turn's last answer and
396
+ * `turn.say` of them sends nothing, so the app shows no second reply and its model is not asked to word one
397
+ * (chat.opencommerce.sh, 2026-10-29: after "i found 16 more hotels" the model wrote a second recommendation of the same hotels).
398
+ */
399
+ const readAsWritten = new WeakMap();
422
400
  function waitsForAnswer(message) {
423
401
  return CARD_ASK.test(message) || new RegExp(URL_IN_TEXT.source, 'i').test(message);
424
402
  }
@@ -767,6 +745,13 @@ export function buyTool(options) {
767
745
  if (!client.company && externalUserId !== undefined) {
768
746
  throw new TypeError("A shopper's own access token already names the shopper. Leave externalUserId out.");
769
747
  }
748
+ if (o.threadId !== undefined && (typeof o.threadId !== 'string' || o.threadId.trim() === '' || o.threadId.trim().length > 200)) {
749
+ throw new TypeError('Pass threadId as your own id for the chat or thread, 1 to 200 characters, or leave it out.');
750
+ }
751
+ const threadId = o.threadId?.trim();
752
+ checkStoreWait(o);
753
+ /** Whether a message is this tool's to read (`messageInChat`): its chat's purchase, or news about the shopper alone. */
754
+ const inThisThread = (message) => messageInChat(message, threadId);
770
755
  if (o.sendLink !== undefined && typeof o.sendLink !== 'function') {
771
756
  throw new TypeError('Pass sendLink as a function that delivers a link to the shopper.');
772
757
  }
@@ -838,6 +823,9 @@ export function buyTool(options) {
838
823
  const { onWorking, onReaction } = ackRoutes(o.onWorking, o.onReaction);
839
824
  /** Calls running now, when the last one ended, and the template the instant reply used last. */
840
825
  let running = 0;
826
+ // When the newest call of this tool started: a late reply on the conversation answers the newest shopper message, so a call
827
+ // that a newer one followed no longer owns it.
828
+ let newestCallAt = 0;
841
829
  let lastEndedAt = Number.NEGATIVE_INFINITY;
842
830
  let lastTemplate = -1;
843
831
  /** Counts the model's turns: the calls of one turn share its number. */
@@ -936,6 +924,9 @@ export function buyTool(options) {
936
924
  let lastAnswerProducts = [];
937
925
  /** Every product card of the last answer's call the shopper can tap by name, each once (the answer's and the streamed). */
938
926
  let lastTappable = [];
927
+ /** Every product card this conversation's answers showed, newest first, each once: a tap on an older card reads these. */
928
+ let shownBefore = [];
929
+ let shownIn = null;
939
930
  let lastAnswerStores = [];
940
931
  let previousTurn = null;
941
932
  /** 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. */
@@ -1272,7 +1263,8 @@ export function buyTool(options) {
1272
1263
  * picks one first. A placement another attempt is still finishing keeps the
1273
1264
  * reading going and ends as `placing`, never as the cart. A minute without
1274
1265
  * any of them returns Agentcard's own answer, or what the last read showed
1275
- * while the turn still ran.
1266
+ * while the turn still ran. A turn that ended with a reply of its own
1267
+ * (`latest_reply`) answers with that reply and the products it showed.
1276
1268
  */
1277
1269
  const awaitCart = async (base, conversationId, since) => {
1278
1270
  const shown = new Set(base.hashes);
@@ -1307,13 +1299,18 @@ export function buyTool(options) {
1307
1299
  // conversation and it shows one cart. With carts at two stores `confirm: true` still names the
1308
1300
  // cart an earlier answer showed, so none is offered: the shopper's pick goes out as an ask.
1309
1301
  const one = conversation.carts.length === 1 ? newest : null;
1302
+ // The reply the turn wrote after its early answer is what the shopper waited for: it goes out in place of the
1303
+ // tool's own line, with the products it showed (conv_054988b998634b16ab4a91d9, 2026-10-30: a stop message
1304
+ // written while the shopper read "one moment" was never shown, and the model was told to call again).
1305
+ const late = conversation.turn_in_progress || newestCallAt > since ? null : conversation.latest_reply;
1310
1306
  const answer = {
1311
1307
  ...base,
1312
- reply: one ? CART_READY : newest ? CHOOSE_CART : ANSWER_LATE,
1308
+ reply: late ? late.text : one ? CART_READY : newest ? CHOOSE_CART : ANSWER_LATE,
1309
+ ...(late?.catalog ? { products: productsOfCatalog(late.catalog) } : {}),
1313
1310
  cart: one,
1314
1311
  carts: conversation.carts,
1315
1312
  hashes: conversation.carts.map((c) => c.hash),
1316
- error_code: newest ? null : base.error_code,
1313
+ error_code: newest || late ? null : base.error_code,
1317
1314
  // A read replaced the answer the step was named for, and names no step of its own: the reply asks.
1318
1315
  next_action: null,
1319
1316
  // The early answer judged the pick before the turn finished adding it.
@@ -1750,6 +1747,16 @@ export function buyTool(options) {
1750
1747
  let refusal;
1751
1748
  /** 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. */
1752
1749
  const told = [];
1750
+ /**
1751
+ * Where this call's messages from Agentcard go as they stream: the app's onText, or, for an app that shows the reply as
1752
+ * written, its onMessage once Agentcard says the reply is written (`reply_written`). Until then such an app's
1753
+ * messages are kept in `unsaid`, and a call whose stream never says so shows none of them: the app shows the answer.
1754
+ */
1755
+ let speak = t.onText;
1756
+ // Only where the cards went out as they streamed (earlyCards): a reply shown earlier than its cards would ask the shopper
1757
+ // to choose from hotels they cannot see yet.
1758
+ const writtenText = early ? t.onWrittenText : undefined;
1759
+ const unsaid = [];
1753
1760
  /**
1754
1761
  * The streamed messages that wait for the answer: the first that asks for a card or points at a link (waitsForAnswer),
1755
1762
  * and every message after it, so the shopper reads them in the order Agentcard wrote them.
@@ -1762,7 +1769,7 @@ export function buyTool(options) {
1762
1769
  let heldTexts = [];
1763
1770
  /** Shows a message through onText, its lists as bullets (messageForShopper); `told` keeps each of Agentcard's messages in it as written. */
1764
1771
  const showMessage = async (message, texts, conversationId) => {
1765
- await t.onText(messageForShopper(message, leadIn, t.texting), { conversationId });
1772
+ await speak(messageForShopper(message, leadIn, t.texting), { conversationId });
1766
1773
  leadIn = leadsIntoList(message);
1767
1774
  told.push(...texts);
1768
1775
  };
@@ -1802,6 +1809,9 @@ export function buyTool(options) {
1802
1809
  let cardsOut = false;
1803
1810
  /** The product cards this call's searches sent out as they answered, which the shopper can tap whatever the answer lists. */
1804
1811
  const streamedCards = [];
1812
+ /** Whether Agentcard said the reply is written (`reply_written`), and the cards that went out after it: a store that answered late. */
1813
+ let replyWritten = false;
1814
+ const lateCards = [];
1805
1815
  let heldCount = null;
1806
1816
  /** The card each catalog id of this call went out as (its `productId`), and the cards still `checking`, by conversation. */
1807
1817
  const cardOfId = new Map();
@@ -1863,6 +1873,23 @@ export function buyTool(options) {
1863
1873
  // The app's display failing never fails the call.
1864
1874
  }
1865
1875
  };
1876
+ /** One of Agentcard's messages as it streams, to the app through `speak`. */
1877
+ const streamText = async (text, conversationId) => {
1878
+ // A message that lands before the instant reply was due is the reply; one after it follows it.
1879
+ ack?.cancel();
1880
+ await t.ackOut;
1881
+ // 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.
1882
+ if (waiting.length > 0 || waitsForAnswer(text)) {
1883
+ waiting.push({ text, conversationId });
1884
+ return;
1885
+ }
1886
+ try {
1887
+ await showText(text, conversationId);
1888
+ }
1889
+ catch {
1890
+ // A message the app failed to show was never read, so the model words the answer.
1891
+ }
1892
+ };
1866
1893
  // Streamed: the stages go to onProgress, a search's products to onProducts and its store pages out the moment it
1867
1894
  // answers, and each message and cart to the app as Agentcard writes it.
1868
1895
  const handlers = streams
@@ -1901,13 +1928,19 @@ export function buyTool(options) {
1901
1928
  // The products past the cards are read after the cards went out, so the first card never waits for them.
1902
1929
  const keepMore = () => { if (t.onFound && early)
1903
1930
  moreFound = { conversationId: update.conversationId, links: productsOfCatalog({ ...update.catalog, items: update.more }).map(productLink) }; };
1904
- if (!early)
1931
+ if (!early) {
1932
+ // Without early cards nothing goes out here, but a store that answered after the reply still joins the result.
1933
+ if (replyWritten)
1934
+ lateCards.push(...productsOfCatalog(update.catalog));
1905
1935
  return;
1936
+ }
1906
1937
  // Products arrived before the instant reply was due: they are the reply.
1907
1938
  ack?.cancel();
1908
1939
  await t.ackOut;
1909
1940
  // The turn's own search presents its products, as a reply that lists them does.
1910
- await deliverPagesInTurn(earlyPages(update.conversationId, update.asOf, withAnswers(productsOfCatalog(update.catalog))), { turn: callTurn, presented: true, owner: t }, send);
1941
+ const out = await deliverPagesInTurn(earlyPages(update.conversationId, update.asOf, withAnswers(productsOfCatalog(update.catalog))), { turn: callTurn, presented: true, owner: t }, send);
1942
+ if (replyWritten)
1943
+ lateCards.push(...out.products);
1911
1944
  // A place shown `checking`, on a card or behind "show more", waits for the live search's word on it.
1912
1945
  noteCards([...productsOfCatalog(update.catalog), ...productsOfCatalog({ ...update.catalog, items: update.more })], update.conversationId);
1913
1946
  streamedCards.push(...productsOfCatalog(update.catalog));
@@ -1928,23 +1961,26 @@ export function buyTool(options) {
1928
1961
  },
1929
1962
  }
1930
1963
  : {}),
1931
- ...(t.onText
1964
+ ...(t.onText || writtenText
1932
1965
  ? {
1933
1966
  onSegment: async ({ text, conversationId }) => {
1934
- // A message that lands before the instant reply was due is the reply; one after it follows it.
1935
- ack?.cancel();
1936
- await t.ackOut;
1937
- // 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.
1938
- if (waiting.length > 0 || waitsForAnswer(text)) {
1939
- waiting.push({ text, conversationId });
1967
+ if (!speak) {
1968
+ unsaid.push({ text, conversationId });
1940
1969
  return;
1941
1970
  }
1942
- try {
1943
- await showText(text, conversationId);
1944
- }
1945
- catch {
1946
- // A message the app failed to show was never read, so the model words the answer.
1947
- }
1971
+ await streamText(text, conversationId);
1972
+ },
1973
+ }
1974
+ : {}),
1975
+ ...(early || t.onProducts
1976
+ ? {
1977
+ onReplyWritten: async () => {
1978
+ replyWritten = true;
1979
+ if (speak || !writtenText)
1980
+ return;
1981
+ speak = writtenText;
1982
+ for (const message of unsaid.splice(0))
1983
+ await streamText(message.text, message.conversationId);
1948
1984
  },
1949
1985
  }
1950
1986
  : {}),
@@ -1983,10 +2019,18 @@ export function buyTool(options) {
1983
2019
  // A streamed ask for something new that the tool shows no line of Agentcard's for says so. A pick never gets one.
1984
2020
  const streamed = !!handlers && Object.values(handlers).some((handler) => typeof handler === 'function');
1985
2021
  sent = planned.ask !== undefined && !planned.pick && streamed && !agentcardAcks ? { ...planned, ack: false } : planned;
2022
+ // Every request names the tool's thread, so Agentcard continues this chat's purchase and no other.
2023
+ if (threadId)
2024
+ sent = { ...sent, thread_id: threadId };
2025
+ // Every request names how long the reply waits for stores, and what happens to a store that answers after it.
2026
+ sent = { ...sent, ...storeWaitFields(t.storeWait ?? {}, o) };
1986
2027
  if (plan && 'local' in plan) {
1987
2028
  // Nothing went to Agentcard: the answer is where the purchase stands.
1988
2029
  }
1989
2030
  else {
2031
+ // This call now owns the conversation's newest reply: an earlier call still waiting for its late reply gives it up.
2032
+ // A call that threw on its input, or answered from where the purchase stands, sent nothing and takes none.
2033
+ newestCallAt = Math.max(newestCallAt, startedAt);
1990
2034
  try {
1991
2035
  turn = await client.buy(sent, handlers);
1992
2036
  }
@@ -2003,6 +2047,8 @@ export function buyTool(options) {
2003
2047
  // Only the answer of the request sent again counts toward what the shopper read.
2004
2048
  told.length = 0;
2005
2049
  waiting.length = 0;
2050
+ unsaid.length = 0;
2051
+ speak = t.onText;
2006
2052
  leads = leadInJoiner();
2007
2053
  heldTexts = [];
2008
2054
  try {
@@ -2029,7 +2075,7 @@ export function buyTool(options) {
2029
2075
  // a link the answer replaced gives way to its ask for the step Agentcard named, once, after them. All of them go out
2030
2076
  // before any link of the answer.
2031
2077
  let replaced = false;
2032
- if (waiting.length > 0 && t.onText) {
2078
+ if (waiting.length > 0 && speak) {
2033
2079
  const rewritten = !!turn && settled.reply !== turn.reply;
2034
2080
  // Paragraph for paragraph, as `told` keeps Agentcard's messages, before any lead-in joins the one after it.
2035
2081
  const kept = new Set((rewritten ? paragraphsOf(settled.reply) : []).map((message) => message.trim()));
@@ -2055,7 +2101,7 @@ export function buyTool(options) {
2055
2101
  }
2056
2102
  }
2057
2103
  }
2058
- if (t.onText) {
2104
+ if (speak) {
2059
2105
  try {
2060
2106
  await showLeftLead();
2061
2107
  }
@@ -2068,10 +2114,10 @@ export function buyTool(options) {
2068
2114
  // A reply the shopper read that asks them to choose, with the values named inside a sentence or not at all, is followed
2069
2115
  // by one more message that lists every value on its own line (choiceLines), so no choice is read buried in prose.
2070
2116
  let choiceShown = true;
2071
- if (t.onText && turn?.choice && told.length > 0 && !messagesListChoice(told, turn.choice)) {
2117
+ if (speak && turn?.choice && told.length > 0 && !messagesListChoice(told, turn.choice)) {
2072
2118
  const lines = choiceLines(turn.choice);
2073
2119
  try {
2074
- await t.onText(t.texting ? textingStyle(lines) : lines, { conversationId: turn.conversation_id || null });
2120
+ await speak(t.texting ? textingStyle(lines) : lines, { conversationId: turn.conversation_id || null });
2075
2121
  }
2076
2122
  catch {
2077
2123
  // Not shown, so the shopper has not read the whole answer: the model words it, with the choice it reads.
@@ -2079,8 +2125,14 @@ export function buyTool(options) {
2079
2125
  }
2080
2126
  }
2081
2127
  const read = choiceShown && !!turn && (settled.reply === turn.reply ? readEvery(told, turn.messages) : replaced && readEvery(told, paragraphsOf(settled.reply)));
2128
+ // The shopper read the whole answer before the call ended, the late source's message included: the turn's reply is what
2129
+ // they read, and nothing is worded again (chat.opencommerce.sh, 2026-10-29: after "i found 16 more hotels" the model
2130
+ // wrote a second recommendation of the same hotels).
2082
2131
  const answer = withMessages(settled, turn);
2083
- const delivered = await deliver(answered.size > 0 ? { ...answer, products: withAnswers(answer.products) } : answer, { turn: callTurn, presented: turn?.catalog?.presented === true, owner: t }, send);
2132
+ // A late reply read from the conversation presents the products of its own catalog, which the early answer never held.
2133
+ const late = settled.envelope?.latest_reply;
2134
+ const presented = turn?.catalog?.presented === true || (late?.text === settled.reply && late?.catalog?.presented === true);
2135
+ const delivered = await deliver(answered.size > 0 ? { ...answer, products: withAnswers(answer.products) } : answer, { turn: callTurn, presented, owner: t }, send);
2084
2136
  // A count that came before any card (find mode puts its cards in the answer) goes out once the answer's cards did.
2085
2137
  if (heldCount && (delivered.products?.length ?? 0) > 0) {
2086
2138
  const held = heldCount;
@@ -2105,11 +2157,15 @@ export function buyTool(options) {
2105
2157
  : null;
2106
2158
  const result = {
2107
2159
  ...delivered,
2160
+ // The cards of a store that answered after the reply join the answer's, when the answer does not list them already.
2161
+ ...(lateCards.length > 0 ? { products: withLateCards(delivered.products, lateCards) } : {}),
2108
2162
  ...(t.ackDelivered ? { shopper_already_told_looking: true } : {}),
2109
2163
  ...(read ? { shopper_already_read_reply: true } : {}),
2110
2164
  ...(confirmSent ? { confirm_sent: true } : {}),
2111
2165
  ...(yesCarried ? { yes_carried: yesCarried } : {}),
2112
2166
  };
2167
+ if (read && speak && speak === writtenText)
2168
+ readAsWritten.set(result, told.join('\n\n'));
2113
2169
  lastNextAction = result.next_action;
2114
2170
  lastReply = result.reply;
2115
2171
  // A refusal reports no carts, so only a turn's answer says how many wait, and whether a checkout just ended with none.
@@ -2159,6 +2215,13 @@ export function buyTool(options) {
2159
2215
  }
2160
2216
  lastAnswerProducts = shown.map((product) => ({ productId: pickId(product), name: product.name }));
2161
2217
  lastTappable = tappable.map((product) => ({ productId: pickId(product), name: product.name }));
2218
+ // A new conversation starts the cards shown before afresh; each card keeps its newest showing, up to SHOWN_BEFORE_MAX.
2219
+ if (result.conversation_id && result.conversation_id !== shownIn)
2220
+ shownBefore = [];
2221
+ if (result.conversation_id)
2222
+ shownIn = result.conversation_id;
2223
+ const fresh = new Set(lastTappable.map((product) => product.productId ?? `name:${product.name}`));
2224
+ shownBefore = [...lastTappable, ...shownBefore.filter((product) => !fresh.has(product.productId ?? `name:${product.name}`))].slice(0, SHOWN_BEFORE_MAX);
2162
2225
  lastAnswerStores = storesShown.map((store) => ({ storeId: store.id, name: store.name }));
2163
2226
  t.results?.push(result);
2164
2227
  if (t.onResult) {
@@ -2235,6 +2298,8 @@ export function buyTool(options) {
2235
2298
  return null;
2236
2299
  if (client.company && message.externalUserId !== externalUserId)
2237
2300
  return null;
2301
+ if (!inThisThread(message))
2302
+ return null;
2238
2303
  if (message.eventId && stepped.has(message.eventId))
2239
2304
  return null;
2240
2305
  // A call for the shopper's own message is still running: its answer tells the model where the purchase stands.
@@ -2243,7 +2308,7 @@ export function buyTool(options) {
2243
2308
  if (message.eventId)
2244
2309
  stepped.add(message.eventId);
2245
2310
  if (client.company)
2246
- client.answered(externalUserId, message);
2311
+ client.answered(externalUserId, message, threadId);
2247
2312
  return openTurn(nextStepPrompt(message), options, message);
2248
2313
  };
2249
2314
  const openTurn = (shopperText, options, event) => {
@@ -2268,6 +2333,7 @@ export function buyTool(options) {
2268
2333
  if (own.textingStyle !== undefined && typeof own.textingStyle !== 'boolean')
2269
2334
  throw new TypeError('Pass textingStyle to the turn as true or false, or leave it out.');
2270
2335
  const texting = own.textingStyle ?? textingByDefault;
2336
+ checkStoreWait(own, ' to the turn');
2271
2337
  const hearText = own.onText ?? o.onText;
2272
2338
  // Messages are spaced only for an app that hears each one as it goes out.
2273
2339
  const pauseOf = paceOf(own.pace);
@@ -2337,7 +2403,14 @@ export function buyTool(options) {
2337
2403
  // Agentcard's message takes its place in the turn's sequence, after the items before it, and goes to onText alone, so
2338
2404
  // an app that passes onItem too hears it once.
2339
2405
  onText: hearText ? (text, info) => feed.push({ kind: 'text', text }, () => hearText(text, info)) : undefined,
2406
+ // An app that shows the reply as written hears it the moment Agentcard says it is written, when the turn then waits for
2407
+ // a slower source's cards: the reply no longer waits for them (chat.opencommerce.sh, 2026-10-29, a hotel reply written
2408
+ // at about 21 s reached the shopper at 36.5 s, with the second supplier's hotels).
2409
+ onWrittenText: !hearText && written && (onItem || onMessage)
2410
+ ? (text) => feed.push({ kind: 'text', text }, () => (onItem ? onItem({ kind: 'text', text }) : onMessage?.(text)))
2411
+ : undefined,
2340
2412
  texting,
2413
+ storeWait: { storeWaitMs: own.storeWaitMs, lateResults: own.lateResults },
2341
2414
  onCart: own.onCart ?? o.onCart,
2342
2415
  tell: onItem || baseOnWorking ? (text) => feed.push({ kind: 'text', text }, () => (onItem ? onItem({ kind: 'text', text }) : baseOnWorking?.(text))) : undefined,
2343
2416
  links: [],
@@ -2430,13 +2503,17 @@ export function buyTool(options) {
2430
2503
  const results = state.results;
2431
2504
  // A pick or an answer goes straight only after a turn that ended on Agentcard's answer, and never while a call still runs.
2432
2505
  const answered = previousTurn !== null && (previousTurn.results?.length ?? 0) > 0;
2506
+ // A tap names its card by the id its link carried, whatever answer showed it: the pick goes straight with that id.
2507
+ const tappedId = typeof options?.tapped?.productId === 'string' && options.tapped.productId.trim() ? options.tapped.productId.trim() : null;
2433
2508
  // A `nextStep` turn's call is the same whatever the model writes (`stepPlan` reads where the purchase stands), so it goes
2434
2509
  // straight too: with `replyAsWritten`, a step whose answer is a link, such as the approval after a saved card, needs no model.
2435
2510
  const direct = !directOn || running > 0
2436
2511
  ? null
2437
2512
  : event
2438
2513
  ? { input: { ask: NEXT_STEP_ASK, confirm: false, new_order: false, product_id: null, store_id: null }, reason: 'next_step' }
2439
- : directCall(String(shopperText ?? ''), { answered, nextAction: lastNextAction, asked: lastReply, products: lastAnswerProducts, tappable: lastTappable, stores: lastAnswerStores });
2514
+ : tappedId && String(shopperText ?? '').trim()
2515
+ ? { input: { ask: String(shopperText).trim(), confirm: false, new_order: false, product_id: tappedId, store_id: null }, reason: 'pick' }
2516
+ : directCall(String(shopperText ?? ''), { answered, nextAction: lastNextAction, asked: lastReply, products: lastAnswerProducts, tappable: lastTappable, shownBefore, stores: lastAnswerStores });
2440
2517
  // Agentcard writes the line for a search the turn sends straight. A turn the app's model runs gets the model's own line,
2441
2518
  // or the wait line below.
2442
2519
  state.agentcardAcks = (ask) => direct !== null && direct.reason === 'product_ask' && ask.trim() === direct.input.ask.trim();
@@ -2504,7 +2581,7 @@ export function buyTool(options) {
2504
2581
  if (!event)
2505
2582
  previousTurn = state;
2506
2583
  // What Agentcard told the shopper that no history the model ran with holds yet.
2507
- const kept = client.company ? client.keptMessages(externalUserId) : [];
2584
+ const kept = client.company ? client.keptMessages(externalUserId, threadId).filter(inThisThread) : [];
2508
2585
  let handed = null;
2509
2586
  const shopperWords = String(shopperText ?? '');
2510
2587
  /**
@@ -2526,18 +2603,20 @@ export function buyTool(options) {
2526
2603
  // Another order's update can read the same, so only a history holding the text more often than
2527
2604
  // when this message first went in holds this message.
2528
2605
  const count = occurrences(held, JSON.stringify(text).slice(1, -1));
2529
- const before = offeredAt.get(message);
2606
+ // Each chat counts its own history: a message about the shopper alone goes in to every chat's model.
2607
+ const counts = offeredAt.get(message) ?? new Map();
2608
+ const before = counts.get(threadId ?? '');
2530
2609
  if (before !== undefined && count > before) {
2531
2610
  saved.push(message);
2532
2611
  }
2533
2612
  else {
2534
2613
  if (before === undefined)
2535
- offeredAt.set(message, count);
2614
+ offeredAt.set(message, counts.set(threadId ?? '', count));
2536
2615
  fresh.push(message);
2537
2616
  }
2538
2617
  }
2539
2618
  if (saved.length > 0)
2540
- client.forgetMessages(externalUserId, saved);
2619
+ client.forgetMessages(externalUserId, saved, threadId);
2541
2620
  handed = fresh;
2542
2621
  // A message Agentcard answered for the model to word goes in with that answer, so the model words it and calls nothing again.
2543
2622
  return turnMessages(history, fresh.map(modelText), handedAnswer === null ? shopperWords : `${shopperWords}\n\n${handedNote(handedAnswer)}`);
@@ -2545,6 +2624,10 @@ export function buyTool(options) {
2545
2624
  /** Says a whole text as the turn's messages, after every item before it (`say()`). */
2546
2625
  const sayWhole = (whole) => {
2547
2626
  writesThroughTurn = true;
2627
+ // The reply the shopper already read as Agentcard wrote it (`readAsWritten`), of the turn's last answer: saying it again sends nothing.
2628
+ const read = results.length > 0 ? readAsWritten.get(results[results.length - 1]) : undefined;
2629
+ if (read && whole === read)
2630
+ return releaseForReply();
2548
2631
  firstWords(whole);
2549
2632
  if (state.modelTurn && /\S/.test(String(whole ?? '')))
2550
2633
  state.modelTurn.spoke = true;
@@ -2613,7 +2696,7 @@ export function buyTool(options) {
2613
2696
  if (event.eventId)
2614
2697
  stepped.delete(event.eventId);
2615
2698
  if (client.company)
2616
- client.unanswered(externalUserId, event);
2699
+ client.unanswered(externalUserId, event, threadId);
2617
2700
  },
2618
2701
  enumerable: false,
2619
2702
  },
@@ -2628,7 +2711,7 @@ export function buyTool(options) {
2628
2711
  results: { get: () => [...results], enumerable: false },
2629
2712
  direct: { value: direct, enumerable: false },
2630
2713
  stopped: { get: () => state.stopped === true, enumerable: false },
2631
- replyAsWritten: { get: () => (written && results.length > 0 && !state.stopped ? replyAsWritten(results[results.length - 1], { textingStyle: texting }) : null), enumerable: false },
2714
+ replyAsWritten: { get: () => (written && results.length > 0 && !state.stopped ? readAsWritten.get(results[results.length - 1]) ?? replyAsWritten(results[results.length - 1], { textingStyle: texting }) : null), enumerable: false },
2632
2715
  items: { get: () => feed.items.map((item) => (item.kind === 'link' ? { kind: 'link', link: { ...item.link } } : { ...item })), enumerable: false },
2633
2716
  // 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.
2634
2717
  // A step link held for this message goes out right after its last paragraph, once the stream ends.