@agent-cards/commerce-sdk 0.18.5 → 0.18.7

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,36 +1,3 @@
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';
@@ -39,6 +6,7 @@ import { choiceLines, leadsIntoList, messageForShopper, messagesListChoice, repl
39
6
  import { joinLeadIns, leadInJoiner, messageStream, paceOf, paragraphsOf, sleep, toMessages, typingHook } from './messages.js';
40
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) {
@@ -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]);
@@ -272,7 +242,7 @@ export function productsOf(turn) {
272
242
  /** 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. */
273
243
  function productLink(product) {
274
244
  const productId = pickId(product);
275
- 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] : []), store: product.store ?? null, storeDomain: product.storeDomain ?? null, expiresAt: null, ...(product.availability ? { availability: product.availability } : {}) };
245
+ 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] : []), store: product.store ?? null, storeDomain: product.storeDomain ?? null, expiresAt: null, ...(product.availability ? { availability: product.availability } : {}), ...(product.fulfilledBy ? { fulfilledBy: product.fulfilledBy } : {}) };
276
246
  }
277
247
  /** A host name: dot-separated labels of letters, digits and hyphens, ending in a letters-only top-level label. */
278
248
  const STORE_HOST = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
@@ -308,6 +278,7 @@ function productsOfCatalog(catalog) {
308
278
  action: productAction(catalog.merchant),
309
279
  linkSent: false,
310
280
  ...(item.availability ? { availability: item.availability } : {}),
281
+ ...(item.fulfilled_by ? { fulfilledBy: item.fulfilled_by } : {}),
311
282
  }));
312
283
  }
313
284
  /** The stores an answer presents, as the tool reads them. */
@@ -352,6 +323,9 @@ function answerOf(turn) {
352
323
  hashes: hashesOf(turn.cart, turn.carts),
353
324
  cart_building: turn.cart_building,
354
325
  choice: turn.choice ?? null,
326
+ code_request: turn.code_request ?? null,
327
+ plan: turn.plan ?? null,
328
+ search: turn.search ?? null,
355
329
  envelope: turn.raw,
356
330
  };
357
331
  }
@@ -419,6 +393,13 @@ const CARD_ASK = /\b(?:add|save|link|enter|attach)\s+(?:a|your|the)\s+(?:(?:paym
419
393
  * which `askingForNamedStep` replaces when Agentcard names a step that comes first. Every message after it waits behind
420
394
  * it, so the shopper reads them in order; a message before it goes out at once.
421
395
  */
396
+ /**
397
+ * Agentcard's messages of an answer, as written, that the shopper read in full through the early reply (onWrittenText)
398
+ * before the call ended, by the answer they belong to. `turn.replyAsWritten` hands them back for the turn's last answer and
399
+ * `turn.say` of them sends nothing, so the app shows no second reply and its model is not asked to word one
400
+ * (chat.opencommerce.sh, 2026-10-29: after "i found 16 more hotels" the model wrote a second recommendation of the same hotels).
401
+ */
402
+ const readAsWritten = new WeakMap();
422
403
  function waitsForAnswer(message) {
423
404
  return CARD_ASK.test(message) || new RegExp(URL_IN_TEXT.source, 'i').test(message);
424
405
  }
@@ -771,6 +752,7 @@ export function buyTool(options) {
771
752
  throw new TypeError('Pass threadId as your own id for the chat or thread, 1 to 200 characters, or leave it out.');
772
753
  }
773
754
  const threadId = o.threadId?.trim();
755
+ checkStoreWait(o);
774
756
  /** Whether a message is this tool's to read (`messageInChat`): its chat's purchase, or news about the shopper alone. */
775
757
  const inThisThread = (message) => messageInChat(message, threadId);
776
758
  if (o.sendLink !== undefined && typeof o.sendLink !== 'function') {
@@ -788,7 +770,7 @@ export function buyTool(options) {
788
770
  if (o.stream !== undefined && o.stream !== false)
789
771
  throw new TypeError('Pass stream as false to wait for each whole answer, or leave it out.');
790
772
  const onProgress = o.onProgress;
791
- for (const key of ['onStatus', 'onFound', 'onAvailability']) {
773
+ for (const key of ['onStatus', 'onFound', 'onAvailability', 'onMerchantStatus']) {
792
774
  if (o[key] !== undefined && typeof o[key] !== 'function')
793
775
  throw new TypeError(`Pass ${key} as a function, or leave it out.`);
794
776
  }
@@ -844,6 +826,9 @@ export function buyTool(options) {
844
826
  const { onWorking, onReaction } = ackRoutes(o.onWorking, o.onReaction);
845
827
  /** Calls running now, when the last one ended, and the template the instant reply used last. */
846
828
  let running = 0;
829
+ // When the newest call of this tool started: a late reply on the conversation answers the newest shopper message, so a call
830
+ // that a newer one followed no longer owns it.
831
+ let newestCallAt = 0;
847
832
  let lastEndedAt = Number.NEGATIVE_INFINITY;
848
833
  let lastTemplate = -1;
849
834
  /** Counts the model's turns: the calls of one turn share its number. */
@@ -866,6 +851,7 @@ export function buyTool(options) {
866
851
  onStatus: o.onStatus,
867
852
  onFound: o.onFound,
868
853
  onAvailability: o.onAvailability,
854
+ onMerchantStatus: o.onMerchantStatus,
869
855
  onProducts: o.onProducts,
870
856
  onText: o.onText,
871
857
  texting: textingByDefault,
@@ -942,6 +928,9 @@ export function buyTool(options) {
942
928
  let lastAnswerProducts = [];
943
929
  /** Every product card of the last answer's call the shopper can tap by name, each once (the answer's and the streamed). */
944
930
  let lastTappable = [];
931
+ /** Every product card this conversation's answers showed, newest first, each once: a tap on an older card reads these. */
932
+ let shownBefore = [];
933
+ let shownIn = null;
945
934
  let lastAnswerStores = [];
946
935
  let previousTurn = null;
947
936
  /** 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. */
@@ -1278,7 +1267,8 @@ export function buyTool(options) {
1278
1267
  * picks one first. A placement another attempt is still finishing keeps the
1279
1268
  * reading going and ends as `placing`, never as the cart. A minute without
1280
1269
  * any of them returns Agentcard's own answer, or what the last read showed
1281
- * while the turn still ran.
1270
+ * while the turn still ran. A turn that ended with a reply of its own
1271
+ * (`latest_reply`) answers with that reply and the products it showed.
1282
1272
  */
1283
1273
  const awaitCart = async (base, conversationId, since) => {
1284
1274
  const shown = new Set(base.hashes);
@@ -1313,13 +1303,18 @@ export function buyTool(options) {
1313
1303
  // conversation and it shows one cart. With carts at two stores `confirm: true` still names the
1314
1304
  // cart an earlier answer showed, so none is offered: the shopper's pick goes out as an ask.
1315
1305
  const one = conversation.carts.length === 1 ? newest : null;
1306
+ // The reply the turn wrote after its early answer is what the shopper waited for: it goes out in place of the
1307
+ // tool's own line, with the products it showed (conv_054988b998634b16ab4a91d9, 2026-10-30: a stop message
1308
+ // written while the shopper read "one moment" was never shown, and the model was told to call again).
1309
+ const late = conversation.turn_in_progress || newestCallAt > since ? null : conversation.latest_reply;
1316
1310
  const answer = {
1317
1311
  ...base,
1318
- reply: one ? CART_READY : newest ? CHOOSE_CART : ANSWER_LATE,
1312
+ reply: late ? late.text : one ? CART_READY : newest ? CHOOSE_CART : ANSWER_LATE,
1313
+ ...(late?.catalog ? { products: productsOfCatalog(late.catalog) } : {}),
1319
1314
  cart: one,
1320
1315
  carts: conversation.carts,
1321
1316
  hashes: conversation.carts.map((c) => c.hash),
1322
- error_code: newest ? null : base.error_code,
1317
+ error_code: newest || late ? null : base.error_code,
1323
1318
  // A read replaced the answer the step was named for, and names no step of its own: the reply asks.
1324
1319
  next_action: null,
1325
1320
  // The early answer judged the pick before the turn finished adding it.
@@ -1818,6 +1813,9 @@ export function buyTool(options) {
1818
1813
  let cardsOut = false;
1819
1814
  /** The product cards this call's searches sent out as they answered, which the shopper can tap whatever the answer lists. */
1820
1815
  const streamedCards = [];
1816
+ /** Whether Agentcard said the reply is written (`reply_written`), and the cards that went out after it: a store that answered late. */
1817
+ let replyWritten = false;
1818
+ const lateCards = [];
1821
1819
  let heldCount = null;
1822
1820
  /** The card each catalog id of this call went out as (its `productId`), and the cards still `checking`, by conversation. */
1823
1821
  const cardOfId = new Map();
@@ -1934,13 +1932,19 @@ export function buyTool(options) {
1934
1932
  // The products past the cards are read after the cards went out, so the first card never waits for them.
1935
1933
  const keepMore = () => { if (t.onFound && early)
1936
1934
  moreFound = { conversationId: update.conversationId, links: productsOfCatalog({ ...update.catalog, items: update.more }).map(productLink) }; };
1937
- if (!early)
1935
+ if (!early) {
1936
+ // Without early cards nothing goes out here, but a store that answered after the reply still joins the result.
1937
+ if (replyWritten)
1938
+ lateCards.push(...productsOfCatalog(update.catalog));
1938
1939
  return;
1940
+ }
1939
1941
  // Products arrived before the instant reply was due: they are the reply.
1940
1942
  ack?.cancel();
1941
1943
  await t.ackOut;
1942
1944
  // The turn's own search presents its products, as a reply that lists them does.
1943
- await deliverPagesInTurn(earlyPages(update.conversationId, update.asOf, withAnswers(productsOfCatalog(update.catalog))), { turn: callTurn, presented: true, owner: t }, send);
1945
+ const out = await deliverPagesInTurn(earlyPages(update.conversationId, update.asOf, withAnswers(productsOfCatalog(update.catalog))), { turn: callTurn, presented: true, owner: t }, send);
1946
+ if (replyWritten)
1947
+ lateCards.push(...out.products);
1944
1948
  // A place shown `checking`, on a card or behind "show more", waits for the live search's word on it.
1945
1949
  noteCards([...productsOfCatalog(update.catalog), ...productsOfCatalog({ ...update.catalog, items: update.more })], update.conversationId);
1946
1950
  streamedCards.push(...productsOfCatalog(update.catalog));
@@ -1961,6 +1965,18 @@ export function buyTool(options) {
1961
1965
  },
1962
1966
  }
1963
1967
  : {}),
1968
+ ...(t.onMerchantStatus
1969
+ ? {
1970
+ onMerchantStatus: async (update) => {
1971
+ try {
1972
+ await t.onMerchantStatus(update);
1973
+ }
1974
+ catch {
1975
+ // The app's display failing never fails the turn.
1976
+ }
1977
+ },
1978
+ }
1979
+ : {}),
1964
1980
  ...(t.onText || writtenText
1965
1981
  ? {
1966
1982
  onSegment: async ({ text, conversationId }) => {
@@ -1972,10 +1988,11 @@ export function buyTool(options) {
1972
1988
  },
1973
1989
  }
1974
1990
  : {}),
1975
- ...(!t.onText && writtenText
1991
+ ...(early || t.onProducts
1976
1992
  ? {
1977
1993
  onReplyWritten: async () => {
1978
- if (speak)
1994
+ replyWritten = true;
1995
+ if (speak || !writtenText)
1979
1996
  return;
1980
1997
  speak = writtenText;
1981
1998
  for (const message of unsaid.splice(0))
@@ -2021,10 +2038,15 @@ export function buyTool(options) {
2021
2038
  // Every request names the tool's thread, so Agentcard continues this chat's purchase and no other.
2022
2039
  if (threadId)
2023
2040
  sent = { ...sent, thread_id: threadId };
2041
+ // Every request names how long the reply waits for stores, and what happens to a store that answers after it.
2042
+ sent = { ...sent, ...storeWaitFields(t.storeWait ?? {}, o) };
2024
2043
  if (plan && 'local' in plan) {
2025
2044
  // Nothing went to Agentcard: the answer is where the purchase stands.
2026
2045
  }
2027
2046
  else {
2047
+ // This call now owns the conversation's newest reply: an earlier call still waiting for its late reply gives it up.
2048
+ // A call that threw on its input, or answered from where the purchase stands, sent nothing and takes none.
2049
+ newestCallAt = Math.max(newestCallAt, startedAt);
2028
2050
  try {
2029
2051
  turn = await client.buy(sent, handlers);
2030
2052
  }
@@ -2119,8 +2141,14 @@ export function buyTool(options) {
2119
2141
  }
2120
2142
  }
2121
2143
  const read = choiceShown && !!turn && (settled.reply === turn.reply ? readEvery(told, turn.messages) : replaced && readEvery(told, paragraphsOf(settled.reply)));
2144
+ // The shopper read the whole answer before the call ended, the late source's message included: the turn's reply is what
2145
+ // they read, and nothing is worded again (chat.opencommerce.sh, 2026-10-29: after "i found 16 more hotels" the model
2146
+ // wrote a second recommendation of the same hotels).
2122
2147
  const answer = withMessages(settled, turn);
2123
- const delivered = await deliver(answered.size > 0 ? { ...answer, products: withAnswers(answer.products) } : answer, { turn: callTurn, presented: turn?.catalog?.presented === true, owner: t }, send);
2148
+ // A late reply read from the conversation presents the products of its own catalog, which the early answer never held.
2149
+ const late = settled.envelope?.latest_reply;
2150
+ const presented = turn?.catalog?.presented === true || (late?.text === settled.reply && late?.catalog?.presented === true);
2151
+ const delivered = await deliver(answered.size > 0 ? { ...answer, products: withAnswers(answer.products) } : answer, { turn: callTurn, presented, owner: t }, send);
2124
2152
  // A count that came before any card (find mode puts its cards in the answer) goes out once the answer's cards did.
2125
2153
  if (heldCount && (delivered.products?.length ?? 0) > 0) {
2126
2154
  const held = heldCount;
@@ -2145,11 +2173,15 @@ export function buyTool(options) {
2145
2173
  : null;
2146
2174
  const result = {
2147
2175
  ...delivered,
2176
+ // The cards of a store that answered after the reply join the answer's, when the answer does not list them already.
2177
+ ...(lateCards.length > 0 ? { products: withLateCards(delivered.products, lateCards) } : {}),
2148
2178
  ...(t.ackDelivered ? { shopper_already_told_looking: true } : {}),
2149
2179
  ...(read ? { shopper_already_read_reply: true } : {}),
2150
2180
  ...(confirmSent ? { confirm_sent: true } : {}),
2151
2181
  ...(yesCarried ? { yes_carried: yesCarried } : {}),
2152
2182
  };
2183
+ if (read && speak && speak === writtenText)
2184
+ readAsWritten.set(result, told.join('\n\n'));
2153
2185
  lastNextAction = result.next_action;
2154
2186
  lastReply = result.reply;
2155
2187
  // A refusal reports no carts, so only a turn's answer says how many wait, and whether a checkout just ended with none.
@@ -2199,6 +2231,13 @@ export function buyTool(options) {
2199
2231
  }
2200
2232
  lastAnswerProducts = shown.map((product) => ({ productId: pickId(product), name: product.name }));
2201
2233
  lastTappable = tappable.map((product) => ({ productId: pickId(product), name: product.name }));
2234
+ // A new conversation starts the cards shown before afresh; each card keeps its newest showing, up to SHOWN_BEFORE_MAX.
2235
+ if (result.conversation_id && result.conversation_id !== shownIn)
2236
+ shownBefore = [];
2237
+ if (result.conversation_id)
2238
+ shownIn = result.conversation_id;
2239
+ const fresh = new Set(lastTappable.map((product) => product.productId ?? `name:${product.name}`));
2240
+ shownBefore = [...lastTappable, ...shownBefore.filter((product) => !fresh.has(product.productId ?? `name:${product.name}`))].slice(0, SHOWN_BEFORE_MAX);
2202
2241
  lastAnswerStores = storesShown.map((store) => ({ storeId: store.id, name: store.name }));
2203
2242
  t.results?.push(result);
2204
2243
  if (t.onResult) {
@@ -2292,7 +2331,7 @@ export function buyTool(options) {
2292
2331
  const own = options ?? {};
2293
2332
  if (typeof own !== 'object')
2294
2333
  throw new TypeError('Pass the turn options as an object, or leave them out.');
2295
- for (const key of ['sendLink', 'onWorking', 'onReaction', 'onResult', 'onProgress', 'onStatus', 'onFound', 'onAvailability', 'onItem', 'onMessage', 'onProducts', 'onText', 'onCart']) {
2334
+ for (const key of ['sendLink', 'onWorking', 'onReaction', 'onResult', 'onProgress', 'onStatus', 'onFound', 'onAvailability', 'onMerchantStatus', 'onItem', 'onMessage', 'onProducts', 'onText', 'onCart']) {
2296
2335
  if (own[key] !== undefined && typeof own[key] !== 'function')
2297
2336
  throw new TypeError(`Pass ${key} to the turn as a function, or leave it out.`);
2298
2337
  }
@@ -2310,6 +2349,7 @@ export function buyTool(options) {
2310
2349
  if (own.textingStyle !== undefined && typeof own.textingStyle !== 'boolean')
2311
2350
  throw new TypeError('Pass textingStyle to the turn as true or false, or leave it out.');
2312
2351
  const texting = own.textingStyle ?? textingByDefault;
2352
+ checkStoreWait(own, ' to the turn');
2313
2353
  const hearText = own.onText ?? o.onText;
2314
2354
  // Messages are spaced only for an app that hears each one as it goes out.
2315
2355
  const pauseOf = paceOf(own.pace);
@@ -2375,6 +2415,7 @@ export function buyTool(options) {
2375
2415
  onStatus: own.onStatus ?? o.onStatus,
2376
2416
  onFound: own.onFound ?? o.onFound,
2377
2417
  onAvailability: own.onAvailability ?? o.onAvailability,
2418
+ onMerchantStatus: own.onMerchantStatus ?? o.onMerchantStatus,
2378
2419
  onProducts: own.onProducts ?? o.onProducts,
2379
2420
  // Agentcard's message takes its place in the turn's sequence, after the items before it, and goes to onText alone, so
2380
2421
  // an app that passes onItem too hears it once.
@@ -2386,6 +2427,7 @@ export function buyTool(options) {
2386
2427
  ? (text) => feed.push({ kind: 'text', text }, () => (onItem ? onItem({ kind: 'text', text }) : onMessage?.(text)))
2387
2428
  : undefined,
2388
2429
  texting,
2430
+ storeWait: { storeWaitMs: own.storeWaitMs, lateResults: own.lateResults },
2389
2431
  onCart: own.onCart ?? o.onCart,
2390
2432
  tell: onItem || baseOnWorking ? (text) => feed.push({ kind: 'text', text }, () => (onItem ? onItem({ kind: 'text', text }) : baseOnWorking?.(text))) : undefined,
2391
2433
  links: [],
@@ -2478,13 +2520,17 @@ export function buyTool(options) {
2478
2520
  const results = state.results;
2479
2521
  // A pick or an answer goes straight only after a turn that ended on Agentcard's answer, and never while a call still runs.
2480
2522
  const answered = previousTurn !== null && (previousTurn.results?.length ?? 0) > 0;
2523
+ // A tap names its card by the id its link carried, whatever answer showed it: the pick goes straight with that id.
2524
+ const tappedId = typeof options?.tapped?.productId === 'string' && options.tapped.productId.trim() ? options.tapped.productId.trim() : null;
2481
2525
  // A `nextStep` turn's call is the same whatever the model writes (`stepPlan` reads where the purchase stands), so it goes
2482
2526
  // straight too: with `replyAsWritten`, a step whose answer is a link, such as the approval after a saved card, needs no model.
2483
2527
  const direct = !directOn || running > 0
2484
2528
  ? null
2485
2529
  : event
2486
2530
  ? { input: { ask: NEXT_STEP_ASK, confirm: false, new_order: false, product_id: null, store_id: null }, reason: 'next_step' }
2487
- : directCall(String(shopperText ?? ''), { answered, nextAction: lastNextAction, asked: lastReply, products: lastAnswerProducts, tappable: lastTappable, stores: lastAnswerStores });
2531
+ : tappedId && String(shopperText ?? '').trim()
2532
+ ? { input: { ask: String(shopperText).trim(), confirm: false, new_order: false, product_id: tappedId, store_id: null }, reason: 'pick' }
2533
+ : directCall(String(shopperText ?? ''), { answered, nextAction: lastNextAction, asked: lastReply, products: lastAnswerProducts, tappable: lastTappable, shownBefore, stores: lastAnswerStores });
2488
2534
  // Agentcard writes the line for a search the turn sends straight. A turn the app's model runs gets the model's own line,
2489
2535
  // or the wait line below.
2490
2536
  state.agentcardAcks = (ask) => direct !== null && direct.reason === 'product_ask' && ask.trim() === direct.input.ask.trim();
@@ -2595,6 +2641,10 @@ export function buyTool(options) {
2595
2641
  /** Says a whole text as the turn's messages, after every item before it (`say()`). */
2596
2642
  const sayWhole = (whole) => {
2597
2643
  writesThroughTurn = true;
2644
+ // The reply the shopper already read as Agentcard wrote it (`readAsWritten`), of the turn's last answer: saying it again sends nothing.
2645
+ const read = results.length > 0 ? readAsWritten.get(results[results.length - 1]) : undefined;
2646
+ if (read && whole === read)
2647
+ return releaseForReply();
2598
2648
  firstWords(whole);
2599
2649
  if (state.modelTurn && /\S/.test(String(whole ?? '')))
2600
2650
  state.modelTurn.spoke = true;
@@ -2678,7 +2728,7 @@ export function buyTool(options) {
2678
2728
  results: { get: () => [...results], enumerable: false },
2679
2729
  direct: { value: direct, enumerable: false },
2680
2730
  stopped: { get: () => state.stopped === true, enumerable: false },
2681
- replyAsWritten: { get: () => (written && results.length > 0 && !state.stopped ? replyAsWritten(results[results.length - 1], { textingStyle: texting }) : null), enumerable: false },
2731
+ replyAsWritten: { get: () => (written && results.length > 0 && !state.stopped ? readAsWritten.get(results[results.length - 1]) ?? replyAsWritten(results[results.length - 1], { textingStyle: texting }) : null), enumerable: false },
2682
2732
  items: { get: () => feed.items.map((item) => (item.kind === 'link' ? { kind: 'link', link: { ...item.link } } : { ...item })), enumerable: false },
2683
2733
  // 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.
2684
2734
  // A step link held for this message goes out right after its last paragraph, once the stream ends.
package/dist/client.d.ts CHANGED
@@ -1,3 +1,16 @@
1
+ /**
2
+ * The client the buy tool calls Agentcard with. A company builds one from its
3
+ * client credentials, and every shopper's tool shares the one access token it
4
+ * exchanges them for; or a tool runs on one shopper's own access token. The
5
+ * global fetch is the only transport, so the package keeps no dependencies.
6
+ *
7
+ * The answer fields and the error codes follow the buy module of
8
+ * @agent-cards/sdk (packages/checkout/src/buy.ts: turnOf, buyError), and the
9
+ * token cache follows its client (packages/checkout/src/client.ts accessToken).
10
+ */
11
+ import { type BuyPlan } from './plan.js';
12
+ import { type BuyMerchantStatusUpdate, type BuySearchStatus } from './search-status.js';
13
+ import { type BuyFulfilledBy } from './fulfilled-by.js';
1
14
  import { type AgentcardListener, type ListenOptions } from './listen.js';
2
15
  import { type ShopperMessage } from './shopper-message.js';
3
16
  /** The fetch the client calls: Node 18 and later have one built in. */
@@ -151,6 +164,11 @@ export interface BuyStreamHandlers {
151
164
  onReplyWritten?: (update: {
152
165
  conversationId: string | null;
153
166
  }) => void | Promise<void>;
167
+ /**
168
+ * Each store the turn searches, the moment its status changes: `searching` when its search starts, then what it found. To
169
+ * follow one store, keep the updates `isMerchantStatusAt(update, 'walmart.com')` accepts. The answer's `search` holds every store.
170
+ */
171
+ onMerchantStatus?: (update: BuyMerchantStatusUpdate) => void | Promise<void>;
154
172
  }
155
173
  export declare const DEFAULT_BASE_URL = "https://api.agentcard.sh";
156
174
  /** The wait for one purchase turn: past Agentcard's own 110-second budget, so a slow turn answers `turn_timeout` rather than aborting here. */
@@ -241,6 +259,16 @@ export interface BuyRequest {
241
259
  * another thread is refused (409 `thread_mismatch`). Leave it out for an app with one chat per shopper.
242
260
  */
243
261
  thread_id?: string;
262
+ /**
263
+ * How long, in milliseconds, the reply waits for store searches before Agentcard writes it. Left out, Agentcard keeps
264
+ * its own wait. `buyTool({ storeWaitMs })` sends it on every request.
265
+ */
266
+ store_wait_ms?: number;
267
+ /**
268
+ * What happens to a store that answers after the reply: `stream` sends its cards after the reply in the same streamed
269
+ * turn, before the answer; `hold` keeps them out of the turn. Left out, Agentcard does as it does today.
270
+ */
271
+ late_results?: 'stream' | 'hold';
244
272
  /** Drop the cart on the table and start a new order. */
245
273
  new_order?: true;
246
274
  /**
@@ -325,6 +353,8 @@ export interface BuyCartLine {
325
353
  ref: string | null;
326
354
  /** The product's page at its store, as an http or https address, or null when Agentcard sent none. */
327
355
  url: string | null;
356
+ /** Who sells or books the line, where that is not the store the cart names (`via StubHub`); absent when Agentcard named none. */
357
+ fulfilled_by?: BuyFulfilledBy;
328
358
  }
329
359
  /**
330
360
  * A cart as Agentcard answers it. `totalCents` is the all-in figure the shopper sees; `hash` is what a confirm names.
@@ -335,6 +365,25 @@ export interface BuyCartFeeLine {
335
365
  label: string;
336
366
  amountCents: number;
337
367
  }
368
+ /**
369
+ * A discount or a coupon inside a cart's total, as the store labels it. `amountCents` is negative. `kind` is `discount`
370
+ * for one the store applied on its own, such as a promotion, and `coupon` for a code entered at the store's checkout,
371
+ * which `code` names; `code` is null for a discount.
372
+ */
373
+ export interface BuyCartDiscount {
374
+ label: string;
375
+ amountCents: number;
376
+ kind: 'discount' | 'coupon';
377
+ code: string | null;
378
+ }
379
+ /**
380
+ * Why a cart's card is held for `amountCents`: `flight` and `hotel` for the hold a booking places on the card, and
381
+ * `checkout_estimate` for the headroom above a store's total that covers tax and shipping and is released unused.
382
+ */
383
+ export interface BuyCartHold {
384
+ amountCents: number;
385
+ reason: 'flight' | 'hotel' | 'checkout_estimate';
386
+ }
338
387
  export interface BuyCart {
339
388
  merchant: string;
340
389
  merchant_name: string;
@@ -346,6 +395,10 @@ export interface BuyCart {
346
395
  serviceFeesStatus?: 'known' | 'pending' | 'unknown';
347
396
  /** The store's own fee and tax lines inside `serviceFeesCents`, or null when the store itemized none. */
348
397
  merchantFeeLines: BuyCartFeeLine[] | null;
398
+ /** The discounts and coupons inside `totalCents`, which `merchantFeeLines` also lists. Empty when there are none, and from an older API. */
399
+ discounts?: BuyCartDiscount[];
400
+ /** True while the store's checkout builds this cart again, so `totalCents` is not its price yet. False from an older API. */
401
+ pricing?: boolean;
349
402
  tipCents: number;
350
403
  totalCents: number;
351
404
  /**
@@ -356,6 +409,8 @@ export interface BuyCart {
356
409
  /** The ISO 4217 code the store prices the cart in, or null when Agentcard sent none. */
357
410
  currency: string | null;
358
411
  approvedCeilingCents: number | null;
412
+ /** Why the card is held for `approvedCeilingCents`, or null when nothing is held or the API is older. */
413
+ hold?: BuyCartHold | null;
359
414
  hash: string;
360
415
  }
361
416
  /** A product a search found. `priceCents` is in the minor unit of `currency`, the store's ISO 4217 code. */
@@ -392,6 +447,8 @@ export interface BuyCatalogItem {
392
447
  url: string | null;
393
448
  /** Whether a place has room at the asked time (BuyAvailability). Absent on a product with no such question. */
394
449
  availability?: BuyAvailability;
450
+ /** Who sells or books the product, where that is not the store the card names (`booked with OpenTable`); absent when Agentcard named none. */
451
+ fulfilled_by?: BuyFulfilledBy;
395
452
  }
396
453
  /**
397
454
  * The products the last search found. `presented` is true when this answer's reply presents them: the
@@ -489,6 +546,19 @@ export interface BuyPaymentSource {
489
546
  * (the code a booking texted them), then `approve` (the approval link). A value this version does
490
547
  * not know reads as null.
491
548
  */
549
+ /**
550
+ * A store checkout waiting for the one-time code Stripe Link or Shop Pay sent the shopper: which wallet, whether it was a
551
+ * text or an email, and where it went, masked to a phone's last two digits ("••21") or an email's first letter and domain
552
+ * ("j•••@example.com"). The shopper sends the code as their next message, as `next_action: 'verification_code'` says.
553
+ */
554
+ export interface BuyCodeRequest {
555
+ store: string;
556
+ wallet: 'link' | 'shop_pay';
557
+ channel: 'text' | 'email';
558
+ destination: string | null;
559
+ }
560
+ /** An answer's code request, from its `browser_checkout` when that checkout waits on a wallet's code; null otherwise. */
561
+ export declare function codeRequestOf(value: unknown): BuyCodeRequest | null;
492
562
  export type BuyNextAction = 'shipping_details' | 'guest_details' | 'store_login' | 'add_card' | 'confirm' | 'verification_code' | 'approve';
493
563
  /**
494
564
  * A cart the store's checkout is still building after an answer went out, at `store`, since `started_at`: its cart and
@@ -517,6 +587,8 @@ export interface BuyTurn {
517
587
  approval_url: string | null;
518
588
  charge_status: string | null;
519
589
  error_code: string | null;
590
+ /** The party of the purchase this answer says failed, such as StubHub for a ticket, so an app names who stopped it; null when none is named. */
591
+ fulfilled_by: BuyFulfilledBy | null;
520
592
  authorization_id: string | null;
521
593
  merchant_total_cents: number | null;
522
594
  merchant_currency: string | null;
@@ -527,6 +599,8 @@ export interface BuyTurn {
527
599
  approval_in_progress: boolean | null;
528
600
  /** The one step the shopper takes next, or null when nothing waits on them or Agentcard named none. */
529
601
  next_action: BuyNextAction | null;
602
+ /** The wallet's code a store checkout waits for, with where it went, or null when none waits (BuyCodeRequest). */
603
+ code_request: BuyCodeRequest | null;
530
604
  /** The page where the shopper signs in to a store's own account before Agentcard can buy there. */
531
605
  store_login_url: string | null;
532
606
  store_login_expires_at: string | null;
@@ -542,6 +616,10 @@ export interface BuyTurn {
542
616
  cart_building: BuyCartBuilding | null;
543
617
  /** The choice the reply asks the shopper to make, each group with every value; null when it asks none, and on an older API. */
544
618
  choice: BuyChoice | null;
619
+ /** The checkout plan this answer's yes started or moved, with each item's state; null when none, and on an older API. */
620
+ plan: BuyPlan | null;
621
+ /** Every store the turn searched and where each stands; null for a turn that searched none, and on an older API. */
622
+ search: BuySearchStatus | null;
545
623
  /** The answer as Agentcard sent it. */
546
624
  raw: Record<string, unknown>;
547
625
  }
@@ -581,8 +659,22 @@ export interface BuyConversation {
581
659
  last_checkout: BuyLastCheckout | null;
582
660
  orders: BuyOrder[];
583
661
  carts: BuyCart[];
662
+ /**
663
+ * The reply Agentcard wrote since the shopper's last message, once no turn runs: the answer a call that ended with
664
+ * `turn_timeout` never got. Null while a turn runs, when no reply was written since, and on an older API.
665
+ */
666
+ latest_reply: BuyLatestReply | null;
584
667
  raw: Record<string, unknown>;
585
668
  }
669
+ /** A reply a conversation read hands back: its text, its messages, and the products it showed. */
670
+ export interface BuyLatestReply {
671
+ message_id: string;
672
+ text: string;
673
+ messages: string[];
674
+ /** The products the reply showed, when the call that asked ended before it was written; null otherwise. */
675
+ catalog: BuyCatalog | null;
676
+ created_at: string | null;
677
+ }
586
678
  /** A Vault link: the page where a shopper connects and adds a card. */
587
679
  export interface VaultLink {
588
680
  id: string;