@agent-cards/commerce-sdk 0.18.3 → 0.18.5

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/README.md CHANGED
@@ -328,6 +328,16 @@ for await (const stream of runner) {
328
328
  - A step link, the `vault`, `approval` or `store_login` page the shopper opens to go on, comes after the message that asks for it: the turn holds it until your model's next message stream ends (or `turn.say` is called), then sends it right after that message, so the shopper reads "add your card to place the order" and then taps. Product and store cards go out the moment the tool hands them over. A held link also goes out when the turn's next call starts, or after 20 seconds, so a model that writes nothing still sends it. `turn.say` and the stream's `end` resolve once the held links went out too, and reject when one failed to send, so your app never answers without a link the shopper needs; a link that failed to go out at the next call or the 20 seconds rejects the next reply that ends.
329
329
  - Without `onItem`, pass `onMessage` beside `sendLink`, and write the model's text through `turn.messageStream()`: `onMessage` hears each message, paced, and `sendLink` hears each step link after the message that asks for it. An app that streams the model through the package's own `messageStream` gets each link the moment the tool hands it over, before the words about it.
330
330
 
331
+ ## Keep one purchase per chat
332
+
333
+ A shopper who writes to your agent in several chats at once, such as group chats or threads, would otherwise continue one purchase from all of them. Build one tool per shopper and chat, and name the chat with `threadId`, your own id for it. Agentcard then keeps a purchase per chat: each chat's carts, questions and approvals stay in that chat, while the shopper keeps one Vault card, one store sign-in and one address book.
334
+
335
+ ```ts
336
+ const buy = buyTool({ agentcard, externalUserId: shopper.id, threadId: chat.id }); // one per shopper and chat
337
+ ```
338
+
339
+ A message about one chat's purchase carries that chat as `threadId`, so post it into that chat. One about the shopper alone, such as a saved card, carries `threadId: null`. A chat's tool takes a next step only for its own chat's messages and the shopper's own, and a tool built without `threadId` only for purchases opened under no thread and the shopper's own.
340
+
331
341
  ## Tell the model what Agentcard told the shopper
332
342
 
333
343
  Agentcard posts messages into the shopper's chat between turns, through `listen` or the webhook: `Your Visa ending 3132 is saved, so you're ready to check out.`, an order placed, an approval about to expire. The shopper reads them, so your model has to know them too. A model that never saw `card_added` answers "ok lets checkout" with "did you save your card?" and never calls the tool.
@@ -517,6 +527,8 @@ const buy = buyTool({
517
527
 
518
528
  When your app shows Agentcard's messages through `onText`, the model reads `shopper_already_read_reply: true` in that call's answer and adds nothing to them, as it reads `shopper_already_told_looking` after an instant reply: end the turn without a second model call, as with `replyAsWritten`, or let the model add one short line for what the reply left out. `turn.replyAsWritten` stays null for that answer, so the reply never reaches the shopper twice, and a step link the turn holds for your model's next message goes out right after Agentcard's ask instead.
519
529
 
530
+ A turn built with `replyAsWritten` and `earlyCards` that hears messages through `onMessage` or `onItem` and passes no `onText` gets the same once a reply is written while a slower source's cards are still coming, such as a second hotel supplier's: Agentcard's messages go out through that callback the moment Agentcard says the reply is written, the slower cards and their one message follow in the same call, and the answer reads `shopper_already_read_reply: true` with `turn.replyAsWritten` null.
531
+
520
532
  A stream that breaks after the call started reads the conversation for what the turn left, as a turn past Agentcard's budget does. Pass `stream: false` to wait for each whole answer, with its cards sent beside it.
521
533
 
522
534
  Code that calls the client itself streams with `agentcard.buy(body, handlers)`: `onCatalog`, `onSegment`, `onCart` and `onDelta` hear their events in the order Agentcard sent them, each once the one before it settled, and the answer waits for the last of them. `onDelta` asks Agentcard for the pieces of each message as it writes them, a draft that each `onSegment` message replaces.
@@ -296,6 +296,13 @@ export interface BuyToolOptions {
296
296
  accessToken?: string;
297
297
  /** Your own id for the shopper. Required with a company client, and left out with a shopper's own token. */
298
298
  externalUserId?: string;
299
+ /**
300
+ * Your own id for the chat or thread this tool answers in, such as a group chat's id. Agentcard keeps one open purchase
301
+ * per thread for the shopper, so two chats never share a cart, a question or an approval, and the tool reads only the
302
+ * messages about this thread's purchase and those about the shopper alone, such as a saved card. Build one tool per
303
+ * shopper and thread. Leave it out for an app with one chat per shopper.
304
+ */
305
+ threadId?: string;
299
306
  /**
300
307
  * Delivers a link to the shopper. Each call waits for it, so the shopper
301
308
  * holds the link before the model answers. A `sendLink` that throws on a
package/dist/buy-tool.js CHANGED
@@ -37,7 +37,7 @@ 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
39
  import { joinLeadIns, leadInJoiner, messageStream, paceOf, paragraphsOf, sleep, toMessages, typingHook } from './messages.js';
40
- import { isStale } from './shopper-message.js';
40
+ import { isStale, messageInChat } from './shopper-message.js';
41
41
  import { actionLinkId, isActionLink } from './action-links.js';
42
42
  import { ACK_REACTION, ackAt, answersCheckout, answersQuestion, asksForSimilar, holdsBack, instantAck as instantAckOf, isNewRequest, isPickText, keepsYes, namesStore, nextTemplate, readsAsAckLine, slowStepLine } from './ack.js';
43
43
  /** The action a product of `merchant` takes (BuyToolProductAction). */
@@ -81,7 +81,7 @@ export function handedNote(answer) {
81
81
  }
82
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. */
83
83
  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. */
84
+ /** 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
85
  const offeredAt = new WeakMap();
86
86
  /** How many times `needle` appears in `haystack`, none overlapping. */
87
87
  function occurrences(haystack, needle) {
@@ -767,6 +767,12 @@ export function buyTool(options) {
767
767
  if (!client.company && externalUserId !== undefined) {
768
768
  throw new TypeError("A shopper's own access token already names the shopper. Leave externalUserId out.");
769
769
  }
770
+ if (o.threadId !== undefined && (typeof o.threadId !== 'string' || o.threadId.trim() === '' || o.threadId.trim().length > 200)) {
771
+ throw new TypeError('Pass threadId as your own id for the chat or thread, 1 to 200 characters, or leave it out.');
772
+ }
773
+ const threadId = o.threadId?.trim();
774
+ /** Whether a message is this tool's to read (`messageInChat`): its chat's purchase, or news about the shopper alone. */
775
+ const inThisThread = (message) => messageInChat(message, threadId);
770
776
  if (o.sendLink !== undefined && typeof o.sendLink !== 'function') {
771
777
  throw new TypeError('Pass sendLink as a function that delivers a link to the shopper.');
772
778
  }
@@ -1750,6 +1756,16 @@ export function buyTool(options) {
1750
1756
  let refusal;
1751
1757
  /** 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
1758
  const told = [];
1759
+ /**
1760
+ * Where this call's messages from Agentcard go as they stream: the app's onText, or, for an app that shows the reply as
1761
+ * written, its onMessage once Agentcard says the reply is written (`reply_written`). Until then such an app's
1762
+ * messages are kept in `unsaid`, and a call whose stream never says so shows none of them: the app shows the answer.
1763
+ */
1764
+ let speak = t.onText;
1765
+ // Only where the cards went out as they streamed (earlyCards): a reply shown earlier than its cards would ask the shopper
1766
+ // to choose from hotels they cannot see yet.
1767
+ const writtenText = early ? t.onWrittenText : undefined;
1768
+ const unsaid = [];
1753
1769
  /**
1754
1770
  * The streamed messages that wait for the answer: the first that asks for a card or points at a link (waitsForAnswer),
1755
1771
  * and every message after it, so the shopper reads them in the order Agentcard wrote them.
@@ -1762,7 +1778,7 @@ export function buyTool(options) {
1762
1778
  let heldTexts = [];
1763
1779
  /** Shows a message through onText, its lists as bullets (messageForShopper); `told` keeps each of Agentcard's messages in it as written. */
1764
1780
  const showMessage = async (message, texts, conversationId) => {
1765
- await t.onText(messageForShopper(message, leadIn, t.texting), { conversationId });
1781
+ await speak(messageForShopper(message, leadIn, t.texting), { conversationId });
1766
1782
  leadIn = leadsIntoList(message);
1767
1783
  told.push(...texts);
1768
1784
  };
@@ -1863,6 +1879,23 @@ export function buyTool(options) {
1863
1879
  // The app's display failing never fails the call.
1864
1880
  }
1865
1881
  };
1882
+ /** One of Agentcard's messages as it streams, to the app through `speak`. */
1883
+ const streamText = async (text, conversationId) => {
1884
+ // A message that lands before the instant reply was due is the reply; one after it follows it.
1885
+ ack?.cancel();
1886
+ await t.ackOut;
1887
+ // 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.
1888
+ if (waiting.length > 0 || waitsForAnswer(text)) {
1889
+ waiting.push({ text, conversationId });
1890
+ return;
1891
+ }
1892
+ try {
1893
+ await showText(text, conversationId);
1894
+ }
1895
+ catch {
1896
+ // A message the app failed to show was never read, so the model words the answer.
1897
+ }
1898
+ };
1866
1899
  // Streamed: the stages go to onProgress, a search's products to onProducts and its store pages out the moment it
1867
1900
  // answers, and each message and cart to the app as Agentcard writes it.
1868
1901
  const handlers = streams
@@ -1928,23 +1961,25 @@ 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
+ ...(!t.onText && writtenText
1976
+ ? {
1977
+ onReplyWritten: async () => {
1978
+ if (speak)
1979
+ return;
1980
+ speak = writtenText;
1981
+ for (const message of unsaid.splice(0))
1982
+ await streamText(message.text, message.conversationId);
1948
1983
  },
1949
1984
  }
1950
1985
  : {}),
@@ -1983,6 +2018,9 @@ export function buyTool(options) {
1983
2018
  // A streamed ask for something new that the tool shows no line of Agentcard's for says so. A pick never gets one.
1984
2019
  const streamed = !!handlers && Object.values(handlers).some((handler) => typeof handler === 'function');
1985
2020
  sent = planned.ask !== undefined && !planned.pick && streamed && !agentcardAcks ? { ...planned, ack: false } : planned;
2021
+ // Every request names the tool's thread, so Agentcard continues this chat's purchase and no other.
2022
+ if (threadId)
2023
+ sent = { ...sent, thread_id: threadId };
1986
2024
  if (plan && 'local' in plan) {
1987
2025
  // Nothing went to Agentcard: the answer is where the purchase stands.
1988
2026
  }
@@ -2003,6 +2041,8 @@ export function buyTool(options) {
2003
2041
  // Only the answer of the request sent again counts toward what the shopper read.
2004
2042
  told.length = 0;
2005
2043
  waiting.length = 0;
2044
+ unsaid.length = 0;
2045
+ speak = t.onText;
2006
2046
  leads = leadInJoiner();
2007
2047
  heldTexts = [];
2008
2048
  try {
@@ -2029,7 +2069,7 @@ export function buyTool(options) {
2029
2069
  // a link the answer replaced gives way to its ask for the step Agentcard named, once, after them. All of them go out
2030
2070
  // before any link of the answer.
2031
2071
  let replaced = false;
2032
- if (waiting.length > 0 && t.onText) {
2072
+ if (waiting.length > 0 && speak) {
2033
2073
  const rewritten = !!turn && settled.reply !== turn.reply;
2034
2074
  // Paragraph for paragraph, as `told` keeps Agentcard's messages, before any lead-in joins the one after it.
2035
2075
  const kept = new Set((rewritten ? paragraphsOf(settled.reply) : []).map((message) => message.trim()));
@@ -2055,7 +2095,7 @@ export function buyTool(options) {
2055
2095
  }
2056
2096
  }
2057
2097
  }
2058
- if (t.onText) {
2098
+ if (speak) {
2059
2099
  try {
2060
2100
  await showLeftLead();
2061
2101
  }
@@ -2068,10 +2108,10 @@ export function buyTool(options) {
2068
2108
  // A reply the shopper read that asks them to choose, with the values named inside a sentence or not at all, is followed
2069
2109
  // by one more message that lists every value on its own line (choiceLines), so no choice is read buried in prose.
2070
2110
  let choiceShown = true;
2071
- if (t.onText && turn?.choice && told.length > 0 && !messagesListChoice(told, turn.choice)) {
2111
+ if (speak && turn?.choice && told.length > 0 && !messagesListChoice(told, turn.choice)) {
2072
2112
  const lines = choiceLines(turn.choice);
2073
2113
  try {
2074
- await t.onText(t.texting ? textingStyle(lines) : lines, { conversationId: turn.conversation_id || null });
2114
+ await speak(t.texting ? textingStyle(lines) : lines, { conversationId: turn.conversation_id || null });
2075
2115
  }
2076
2116
  catch {
2077
2117
  // Not shown, so the shopper has not read the whole answer: the model words it, with the choice it reads.
@@ -2235,6 +2275,8 @@ export function buyTool(options) {
2235
2275
  return null;
2236
2276
  if (client.company && message.externalUserId !== externalUserId)
2237
2277
  return null;
2278
+ if (!inThisThread(message))
2279
+ return null;
2238
2280
  if (message.eventId && stepped.has(message.eventId))
2239
2281
  return null;
2240
2282
  // A call for the shopper's own message is still running: its answer tells the model where the purchase stands.
@@ -2243,7 +2285,7 @@ export function buyTool(options) {
2243
2285
  if (message.eventId)
2244
2286
  stepped.add(message.eventId);
2245
2287
  if (client.company)
2246
- client.answered(externalUserId, message);
2288
+ client.answered(externalUserId, message, threadId);
2247
2289
  return openTurn(nextStepPrompt(message), options, message);
2248
2290
  };
2249
2291
  const openTurn = (shopperText, options, event) => {
@@ -2337,6 +2379,12 @@ export function buyTool(options) {
2337
2379
  // Agentcard's message takes its place in the turn's sequence, after the items before it, and goes to onText alone, so
2338
2380
  // an app that passes onItem too hears it once.
2339
2381
  onText: hearText ? (text, info) => feed.push({ kind: 'text', text }, () => hearText(text, info)) : undefined,
2382
+ // An app that shows the reply as written hears it the moment Agentcard says it is written, when the turn then waits for
2383
+ // a slower source's cards: the reply no longer waits for them (chat.opencommerce.sh, 2026-10-29, a hotel reply written
2384
+ // at about 21 s reached the shopper at 36.5 s, with the second supplier's hotels).
2385
+ onWrittenText: !hearText && written && (onItem || onMessage)
2386
+ ? (text) => feed.push({ kind: 'text', text }, () => (onItem ? onItem({ kind: 'text', text }) : onMessage?.(text)))
2387
+ : undefined,
2340
2388
  texting,
2341
2389
  onCart: own.onCart ?? o.onCart,
2342
2390
  tell: onItem || baseOnWorking ? (text) => feed.push({ kind: 'text', text }, () => (onItem ? onItem({ kind: 'text', text }) : baseOnWorking?.(text))) : undefined,
@@ -2504,7 +2552,7 @@ export function buyTool(options) {
2504
2552
  if (!event)
2505
2553
  previousTurn = state;
2506
2554
  // What Agentcard told the shopper that no history the model ran with holds yet.
2507
- const kept = client.company ? client.keptMessages(externalUserId) : [];
2555
+ const kept = client.company ? client.keptMessages(externalUserId, threadId).filter(inThisThread) : [];
2508
2556
  let handed = null;
2509
2557
  const shopperWords = String(shopperText ?? '');
2510
2558
  /**
@@ -2526,18 +2574,20 @@ export function buyTool(options) {
2526
2574
  // Another order's update can read the same, so only a history holding the text more often than
2527
2575
  // when this message first went in holds this message.
2528
2576
  const count = occurrences(held, JSON.stringify(text).slice(1, -1));
2529
- const before = offeredAt.get(message);
2577
+ // Each chat counts its own history: a message about the shopper alone goes in to every chat's model.
2578
+ const counts = offeredAt.get(message) ?? new Map();
2579
+ const before = counts.get(threadId ?? '');
2530
2580
  if (before !== undefined && count > before) {
2531
2581
  saved.push(message);
2532
2582
  }
2533
2583
  else {
2534
2584
  if (before === undefined)
2535
- offeredAt.set(message, count);
2585
+ offeredAt.set(message, counts.set(threadId ?? '', count));
2536
2586
  fresh.push(message);
2537
2587
  }
2538
2588
  }
2539
2589
  if (saved.length > 0)
2540
- client.forgetMessages(externalUserId, saved);
2590
+ client.forgetMessages(externalUserId, saved, threadId);
2541
2591
  handed = fresh;
2542
2592
  // A message Agentcard answered for the model to word goes in with that answer, so the model words it and calls nothing again.
2543
2593
  return turnMessages(history, fresh.map(modelText), handedAnswer === null ? shopperWords : `${shopperWords}\n\n${handedNote(handedAnswer)}`);
@@ -2613,7 +2663,7 @@ export function buyTool(options) {
2613
2663
  if (event.eventId)
2614
2664
  stepped.delete(event.eventId);
2615
2665
  if (client.company)
2616
- client.unanswered(externalUserId, event);
2666
+ client.unanswered(externalUserId, event, threadId);
2617
2667
  },
2618
2668
  enumerable: false,
2619
2669
  },
package/dist/client.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type AgentcardListener, type ListenOptions } from './listen.js';
2
- import type { ShopperMessage } from './shopper-message.js';
2
+ import { type ShopperMessage } from './shopper-message.js';
3
3
  /** The fetch the client calls: Node 18 and later have one built in. */
4
4
  export type FetchLike = (url: string, init: {
5
5
  method: string;
@@ -144,6 +144,13 @@ export interface BuyStreamHandlers {
144
144
  onDelta?: (update: BuyDeltaUpdate) => void | Promise<void>;
145
145
  /** Whether the places the turn's cards show have room, once Agentcard's live search says, after the cards it changes. */
146
146
  onAvailability?: (update: BuyAvailabilityUpdate) => void | Promise<void>;
147
+ /**
148
+ * The reply is written, after its messages, and the turn stays open only for a slower source's cards, such as a second
149
+ * hotel supplier's, and the one message that says what they added. Comes once, or not at all when nothing is still out.
150
+ */
151
+ onReplyWritten?: (update: {
152
+ conversationId: string | null;
153
+ }) => void | Promise<void>;
147
154
  }
148
155
  export declare const DEFAULT_BASE_URL = "https://api.agentcard.sh";
149
156
  /** The wait for one purchase turn: past Agentcard's own 110-second budget, so a slow turn answers `turn_timeout` rather than aborting here. */
@@ -228,6 +235,12 @@ export interface BuyRequest {
228
235
  resume?: true;
229
236
  /** The conversation a confirm of a cart hash belongs to, in place of `resume`. */
230
237
  conversation_id?: string;
238
+ /**
239
+ * Your own id for the chat or thread the shopper wrote in, such as a group chat's id (up to 200 characters). The shopper
240
+ * keeps one open purchase per thread: `resume` continues the newest one in this thread only, and a conversation_id from
241
+ * another thread is refused (409 `thread_mismatch`). Leave it out for an app with one chat per shopper.
242
+ */
243
+ thread_id?: string;
231
244
  /** Drop the cart on the table and start a new order. */
232
245
  new_order?: true;
233
246
  /**
@@ -429,6 +442,19 @@ export interface BuyChoiceOption {
429
442
  label: string;
430
443
  /** What choosing it adds to the price, in cents: 0 when free, null when the store does not say. */
431
444
  price_delta_cents: number | null;
445
+ /** On a `recipe_delivery` choice: the grocery store, when it arrives and what this way costs. Absent on every other choice. */
446
+ delivery?: BuyChoiceDelivery;
447
+ }
448
+ /**
449
+ * One way a recipe's groceries can come: the DoorDash grocery store, its street when DoorDash names one, its delivery estimate in
450
+ * minutes (null when DoorDash shows none), the total with DoorDash's fees, and the part of that total that ships from Walmart or Amazon.
451
+ */
452
+ export interface BuyChoiceDelivery {
453
+ store: string;
454
+ store_address: string | null;
455
+ eta_minutes: number | null;
456
+ total_cents: number;
457
+ shipped_cents: number;
432
458
  }
433
459
  /** One group of values, such as a sandwich's bread or a shirt's size. `min` and `max` count the values picked; `max` null for no limit. */
434
460
  export interface BuyChoiceGroup {
@@ -441,7 +467,8 @@ export interface BuyChoiceGroup {
441
467
  /**
442
468
  * The choice an answer's reply asks the shopper to make, as data: the question, the item it is about, where the values come
443
469
  * from (`menu_item`: a restaurant item's options, `store_checkout`: a store's own size or colour question, `catalog_variant`:
444
- * a product's sizes or colours in stock), and every group with its values.
470
+ * a product's sizes or colours in stock, `recipe_delivery`: the ways a recipe's groceries can come, each option with its
471
+ * `delivery`), and every group with its values.
445
472
  */
446
473
  export interface BuyChoice {
447
474
  question: string | null;
@@ -601,6 +628,10 @@ export declare class AgentcardClient {
601
628
  private learnedSandbox;
602
629
  /** Each shopper's messages that no history the model ran with holds yet, under your own id for them. */
603
630
  private readonly inbox;
631
+ /** The chats (`threadId`, '' for a tool built without one) that need a kept message about the shopper alone no more: their history holds it, or their agent answered it (`forgetMessages`, `answered`). */
632
+ private readonly savedInChats;
633
+ /** The same marks by event id, for a copy of the message delivered later, such as a retried webhook's: `delivered` starts that copy with them. */
634
+ private readonly savedInChatsById;
604
635
  private readonly deliveredIds;
605
636
  private readonly trace;
606
637
  constructor(options: AgentcardClientOptions);
@@ -645,22 +676,41 @@ export declare class AgentcardClient {
645
676
  delivered(externalUserId: string, message: ShopperMessage): void;
646
677
  /**
647
678
  * Notes that your agent answers `message` with a turn of its own (`buy.nextStep`) instead of posting
648
- * its `text`, so the shopper never reads that text: `delivered` skips it from now on, and no later
649
- * `buy.turn` hands it to the model as words the shopper read. `buy.nextStep` calls it.
679
+ * its `text`, so the shopper never reads that text in that chat (`threadId`, or the tool built
680
+ * without one). A message about one purchase belongs to one chat, so it leaves the client:
681
+ * `delivered` skips it from now on, and no later `buy.turn` hands it to the model as words the
682
+ * shopper read. A message about the shopper alone, such as a saved card, is answered for that chat
683
+ * only: the shopper's other chats still keep it. `buy.nextStep` calls it.
650
684
  */
651
- answered(externalUserId: string, message: ShopperMessage): void;
685
+ answered(externalUserId: string, message: ShopperMessage, threadId?: string): void;
652
686
  /**
653
687
  * Takes back `answered` for a message your agent's own turn could not answer (`BuyToolTurn.release` calls it): once you
654
- * post its text after all, `delivered` keeps it, and a later `buy.turn` hands it to the model as words the shopper read.
688
+ * post its text after all, `delivered` keeps it, and a later `buy.turn` in that chat hands it to the model as words the
689
+ * shopper read.
690
+ */
691
+ unanswered(externalUserId: string, message: ShopperMessage, threadId?: string): void;
692
+ /**
693
+ * The messages kept for this shopper that this chat reads, oldest first: each one about a purchase
694
+ * opened in this chat or about the shopper alone, that reached their chat and that no history
695
+ * `turn.messages` read in this chat has held yet. The chat is `threadId`, or the tool built without
696
+ * one, which reads the purchases opened under no thread. `buy.turn` reads them when it opens.
697
+ */
698
+ keptMessages(externalUserId: string, threadId?: string): ShopperMessage[];
699
+ /**
700
+ * Forgets these messages for this shopper in this chat (`threadId`, or the tool built without one): `turn.messages`
701
+ * calls it once the history it reads holds them. A message about one purchase leaves the client, since one chat reads
702
+ * it. A message about the shopper alone, such as a saved card, stays kept for the shopper's other chats until their
703
+ * own histories hold it.
655
704
  */
656
- unanswered(externalUserId: string, message: ShopperMessage): void;
705
+ forgetMessages(externalUserId: string, messages: readonly ShopperMessage[], threadId?: string): void;
657
706
  /**
658
- * The messages kept for this shopper, oldest first: each one that reached their chat and that no
659
- * history `turn.messages` read has held yet. `buy.turn` reads them when it opens.
707
+ * Marks a message about the shopper alone as one this chat needs no more, its history holding it or its agent having
708
+ * answered it, or takes the mark back. The kept copies with the same event id carry the mark too, and so does a copy
709
+ * delivered later (`delivered`), while the client still remembers the id.
660
710
  */
661
- keptMessages(externalUserId: string): ShopperMessage[];
662
- /** Forgets these messages for this shopper: `turn.messages` calls it once the history it reads holds them. */
663
- forgetMessages(externalUserId: string, messages: readonly ShopperMessage[]): void;
711
+ private markChat;
712
+ /** Removes these messages from the shopper's kept messages, for every chat. */
713
+ private dropKept;
664
714
  /** A company names the shopper exactly once; a shopper's own token already names them. */
665
715
  private checkShopper;
666
716
  private call;
package/dist/client.js CHANGED
@@ -10,8 +10,9 @@
10
10
  */
11
11
  import { isRecord } from './verify.js';
12
12
  import { listenToAgentcard } from './listen.js';
13
+ import { messageInChat } from './shopper-message.js';
13
14
  /** The handlers that ask Agentcard to stream a turn. */
14
- const STREAM_HANDLERS = ['onProgress', 'onStatus', 'onFound', 'onCatalog', 'onSegment', 'onAck', 'onCart', 'onDelta', 'onAvailability'];
15
+ const STREAM_HANDLERS = ['onProgress', 'onStatus', 'onFound', 'onCatalog', 'onSegment', 'onAck', 'onCart', 'onDelta', 'onAvailability', 'onReplyWritten'];
15
16
  const PROGRESS_STAGES = new Set(['searching', 'building_cart', 'checking_out', 'working']);
16
17
  export const DEFAULT_BASE_URL = 'https://api.agentcard.sh';
17
18
  /** The wait for one purchase turn: past Agentcard's own 110-second budget, so a slow turn answers `turn_timeout` rather than aborting here. */
@@ -309,11 +310,17 @@ export function choiceOf(value) {
309
310
  if (!isRecord(value) || !Array.isArray(value.groups))
310
311
  return null;
311
312
  const groups = value.groups.filter(isRecord).flatMap((g) => {
312
- const options = (Array.isArray(g.options) ? g.options : []).filter(isRecord).flatMap((o) => str(o.label) ? [{ id: str(o.id), label: o.label.trim(), price_delta_cents: num(o.price_delta_cents) }] : []);
313
+ const options = (Array.isArray(g.options) ? g.options : []).filter(isRecord).flatMap((o) => str(o.label) ? [{ id: str(o.id), label: o.label.trim(), price_delta_cents: num(o.price_delta_cents), ...deliveryOf(o.delivery) }] : []);
313
314
  return options.length ? [{ name: str(g.name) ?? '', required: g.required === true, min: num(g.min) ?? 0, max: num(g.max), options }] : [];
314
315
  });
315
316
  return groups.length ? { question: str(value.question), item: str(value.item), source: str(value.source) ?? '', groups } : null;
316
317
  }
318
+ /** A recipe option's `delivery`, as `{ delivery }` when it names a store and a total, else nothing. */
319
+ function deliveryOf(value) {
320
+ if (!isRecord(value) || !str(value.store) || num(value.total_cents) === null)
321
+ return {};
322
+ return { delivery: { store: value.store, store_address: str(value.store_address), eta_minutes: num(value.eta_minutes), total_cents: num(value.total_cents), shipped_cents: num(value.shipped_cents) ?? 0 } };
323
+ }
317
324
  /** A cart being built, as an answer, a refusal or the conversation read names it. */
318
325
  export function cartBuildingOf(value) {
319
326
  return isRecord(value) && str(value.store) ? { store: value.store, started_at: str(value.started_at) ?? '' } : null;
@@ -457,7 +464,7 @@ async function eachEvent(res, onEvent) {
457
464
  }
458
465
  /**
459
466
  * A streamed purchase turn, read to its `envelope`: the same answer a JSON turn returns, with its
460
- * status in `http_status`. Each `progress`, `status`, `delta`, `catalog`, `find`, `segment`, `cart` and `venue_availability` goes to its handler
467
+ * status in `http_status`. Each `progress`, `status`, `delta`, `catalog`, `find`, `segment`, `reply_written`, `cart` and `venue_availability` goes to its handler
461
468
  * on the way, and any other event, such as a `ping` or a `tool`, is read past. A stream that breaks or
462
469
  * ends before its envelope, once it named the conversation, reads as a turn past its budget
463
470
  * (`turn_timeout`), so the tool reads the conversation for what the turn left instead of losing it.
@@ -536,6 +543,9 @@ async function readStreamedTurn(res, stream, timeoutMs) {
536
543
  else if (event === 'segment' && typeof data.text === 'string' && data.text.trim() !== '' && stream.onSegment) {
537
544
  deliver(() => stream.onSegment({ conversationId: named, text: data.text }));
538
545
  }
546
+ else if (event === 'reply_written' && stream.onReplyWritten) {
547
+ deliver(() => stream.onReplyWritten({ conversationId: named }));
548
+ }
539
549
  else if (event === 'ack' && typeof data.text === 'string' && data.text.trim() !== '' && stream.onAck) {
540
550
  deliver(() => stream.onAck({ conversationId: named, text: data.text }));
541
551
  }
@@ -580,6 +590,10 @@ export class AgentcardClient {
580
590
  learnedSandbox = null;
581
591
  /** Each shopper's messages that no history the model ran with holds yet, under your own id for them. */
582
592
  inbox = new Map();
593
+ /** The chats (`threadId`, '' for a tool built without one) that need a kept message about the shopper alone no more: their history holds it, or their agent answered it (`forgetMessages`, `answered`). */
594
+ savedInChats = new WeakMap();
595
+ /** The same marks by event id, for a copy of the message delivered later, such as a retried webhook's: `delivered` starts that copy with them. */
596
+ savedInChatsById = new Map();
583
597
  deliveredIds = new Set();
584
598
  trace;
585
599
  constructor(options) {
@@ -724,6 +738,10 @@ export class AgentcardClient {
724
738
  this.deliveredIds.delete(this.deliveredIds.values().next().value);
725
739
  }
726
740
  const kept = this.inbox.get(externalUserId) ?? [];
741
+ // A copy delivered again, such as a retried webhook's, keeps the chats that already answered it.
742
+ const marks = str(message.eventId) ? this.savedInChatsById.get(message.eventId) : undefined;
743
+ if (marks)
744
+ this.savedInChats.set(message, new Set(marks));
727
745
  // The shopper written to last moves to the end, so the one dropped first is the one written to longest ago.
728
746
  this.inbox.delete(externalUserId);
729
747
  this.inbox.set(externalUserId, [...kept, message].slice(-DELIVERED_PER_SHOPPER));
@@ -732,14 +750,21 @@ export class AgentcardClient {
732
750
  }
733
751
  /**
734
752
  * Notes that your agent answers `message` with a turn of its own (`buy.nextStep`) instead of posting
735
- * its `text`, so the shopper never reads that text: `delivered` skips it from now on, and no later
736
- * `buy.turn` hands it to the model as words the shopper read. `buy.nextStep` calls it.
753
+ * its `text`, so the shopper never reads that text in that chat (`threadId`, or the tool built
754
+ * without one). A message about one purchase belongs to one chat, so it leaves the client:
755
+ * `delivered` skips it from now on, and no later `buy.turn` hands it to the model as words the
756
+ * shopper read. A message about the shopper alone, such as a saved card, is answered for that chat
757
+ * only: the shopper's other chats still keep it. `buy.nextStep` calls it.
737
758
  */
738
- answered(externalUserId, message) {
759
+ answered(externalUserId, message, threadId) {
739
760
  if (!str(externalUserId))
740
761
  throw new TypeError('Pass externalUserId: your own id for the shopper the message is for.');
741
762
  if (!isRecord(message))
742
763
  throw new TypeError('Pass the message onMessage got.');
764
+ if (!message.threadId && !message.conversationId) {
765
+ this.markChat(externalUserId, message, threadId ?? '', true);
766
+ return;
767
+ }
743
768
  if (str(message.eventId)) {
744
769
  this.deliveredIds.add(message.eventId);
745
770
  if (this.deliveredIds.size > DELIVERED_IDS_KEPT)
@@ -747,31 +772,82 @@ export class AgentcardClient {
747
772
  }
748
773
  const kept = this.inbox.get(externalUserId);
749
774
  if (kept)
750
- this.forgetMessages(externalUserId, kept.filter((m) => m === message || (!!message.eventId && m.eventId === message.eventId)));
775
+ this.dropKept(externalUserId, kept.filter((m) => m === message || (!!message.eventId && m.eventId === message.eventId)));
751
776
  }
752
777
  /**
753
778
  * Takes back `answered` for a message your agent's own turn could not answer (`BuyToolTurn.release` calls it): once you
754
- * post its text after all, `delivered` keeps it, and a later `buy.turn` hands it to the model as words the shopper read.
779
+ * post its text after all, `delivered` keeps it, and a later `buy.turn` in that chat hands it to the model as words the
780
+ * shopper read.
755
781
  */
756
- unanswered(externalUserId, message) {
782
+ unanswered(externalUserId, message, threadId) {
757
783
  if (!str(externalUserId))
758
784
  throw new TypeError('Pass externalUserId: your own id for the shopper the message is for.');
759
785
  if (!isRecord(message))
760
786
  throw new TypeError('Pass the message onMessage got.');
787
+ if (!message.threadId && !message.conversationId) {
788
+ this.markChat(externalUserId, message, threadId ?? '', false);
789
+ return;
790
+ }
761
791
  if (str(message.eventId))
762
792
  this.deliveredIds.delete(message.eventId);
763
793
  }
764
794
  /**
765
- * The messages kept for this shopper, oldest first: each one that reached their chat and that no
766
- * history `turn.messages` read has held yet. `buy.turn` reads them when it opens.
795
+ * The messages kept for this shopper that this chat reads, oldest first: each one about a purchase
796
+ * opened in this chat or about the shopper alone, that reached their chat and that no history
797
+ * `turn.messages` read in this chat has held yet. The chat is `threadId`, or the tool built without
798
+ * one, which reads the purchases opened under no thread. `buy.turn` reads them when it opens.
767
799
  */
768
- keptMessages(externalUserId) {
769
- return [...(this.inbox.get(externalUserId) ?? [])];
800
+ keptMessages(externalUserId, threadId) {
801
+ const chat = threadId ?? '';
802
+ return (this.inbox.get(externalUserId) ?? []).filter((m) => messageInChat(m, threadId) && !this.savedInChats.get(m)?.has(chat));
770
803
  }
771
- /** Forgets these messages for this shopper: `turn.messages` calls it once the history it reads holds them. */
772
- forgetMessages(externalUserId, messages) {
804
+ /**
805
+ * Forgets these messages for this shopper in this chat (`threadId`, or the tool built without one): `turn.messages`
806
+ * calls it once the history it reads holds them. A message about one purchase leaves the client, since one chat reads
807
+ * it. A message about the shopper alone, such as a saved card, stays kept for the shopper's other chats until their
808
+ * own histories hold it.
809
+ */
810
+ forgetMessages(externalUserId, messages, threadId) {
811
+ const chat = threadId ?? '';
812
+ this.dropKept(externalUserId, messages.filter((m) => {
813
+ if (m.threadId || m.conversationId)
814
+ return true;
815
+ this.markChat(externalUserId, m, chat, true);
816
+ return false;
817
+ }));
818
+ }
819
+ /**
820
+ * Marks a message about the shopper alone as one this chat needs no more, its history holding it or its agent having
821
+ * answered it, or takes the mark back. The kept copies with the same event id carry the mark too, and so does a copy
822
+ * delivered later (`delivered`), while the client still remembers the id.
823
+ */
824
+ markChat(externalUserId, message, chat, done) {
825
+ const id = str(message.eventId);
826
+ const mark = (chats) => {
827
+ if (done)
828
+ chats.add(chat);
829
+ else
830
+ chats.delete(chat);
831
+ return chats;
832
+ };
833
+ const same = (m) => m === message || (!!id && m.eventId === id);
834
+ for (const m of new Set([message, ...(this.inbox.get(externalUserId) ?? []).filter(same)])) {
835
+ this.savedInChats.set(m, mark(this.savedInChats.get(m) ?? new Set()));
836
+ }
837
+ if (!id)
838
+ return;
839
+ // The id marked last moves to the end, so the one dropped first is the one marked longest ago. A kept copy keeps its
840
+ // own marks after its id is dropped.
841
+ const chats = mark(this.savedInChatsById.get(id) ?? new Set());
842
+ this.savedInChatsById.delete(id);
843
+ this.savedInChatsById.set(id, chats);
844
+ if (this.savedInChatsById.size > DELIVERED_IDS_KEPT)
845
+ this.savedInChatsById.delete(this.savedInChatsById.keys().next().value);
846
+ }
847
+ /** Removes these messages from the shopper's kept messages, for every chat. */
848
+ dropKept(externalUserId, messages) {
773
849
  const kept = this.inbox.get(externalUserId);
774
- if (!kept)
850
+ if (!kept || messages.length === 0)
775
851
  return;
776
852
  const left = kept.filter((m) => !messages.includes(m));
777
853
  if (left.length > 0)
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@ export { readPurchaseMessage, type PurchaseMessage, type PurchaseMessageKind, ty
3
3
  export { readShopperMessage, isStale, SHOPPER_EVENT_TYPES, NEXT_STEP_KINDS, type NextStepKind, type ShopperMessage, type ShopperMessageKind, type ShopperFacts, type ShopperEventType, type OrderStatus, } from './shopper-message.js';
4
4
  export { ORDER_STEPS, updateOrder, arrivalText, type ShopperOrder, type OrderStep, type OrderPathStep, type OrderStepState, type ArrivalTextOptions, } from './order.js';
5
5
  export { agentcardWebhook, purchaseAgentWebhook, type AgentcardWebhookOptions, type AgentcardWebhookHandler, type PurchaseAgentWebhookOptions, type WebhookFetchRequest, type WebhookRequest, type WebhookResponse, } from './handler.js';
6
- export { AgentcardClient, AgentcardApiError, BUY_TURN_TIMEOUT_MS, DEFAULT_BASE_URL, type AgentcardClientOptions, type AgentcardTraceOptions, type OtelApi, type TraceContext, type FetchLike, type FetchLikeResponse, type BuyProgress, type BuyProgressStage, type BuyCatalogUpdate, type BuyAvailability, type BuyAvailabilityUpdate, type BuyFoundUpdate, type BuyTurnStatus, type BuySegmentUpdate, type BuyCartUpdate, type BuyDeltaUpdate, type BuyStreamHandlers, type Shopper, type BuyLastAnswer, type BuyRequest, type BuyDeliveryAddress, type BuyTurn, type BuyStatus, type BuyCart, type BuyCartBuilding, type BuyCartLine, type BuyCartFeeLine, type BuyStore, type BuyCatalog, type BuyCatalogItem, type BuyFlightItinerary, type BuyPaymentSource, type BuyConversation, type BuyLastCheckout, type BuyOrder, type VaultLink, type BuyNextAction, type BuyPick, type BuyStoreChoice, type BuyStorePick, type BuyStores, type BuyChoice, type BuyChoiceGroup, type BuyChoiceOption, } from './client.js';
6
+ export { AgentcardClient, AgentcardApiError, BUY_TURN_TIMEOUT_MS, DEFAULT_BASE_URL, type AgentcardClientOptions, type AgentcardTraceOptions, type OtelApi, type TraceContext, type FetchLike, type FetchLikeResponse, type BuyProgress, type BuyProgressStage, type BuyCatalogUpdate, type BuyAvailability, type BuyAvailabilityUpdate, type BuyFoundUpdate, type BuyTurnStatus, type BuySegmentUpdate, type BuyCartUpdate, type BuyDeltaUpdate, type BuyStreamHandlers, type Shopper, type BuyLastAnswer, type BuyRequest, type BuyDeliveryAddress, type BuyTurn, type BuyStatus, type BuyCart, type BuyCartBuilding, type BuyCartLine, type BuyCartFeeLine, type BuyStore, type BuyCatalog, type BuyCatalogItem, type BuyFlightItinerary, type BuyPaymentSource, type BuyConversation, type BuyLastCheckout, type BuyOrder, type VaultLink, type BuyNextAction, type BuyPick, type BuyStoreChoice, type BuyStorePick, type BuyStores, type BuyChoice, type BuyChoiceGroup, type BuyChoiceDelivery, type BuyChoiceOption, } from './client.js';
7
7
  export { BUY_TOOL_NAME, BUY_TOOL_DESCRIPTION, buyToolDefinition, parseBuyToolInput, type BuyToolDefinition, type BuyToolInput, type BuyToolInputSchema, type BuyToolInputCheck, type BuyToolStandardSchema, type OpenAIBuyTool, } from './definition.js';
8
8
  export { toMessages, messageStream, typingDelay, type MessagePace, type MessageStream, type MessageStreamOptions, } from './messages.js';
9
9
  export { buyTool, nextStepPrompt, type BuyTool, type BuyToolOptions, type BuyToolResult, type BuyToolStatus, type BuyToolLink, type BuyToolFound, type BuyToolAvailability, type BuyToolProduct, type BuyToolProductAction, productAction, type BuyToolStore, type BuyToolTurn, type BuyToolTurnItem, type BuyToolTurnOptions, type BuyToolTurnMessage, type BuyAckMode, } from './buy-tool.js';
@@ -72,6 +72,12 @@ export interface ShopperMessage {
72
72
  externalUserId: string | null;
73
73
  /** The buy conversation the event belongs to, when Agentcard names one. */
74
74
  conversationId: string | null;
75
+ /**
76
+ * Your own id for the chat or thread that conversation opened under (the buy tool's `threadId`, POST /buy `thread_id`):
77
+ * the chat to post `text` into. Null for a purchase that opened under none, and for a message about the shopper alone,
78
+ * such as a saved card.
79
+ */
80
+ threadId: string | null;
75
81
  /** Ready to send to the shopper as is. */
76
82
  text: string;
77
83
  /** For your agent: the facts of what happened, marked as written by the system and not typed by the shopper. */
@@ -97,6 +103,12 @@ export interface ShopperMessage {
97
103
  /** Unix seconds. */
98
104
  created: number;
99
105
  }
106
+ /**
107
+ * Whether a chat reads this message: one about a purchase opened under the chat's thread, matched both ways, so the chat
108
+ * of a tool built without `threadId` reads the purchases opened under none and never a chat's, or one about the shopper
109
+ * alone, which names neither a thread nor a conversation. Internal: `buyTool` and the client's kept messages share it.
110
+ */
111
+ export declare function messageInChat(message: Pick<ShopperMessage, 'threadId' | 'conversationId'>, threadId?: string): boolean;
100
112
  /**
101
113
  * The message an event carries for a shopper, or null when it carries none: an event type that is
102
114
  * not a shopper event, an order event that names no shopper, or a delivery stage the package does
@@ -89,6 +89,7 @@ function orderBase(event, type) {
89
89
  userId,
90
90
  externalUserId: id(data.external_user_id) ?? null,
91
91
  conversationId: id(data.conversation_id) ?? null,
92
+ threadId: id(data.thread_id) ?? null,
92
93
  clientId: id(connection.client_id) ?? null,
93
94
  livemode: event.livemode,
94
95
  created: event.created,
@@ -180,6 +181,14 @@ function readOrderUpdated(event) {
180
181
  order,
181
182
  };
182
183
  }
184
+ /**
185
+ * Whether a chat reads this message: one about a purchase opened under the chat's thread, matched both ways, so the chat
186
+ * of a tool built without `threadId` reads the purchases opened under none and never a chat's, or one about the shopper
187
+ * alone, which names neither a thread nor a conversation. Internal: `buyTool` and the client's kept messages share it.
188
+ */
189
+ export function messageInChat(message, threadId) {
190
+ return (message.threadId || undefined) === (threadId || undefined) || (!message.threadId && !message.conversationId);
191
+ }
183
192
  /**
184
193
  * The message an event carries for a shopper, or null when it carries none: an event type that is
185
194
  * not a shopper event, an order event that names no shopper, or a delivery stage the package does
@@ -198,6 +207,7 @@ export function readShopperMessage(event) {
198
207
  ...message,
199
208
  type: 'purchase.message',
200
209
  conversationId: id(event.data.conversation_id) ?? null,
210
+ threadId: id(event.data.thread_id) ?? null,
201
211
  advances: NEXT_STEP.has(message.kind),
202
212
  order: purchaseOrder(message.kind, message.facts, event.created),
203
213
  };
@@ -4,8 +4,8 @@
4
4
  * This file imports nothing, so the server helper (`turnStream`) and the browser reader (`streamTurn`) share it.
5
5
  */
6
6
  /**
7
- * Whether a turn keeps its record and a dropped stream reads it back. Off until the pull request that turns it on:
8
- * while it is off, `turnStream` streams without saving and `streamTurn` reports a drop as the error it was.
7
+ * Whether a turn keeps its record and a dropped stream reads it back. When it is off, `turnStream` streams without
8
+ * saving and `streamTurn` reports a drop as the error it was; `recover: false` does the same for the one call it is passed to.
9
9
  */
10
10
  export declare const TURN_RECOVERY_ON: boolean;
11
11
  /** One event of a turn, as its server-sent frame carries it: `event: <event>` and `data: <JSON of data>`. */
@@ -4,10 +4,10 @@
4
4
  * This file imports nothing, so the server helper (`turnStream`) and the browser reader (`streamTurn`) share it.
5
5
  */
6
6
  /**
7
- * Whether a turn keeps its record and a dropped stream reads it back. Off until the pull request that turns it on:
8
- * while it is off, `turnStream` streams without saving and `streamTurn` reports a drop as the error it was.
7
+ * Whether a turn keeps its record and a dropped stream reads it back. When it is off, `turnStream` streams without
8
+ * saving and `streamTurn` reports a drop as the error it was; `recover: false` does the same for the one call it is passed to.
9
9
  */
10
- export const TURN_RECOVERY_ON = false;
10
+ export const TURN_RECOVERY_ON = true;
11
11
  /** A turn id the browser makes up front: 8 to 64 letters, digits, `-` or `_`. Check one before using it in a key. */
12
12
  export function isTurnId(value) {
13
13
  return typeof value === 'string' && /^[A-Za-z0-9_-]{8,64}$/.test(value);
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@agent-cards/commerce-sdk",
3
- "version": "0.18.3",
3
+ "version": "0.18.5",
4
4
  "description": "Give your agent one tool that buys for your shoppers at real stores, and post each purchase update Agentcard sends into the shopper's chat, over a connection your server opens or a webhook endpoint.",
5
+ "homepage": "https://opencommerce.sh",
5
6
  "type": "module",
6
7
  "main": "dist/index.js",
7
8
  "types": "dist/index.d.ts",