@agent-cards/commerce-sdk 0.10.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/buy-tool.js CHANGED
@@ -31,11 +31,19 @@
31
31
  * messages and links as one sequence, in the order they happened, and spaces
32
32
  * the messages like someone texting.
33
33
  */
34
- import { AgentcardApiError, AgentcardClient, } from './client.js';
34
+ import { AgentcardApiError, AgentcardClient, cartBuildingOf, } from './client.js';
35
35
  import { buyToolDefinition, buyToolStandardSchema, openAIBuyTool, parseBuyToolInput, } from './definition.js';
36
- import { forModel, forModelError, money, pickId, productCardKey, shownProducts, shownStores, stripLinks } from './model-view.js';
36
+ import { forModel, forModelError, pickId, productPrice, productCardKey, replyAsWritten, shownProducts, shownStores, stripLinks } from './model-view.js';
37
37
  import { messageStream, paceOf, sleep, toMessages, typingHook } from './messages.js';
38
- import { ackAt, answersQuestion, instantAck as instantAckOf, isNewRequest, isPickText, namesStore, nextTemplate, slowStepLine, waitAt } from './ack.js';
38
+ import { isStale } from './shopper-message.js';
39
+ import { actionLinkId, isActionLink } from './action-links.js';
40
+ import { ACK_REACTION, ackAt, answersQuestion, instantAck as instantAckOf, isNewRequest, isPickText, keepsYes, namesStore, nextTemplate, slowStepLine, waitAt } from './ack.js';
41
+ /** The action a product of `merchant` takes (BuyToolProductAction). */
42
+ export function productAction(merchant) {
43
+ if (merchant === 'reservations')
44
+ return 'reserve';
45
+ return merchant === 'flights' || merchant === 'hotels' || merchant === 'tickets' || merchant === 'classes' ? 'book' : 'buy';
46
+ }
39
47
  const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
40
48
  /**
41
49
  * The model's messages for a turn: the history, what Agentcard told the shopper, then the shopper's
@@ -112,7 +120,18 @@ function productWords(text) {
112
120
  }
113
121
  /** How long after `instantAck` the model's first call still belongs to the turn it acknowledged. */
114
122
  const APP_TURN_MS = 120_000;
123
+ /** The links that ask the shopper to take a step: they go out after the message that asks for it. */
124
+ const STEP_LINK_KINDS = new Set(['vault', 'approval', 'store_login']);
125
+ /** The longest a step link waits for its message: an app that never writes one still sends the link. */
126
+ const STEP_LINK_HOLD_MS = 20_000;
115
127
  const STILL_WORKING = "Agentcard is still working on the shopper's last request. Call this tool again in a moment.";
128
+ const STILL_BUILDING = "The store is still building the cart for the shopper's last request. Its cart and total reach the shopper in a message from Agentcard: once that message has arrived, call this tool again with the shopper's words.";
129
+ const CART_NOT_READY = 'The store is still building the cart, so it has no total yet, and nothing was confirmed. Its cart and total reach the shopper in a message from Agentcard: confirm it only after the shopper says yes to that total.';
130
+ /** What the shopper hears while their message waits for the cart the store is still building. */
131
+ const stillBuildingLine = (store) => `still building your cart at ${store}, i'll get to this as soon as it's in`;
132
+ /** How often, and how many times, a message held for a cart being built reads whether the cart is in. */
133
+ const CART_HOLD_EVERY_MS = 2_000;
134
+ const CART_HOLD_READS = 45;
116
135
  const CARD_NEEDED = 'The shopper adds a card from a Vault link before Agentcard can buy for them.';
117
136
  const STILL_PLACING = 'The shopper approved, and the order is still going through.';
118
137
  const NOT_PLACED = 'The order did not go through.';
@@ -125,6 +144,40 @@ const NOTHING_TO_CONFIRM = 'There is no cart to confirm yet. Ask the shopper wha
125
144
  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.';
126
145
  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.";
127
146
  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.";
147
+ /** What a call of a `nextStep` turn answers with when the shopper has not said yes to the cart on the table. */
148
+ const ASK_FOR_YES = "The cart is ready, and the shopper has not said yes to it in the chat, so nothing was confirmed. Say the cart's total in one short line and ask for their yes.";
149
+ const STEP_AFTER_DECLINE = 'The shopper declined the approval, so nothing was charged and no order was placed. The cart is still here. Say so in one short line and offer the way forward: their yes sends a new approval link, or they can change the order.';
150
+ const CARD_AFTER_SIGN_IN = 'The shopper is signed in to the store. A card is the next step: ask them to add one from the link.';
151
+ const NO_CART_WAITING = 'No cart is waiting. Carry on with what the shopper last asked for, in one short message.';
152
+ const STEP_APPROVAL_WAITING = 'The shopper already has the link to approve this payment, and nothing was charged yet. Ask for nothing else: in one short line, remind them to approve it from the link they have.';
153
+ const STEP_PLACEMENT_RUNNING = 'The order is going through now. Ask for nothing: say in one short line that it is being placed and that you will tell them once it is.';
154
+ /** What the shopper just did outside the chat, for the line that opens a `nextStep` turn. */
155
+ function stepDone(message) {
156
+ const store = message.facts.merchantName;
157
+ switch (message.kind) {
158
+ case 'card_added':
159
+ return 'The shopper just saved a card outside the chat.';
160
+ case 'store_connected':
161
+ return `The shopper just signed in to ${store ?? 'the store'} outside the chat.`;
162
+ case 'approval_declined':
163
+ return `The shopper just declined the approval${store ? ` for the ${store} order` : ''} outside the chat. Nothing was charged and no order was placed.`;
164
+ default:
165
+ return 'The shopper just finished a step outside the chat.';
166
+ }
167
+ }
168
+ /**
169
+ * The words a `nextStep` turn hands the model as the user's message: the event, marked as no one's
170
+ * typing, and the instruction to take the next step now in one short message, without the filler of
171
+ * a step announced and a question about what comes next.
172
+ */
173
+ export function nextStepPrompt(message) {
174
+ const note = String(message.note ?? '').trim();
175
+ return [
176
+ note || `[event: ${stepDone(message)} This note was generated by the system, not typed by the user.]`,
177
+ `${stepDone(message)} The shopper has not written anything since: this turn starts on its own. Take the next step now, as your next message to them. Call the buy tool once, with ask "next step", and do what its answer says, in one short message.`,
178
+ "Don't tell them they're all set or ready, and don't ask what's next. Never say an order is placed or a card charged unless the buy tool's answer says so.",
179
+ ].join('\n\n');
180
+ }
128
181
  /**
129
182
  * The pick as a later read of the conversation shows it: a pick the early answer found missing is in
130
183
  * the cart once a line carries its catalog id, as its product id or, for a retail product whose id is
@@ -152,11 +205,14 @@ function productsOfCatalog(catalog) {
152
205
  ref: item.ref,
153
206
  name: item.name,
154
207
  priceCents: item.priceCents,
208
+ ...(item.price_from ? { priceFrom: true } : {}),
155
209
  currency: item.currency,
156
210
  imageUrl: item.image_url,
211
+ images: item.image_urls ?? (item.image_url ? [item.image_url] : []),
157
212
  ...(item.flight ? { flight: item.flight } : {}),
158
213
  store: item.store?.name ?? item.store?.id ?? catalog.merchant_name ?? null,
159
214
  url: item.url ?? null,
215
+ action: productAction(catalog.merchant),
160
216
  linkSent: false,
161
217
  }));
162
218
  }
@@ -170,6 +226,14 @@ function storeLoginOf(turn) {
170
226
  return [];
171
227
  return [{ kind: 'store_login', url: turn.store_login_url, expiresAt: turn.store_login_expires_at, store: turn.store_login_merchant_name }];
172
228
  }
229
+ /**
230
+ * The approval link for a cart, naming the cart's store in `store` when the answer names one, so a
231
+ * message about another store's cart never ends this approval (`actionLinkOutcome`).
232
+ */
233
+ function approvalLink(url, expiresAt, cart) {
234
+ const store = cart?.merchant_name;
235
+ return { kind: 'approval', url, expiresAt, ...(store ? { store } : {}) };
236
+ }
173
237
  /** The answer before its status is read: every field the purchase answer carries. */
174
238
  function answerOf(turn) {
175
239
  return {
@@ -192,6 +256,7 @@ function answerOf(turn) {
192
256
  stores: storesOf(turn.stores),
193
257
  store_pick: turn.store_pick,
194
258
  hashes: hashesOf(turn.cart, turn.carts),
259
+ cart_building: turn.cart_building,
195
260
  envelope: turn.raw,
196
261
  };
197
262
  }
@@ -259,7 +324,7 @@ function resultOfStatus(turn) {
259
324
  return {
260
325
  ...answer,
261
326
  status: 'approval_sent',
262
- links: [...answer.links, ...(turn.approval_url ? [{ kind: 'approval', url: turn.approval_url, expiresAt: turn.approval_expires_at }] : [])],
327
+ links: [...answer.links, ...(turn.approval_url ? [approvalLink(turn.approval_url, turn.approval_expires_at, turn.cart)] : [])],
263
328
  shopper_need_not_reply: turn.placement_announced === true,
264
329
  };
265
330
  }
@@ -292,16 +357,19 @@ function resultOfRefusal(error) {
292
357
  stores: [],
293
358
  store_pick: null,
294
359
  hashes: hashesOf(null, carts),
360
+ cart_building: cartBuildingOf(error.body.cart_building),
295
361
  envelope: error.body,
296
362
  });
297
363
  if (error.code.startsWith('delivery_address'))
298
364
  return answer(ADDRESS_CHANGED, []);
365
+ if (error.code === 'cart_not_ready')
366
+ return answer(CART_NOT_READY, []);
299
367
  if (error.code === 'cart_changed')
300
368
  return answer(CART_CHANGED, error.carts);
301
369
  if (error.code === 'nothing_to_confirm' || error.code === 'conversation_closed')
302
370
  return answer(NOTHING_TO_CONFIRM, []);
303
371
  if (error.code === 'turn_in_progress')
304
- return answer(STILL_WORKING, []);
372
+ return answer(cartBuildingOf(error.body.cart_building) ? STILL_BUILDING : STILL_WORKING, []);
305
373
  if (error.code === 'pick_not_found')
306
374
  return answer(PICK_GONE, []);
307
375
  if (error.code === 'store_pick_not_found')
@@ -435,15 +503,29 @@ function feedReply(text) {
435
503
  };
436
504
  return { add, settled };
437
505
  }
438
- /** A message stream for a turn's feed: the messages it cuts are one reply, `feedReply`'s. */
439
- function feedStream(text) {
506
+ /**
507
+ * A message stream for a turn's feed: the messages it cuts are one reply, `feedReply`'s. `ended` runs
508
+ * once the last message is cut and has its place in the feed, before it went out, so what it adds
509
+ * follows the reply. `end` settles once the reply and what `ended` added went out, and rejects with
510
+ * the first failure.
511
+ */
512
+ function feedStream(text, ended) {
440
513
  const reply = feedReply(text);
441
514
  const inner = messageStream({ onMessage: reply.add, pace: false });
442
515
  return {
443
516
  push: (delta) => inner.push(delta),
444
517
  end: async () => {
445
- await inner.end();
518
+ let added = Promise.resolve();
519
+ try {
520
+ await inner.end();
521
+ }
522
+ finally {
523
+ added = ended();
524
+ // Read below, after the reply: a failure here is never left unhandled while the reply settles.
525
+ added.catch(() => { });
526
+ }
446
527
  await reply.settled();
528
+ await added;
447
529
  },
448
530
  };
449
531
  }
@@ -529,19 +611,40 @@ export function buyTool(options) {
529
611
  throw new TypeError('Pass onResult as a function that takes each answer.');
530
612
  }
531
613
  const onResult = o.onResult;
614
+ if (o.replyAsWritten !== undefined && typeof o.replyAsWritten !== 'boolean')
615
+ throw new TypeError('Pass replyAsWritten as true or false.');
616
+ const writtenByDefault = o.replyAsWritten === true;
532
617
  const wait = o.wait ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
533
618
  if (typeof wait !== 'function')
534
619
  throw new TypeError('Pass wait as a function of milliseconds that resolves after the pause.');
535
620
  const who = client.company ? { externalUserId } : {};
536
621
  if (o.onWorking !== undefined && typeof o.onWorking !== 'function')
537
622
  throw new TypeError('Pass onWorking as a function that sends the shopper a message.');
538
- if (o.ack !== undefined && o.ack !== false && typeof o.ack !== 'function')
539
- throw new TypeError('Pass ack as false, or as a function that writes the instant reply from the ask.');
623
+ if (o.onReaction !== undefined && typeof o.onReaction !== 'function')
624
+ throw new TypeError('Pass onReaction as a function that puts the emoji on the shopper\'s message.');
625
+ if (o.ack !== undefined && o.ack !== false && o.ack !== 'reaction' && o.ack !== 'text' && o.ack !== 'none' && typeof o.ack !== 'function') {
626
+ throw new TypeError("Pass ack as 'reaction', 'text' or 'none', as false, or as a function that writes the instant reply from the ask.");
627
+ }
540
628
  const ackDelayMs = o.ackDelayMs ?? ACK_DELAY_MS;
541
629
  if (typeof ackDelayMs !== 'number' || !Number.isFinite(ackDelayMs) || ackDelayMs < 0)
542
630
  throw new TypeError('Pass ackDelayMs as a number of milliseconds, 0 or more.');
543
- const onWorking = o.ack === false ? undefined : o.onWorking;
544
631
  const writeAck = typeof o.ack === 'function' ? o.ack : null;
632
+ /** The mode `ack` names, or null when it names none and the callbacks a turn holds decide. */
633
+ const ackMode = o.ack === undefined ? null : o.ack === false || o.ack === 'none' ? 'none' : o.ack === 'reaction' ? 'reaction' : 'text';
634
+ /**
635
+ * Where a turn acknowledges, from the callbacks it holds: a reaction when it can react and `ack` asks
636
+ * for no text, else text. At most one of the two comes back, so a shopper never gets a 👀 and a line.
637
+ */
638
+ const ackRoutes = (working, reacting) => {
639
+ if (ackMode === 'none')
640
+ return { onWorking: undefined, onReaction: undefined };
641
+ if (ackMode === 'text')
642
+ return { onWorking: working, onReaction: undefined };
643
+ if (ackMode === 'reaction' || reacting)
644
+ return { onWorking: undefined, onReaction: reacting };
645
+ return { onWorking: working, onReaction: undefined };
646
+ };
647
+ const { onWorking, onReaction } = ackRoutes(o.onWorking, o.onReaction);
545
648
  /** Calls running now, when the last one ended, and the template the instant reply used last. */
546
649
  let running = 0;
547
650
  let lastEndedAt = Number.NEGATIVE_INFINITY;
@@ -556,14 +659,18 @@ export function buyTool(options) {
556
659
  picked: null,
557
660
  ackOut: null,
558
661
  ackDelivered: false,
662
+ reaction: null,
559
663
  sendLink,
560
664
  onWorking,
665
+ onReaction,
666
+ tell: o.onWorking,
561
667
  onResult,
562
668
  onProgress,
563
669
  links: null,
564
670
  results: null,
565
671
  pages: null,
566
672
  storePages: null,
673
+ event: null,
567
674
  });
568
675
  /**
569
676
  * The turn the calls made straight on the tool belong to, which the tool tells apart by timing. A call
@@ -580,6 +687,31 @@ export function buyTool(options) {
580
687
  let lastNextAction = null;
581
688
  /** The last reply Agentcard wrote: a message that answers its question for an address or a code gets no instant reply. */
582
689
  let lastReply = null;
690
+ /** How many carts the last answer held: with two or more, a new order goes to the same purchase (answerIn). */
691
+ let lastCartCount = 0;
692
+ /** The conversation and the carts of the last answer, for a `nextStep` call that cannot read the conversation. */
693
+ let lastConversationId = null;
694
+ let lastCarts = [];
695
+ /** The hash of the cart the last answer showed: the cart a confirm sent now confirms. */
696
+ let lastShownHash = null;
697
+ /**
698
+ * The yes the shopper gave in the chat to one cart, by its hash, when the confirm it sent stopped for a
699
+ * step outside the chat: a card to add or a store to sign in to. A `nextStep` turn confirms that cart
700
+ * once the step is done. Any shopper message that is not a plain go-ahead, `ok` or `done` takes it back.
701
+ */
702
+ let carriedYes = null;
703
+ /** The add-card link an answer carried while it asked for the store sign-in first: the card is the step after it. */
704
+ let cardAfterSignIn = null;
705
+ /** The shopper's words that never ran because they were not connected yet: they run once their card is saved. */
706
+ let unconnectedAsk = null;
707
+ /** The events this tool already took a `nextStep` turn for. */
708
+ const stepped = new Set();
709
+ /**
710
+ * How many shopper messages took back what the shopper asked for before them (any message that is not a plain go-ahead).
711
+ * A call that started before one of them leaves no yes and no words for a later step: its answer can land after the
712
+ * shopper said no, and a yes it carried would be confirmed by the next card or sign-in (review of #9220).
713
+ */
714
+ let withdrawals = 0;
583
715
  /** The names of the products the last answer showed, which a pick takes by `product_id`: a message that names one picks it. */
584
716
  let shownNames = [];
585
717
  const namesShownProduct = (text) => {
@@ -603,31 +735,72 @@ export function buyTool(options) {
603
735
  lastTemplate = nextTemplate(lastTemplate);
604
736
  return { picked, text: said ? ackAt(lastTemplate, text) : null };
605
737
  };
738
+ /**
739
+ * A shopper message that is not a plain go-ahead takes back a yes the shopper gave before it, and words of theirs that
740
+ * never ran because they were not connected yet ("never mind" after them): a card saved later replays neither. Every
741
+ * way a shopper message reaches the tool runs it: `turn`, `instantAck` and `instantReaction` (review of #9220).
742
+ */
743
+ const withdrawUnlessGoAhead = (shopperText) => {
744
+ if (keepsYes(String(shopperText ?? '')))
745
+ return;
746
+ carriedYes = null;
747
+ unconnectedAsk = null;
748
+ withdrawals++;
749
+ };
606
750
  /** The app's own instant reply: the turn its message opens sends no `onWorking` reply. */
607
751
  const instantAck = (shopperText) => {
752
+ withdrawUnlessGoAhead(shopperText);
608
753
  const { picked, text } = ackOfMessage(shopperText);
609
754
  appAck = { at: Date.now(), said: text !== null, picked };
610
755
  return text;
611
756
  };
757
+ /** The app's own reaction: the turn its message opens sends no reaction or reply of its own. */
758
+ const instantReaction = (shopperText) => (instantAck(shopperText) === null ? null : ACK_REACTION);
759
+ /**
760
+ * Puts the 👀 on the turn's message. Every call of the turn waits for it before it hands over a link,
761
+ * and one that throws costs only the reaction: `turn.reaction` goes back to null, and the model reads
762
+ * that the shopper was not told.
763
+ */
764
+ const react = (t, reacting) => {
765
+ t.acked = true;
766
+ t.reaction = ACK_REACTION;
767
+ let sent;
768
+ try {
769
+ sent = reacting(ACK_REACTION);
770
+ }
771
+ catch (error) {
772
+ sent = Promise.reject(error);
773
+ }
774
+ t.ackOut = Promise.resolve(sent).then(() => { t.ackDelivered = true; }, () => { t.reaction = null; });
775
+ };
612
776
  /**
613
777
  * Holds the instant reply for a new request until `ackDelayMs` passes. `cancel` stops one that has
614
778
  * not gone out, and frees the turn's reply for a later call.
615
779
  */
616
780
  const holdAck = (ask, t, step = null) => {
617
781
  const onWorking = t.onWorking;
618
- if (!onWorking || t.acked)
782
+ const reacting = t.onReaction;
783
+ if ((!onWorking && !reacting) || t.acked)
619
784
  return null;
620
785
  // A confirm, or a pick of what the shopper saw, gets fixed words that never say `looking for`.
621
786
  const slow = step ?? t.picked ?? (!t.neutral && (namesShown(ask) || isPickText(ask)) ? 'pick' : null);
622
787
  // A short answer to the question the last reply asked, such as `medium` after `what size?`, is no
623
788
  // search: like `yes`, a slow call still tells the shopper, in words that look for nothing.
624
789
  const neutral = t.neutral || answersQuestion(ask, lastReply);
625
- if (!slow && !neutral && !isNewRequest(ask))
790
+ // A slow call of any message may get the 👀, which reads nothing back; only a text reply needs one of these.
791
+ if (!reacting && !slow && !neutral && !isNewRequest(ask))
626
792
  return null;
627
793
  t.acked = true;
628
794
  let held = true;
629
795
  const timer = setTimeout(() => {
630
796
  held = false;
797
+ // With reactions, a slow call puts the message's one 👀 on it, and never adds a line of text.
798
+ if (reacting) {
799
+ react(t, reacting);
800
+ return;
801
+ }
802
+ if (!onWorking)
803
+ return;
631
804
  let text;
632
805
  if (slow) {
633
806
  text = slowStepLine(slow);
@@ -666,6 +839,40 @@ export function buyTool(options) {
666
839
  },
667
840
  };
668
841
  };
842
+ /**
843
+ * A message sent while the store builds the cart the last request asked for (`turn_in_progress` with `cart_building`) is
844
+ * never refused for it: the shopper hears the store is still building their cart, once, and the message waits until
845
+ * the cart is in, reading the conversation every two seconds for at most 90. True once the cart is in and the message
846
+ * can go; false for any other refusal, and when the wait gave up.
847
+ */
848
+ const holdForCart = async (error, t, quiet) => {
849
+ if (!(error instanceof AgentcardApiError) || error.status !== 409 || error.code !== 'turn_in_progress' || !error.conversationId)
850
+ return false;
851
+ const building = cartBuildingOf(error.body.cart_building);
852
+ if (!building)
853
+ return false;
854
+ // The acknowledgement this message was due goes nowhere: the shopper hears the cart is still building instead, and no
855
+ // search runs meanwhile for it to speak of (Greptile on #9652).
856
+ quiet?.();
857
+ try {
858
+ await t.tell?.(stillBuildingLine(building.store));
859
+ }
860
+ catch {
861
+ // The app's display failing never fails the purchase call.
862
+ }
863
+ // The 90 seconds hold however slow a read is: each read gets only the time left, so a read that hangs ends the wait.
864
+ const deadline = Date.now() + CART_HOLD_READS * CART_HOLD_EVERY_MS;
865
+ for (let i = 0; i < CART_HOLD_READS && Date.now() < deadline; i++) {
866
+ await wait(CART_HOLD_EVERY_MS);
867
+ const left = deadline - Date.now();
868
+ if (left <= 0)
869
+ break;
870
+ const conversation = await readWithin(error.conversationId, left);
871
+ if (conversation && !conversation.cart_building && !conversation.turn_in_progress)
872
+ return true;
873
+ }
874
+ return false;
875
+ };
669
876
  /** One read of the conversation, or null when it fails: a read only ever improves an answer the tool already has. */
670
877
  const read = async (conversationId) => {
671
878
  try {
@@ -675,6 +882,17 @@ export function buyTool(options) {
675
882
  return null;
676
883
  }
677
884
  };
885
+ /** One read that gives up after `ms`, as a failed read does: the read itself is no longer waited for. */
886
+ const readWithin = async (conversationId, ms) => {
887
+ let timer;
888
+ const late = new Promise((resolve) => { timer = setTimeout(() => resolve(null), ms); });
889
+ try {
890
+ return await Promise.race([read(conversationId), late]);
891
+ }
892
+ finally {
893
+ clearTimeout(timer);
894
+ }
895
+ };
678
896
  /** The order this purchase placed, or the reason it stopped when the ledger says it did. */
679
897
  const placed = (base, order, conversation) => {
680
898
  const conversationId = conversation.conversation_id || base.conversation_id;
@@ -761,7 +979,7 @@ export function buyTool(options) {
761
979
  ...base,
762
980
  status: 'approval_sent',
763
981
  reply: APPROVAL_WAITING,
764
- links: [{ kind: 'approval', url: last.approval_url, expiresAt: last.approval_expires_at }],
982
+ links: [approvalLink(last.approval_url, last.approval_expires_at, base.cart)],
765
983
  approval_expires_at: last.approval_expires_at,
766
984
  next_action: 'approve',
767
985
  shopper_need_not_reply: announced,
@@ -927,7 +1145,7 @@ export function buyTool(options) {
927
1145
  // The id the model reads and a pick takes, never the store page: the page is `url`.
928
1146
  const productId = pickId(product);
929
1147
  // A flight option has no page at a store: its card carries no url, and its productId picks it.
930
- await send({ kind: 'product', url: product.url || null, title: product.name, price: money(product.priceCents, product.currency), ...(productId ? { productId } : {}), imageUrl: product.imageUrl, expiresAt: null });
1148
+ await send({ kind: 'product', url: product.url || null, title: product.name, price: productPrice(product), ...(productId ? { productId } : {}), action: product.action ?? 'buy', imageUrl: product.imageUrl, images: product.images ?? (product.imageUrl ? [product.imageUrl] : []), expiresAt: null });
931
1149
  }
932
1150
  catch {
933
1151
  continue;
@@ -989,37 +1207,148 @@ export function buyTool(options) {
989
1207
  };
990
1208
  /** The Vault or approval link first, because the purchase waits on it, then the store pages, each to the call's own turn. */
991
1209
  const deliver = async (result, call, send) => {
992
- let delivered = result;
993
- if (result.links.length > 0 && send) {
994
- for (const link of result.links)
1210
+ // Each step link carries its stable id, in what the shopper is sent and in what the call returns.
1211
+ const stamped = result.links.some((link) => isActionLink(link) && !link.id)
1212
+ ? { ...result, links: result.links.map((link) => (isActionLink(link) && !link.id ? { ...link, id: actionLinkId(link) } : link)) }
1213
+ : result;
1214
+ let delivered = stamped;
1215
+ if (stamped.links.length > 0 && send) {
1216
+ for (const link of stamped.links)
995
1217
  await send({ ...link });
996
- delivered = { ...result, link_sent: true };
1218
+ delivered = { ...stamped, link_sent: true };
997
1219
  }
998
1220
  return deliverPagesInTurn(delivered, call, send);
999
1221
  };
1222
+ /**
1223
+ * What a shopper's own call leaves for a later `nextStep` turn: the yes a confirm gave, while the
1224
+ * confirm stopped for a card or a store sign-in; the add-card link an answer held back while it asked
1225
+ * for the sign-in first; and the words of a shopper who was not connected yet.
1226
+ */
1227
+ const noteShopperStep = (result, turn, call) => {
1228
+ // A shopper message that took things back while this call ran wins over what the call carried, and a newer call's
1229
+ // state stays as it is: an answer from before the withdrawal changes nothing here (review of #9220).
1230
+ if (call.withdrawalsAtStart !== withdrawals)
1231
+ return;
1232
+ const outside = result.status === 'needs_card' || result.next_action === 'add_card' || result.next_action === 'store_login';
1233
+ if (call.confirm)
1234
+ carriedYes = outside && call.yesHash ? { hash: call.yesHash } : null;
1235
+ if (result.next_action === 'store_login' && turn?.add_card_url)
1236
+ cardAfterSignIn = { url: turn.add_card_url, expiresAt: turn.add_card_expires_at };
1237
+ else if (result.next_action !== 'store_login')
1238
+ cardAfterSignIn = null;
1239
+ if (!call.confirm && call.ask !== '')
1240
+ unconnectedAsk = result.error_code === 'user_not_connected' ? call.ask : null;
1241
+ };
1242
+ /** An answer the tool writes itself, from where the purchase stands, with nothing sent to Agentcard. */
1243
+ const stepResult = (fields) => ({
1244
+ messages: [],
1245
+ cart: null,
1246
+ carts: [],
1247
+ products: [],
1248
+ links: [],
1249
+ link_sent: false,
1250
+ approval_expires_at: null,
1251
+ shopper_need_not_reply: false,
1252
+ conversation_id: null,
1253
+ order_id: null,
1254
+ decline_code: null,
1255
+ error_code: null,
1256
+ next_action: null,
1257
+ pick: null,
1258
+ stores: [],
1259
+ store_pick: null,
1260
+ hashes: [],
1261
+ envelope: {},
1262
+ ...fields,
1263
+ });
1264
+ /**
1265
+ * How a call of a `nextStep` turn goes. It never sends the model's ask, which Agentcard could read as
1266
+ * the shopper's go-ahead. It sends the shopper's own words that never ran because they were not
1267
+ * connected yet, once their card is saved, or a confirm of the one cart the shopper already said yes
1268
+ * to, by that cart's hash. Every other call answers from a read of the conversation: the card link the
1269
+ * sign-in held back, the cart and the ask for the shopper's yes, or the way forward after a decline.
1270
+ */
1271
+ const stepPlan = async (event) => {
1272
+ const conversationId = event.conversationId ?? lastConversationId;
1273
+ const conversation = conversationId ? await read(conversationId) : null;
1274
+ const carts = conversation ? conversation.carts : lastCarts;
1275
+ const one = carts.length === 1 ? carts[0] : null;
1276
+ const who = {
1277
+ ...(o.deliveryAddress ? { delivery_address: o.deliveryAddress } : {}),
1278
+ ...(client.company ? { external_user_id: externalUserId } : {}),
1279
+ };
1280
+ const standing = { cart: one ?? carts.at(-1) ?? null, carts, hashes: carts.map((c) => c.hash), conversation_id: conversationId };
1281
+ if (event.kind === 'approval_declined') {
1282
+ carriedYes = null;
1283
+ return { local: stepResult({ ...standing, status: 'needs_input', reply: carts.length > 0 ? STEP_AFTER_DECLINE : NO_CART_WAITING }) };
1284
+ }
1285
+ if (event.kind === 'card_added' && unconnectedAsk) {
1286
+ const ask = unconnectedAsk;
1287
+ unconnectedAsk = null;
1288
+ // Every purchase through the tool pays from the Vault, so a replayed ask names it too and its carts are quoted that way.
1289
+ return { body: { resume: true, ask, payment_source: 'vault', ...who } };
1290
+ }
1291
+ if (event.kind === 'store_connected' && cardAfterSignIn && !(Date.parse(cardAfterSignIn.expiresAt ?? '') <= Date.now())) {
1292
+ const link = cardAfterSignIn;
1293
+ cardAfterSignIn = null;
1294
+ return { local: stepResult({ ...standing, status: 'needs_card', reply: CARD_AFTER_SIGN_IN, links: [{ kind: 'vault', url: link.url, expiresAt: link.expiresAt }], next_action: 'add_card' }) };
1295
+ }
1296
+ if (carriedYes && one && conversationId && one.hash === carriedYes.hash) {
1297
+ carriedYes = null;
1298
+ return { body: { confirm: one.hash, conversation_id: conversationId, payment_source: 'vault', ...who } };
1299
+ }
1300
+ // A checkout the shopper already moved past their yes (an approval link waiting, or the order going through) is reported as
1301
+ // it stands: the cart stays while it waits, and asking for another yes would contradict the link they have (review of #9220).
1302
+ const last = conversation?.last_checkout ?? null;
1303
+ const approvalOpen = !!last?.approval_url && !(Date.parse(last.approval_expires_at ?? '') <= Date.now());
1304
+ // A checkout whose order the store already took (`pending` while the charge settles, with its order id) is reported as
1305
+ // placed, with that order id, as the recovery path does, never as still going through (review of #9220).
1306
+ const order = conversation && last?.order_id ? placedOrderOf(conversation, 0) : null;
1307
+ if (conversation && order)
1308
+ return { local: placed(stepResult({ ...standing, status: 'needs_input', reply: '' }), order, conversation) };
1309
+ if (conversation && placementRunning(conversation))
1310
+ return { local: stepResult({ ...standing, status: 'needs_input', reply: STEP_PLACEMENT_RUNNING }) };
1311
+ if (approvalOpen)
1312
+ return { local: stepResult({ ...standing, status: 'needs_input', reply: STEP_APPROVAL_WAITING, next_action: 'approve' }) };
1313
+ return { local: stepResult({ ...standing, status: 'needs_input', reply: carts.length > 0 ? ASK_FOR_YES : NO_CART_WAITING, next_action: carts.length > 0 ? 'confirm' : null }) };
1314
+ };
1000
1315
  /**
1001
1316
  * One call, for the turn `explicit` names, or, made straight on the tool, for the turn the tool reads
1002
1317
  * from its timing. Every link, answer and instant reply of the call goes to the turn it started in.
1003
1318
  */
1004
1319
  const answerIn = async (raw, explicit) => {
1005
1320
  const startedAt = Date.now();
1321
+ // A step link held for the message that introduces it goes out before this call hands over any of its own.
1322
+ explicit?.release?.();
1323
+ const withdrawalsAtStart = withdrawals;
1006
1324
  const input = parseBuyToolInput(raw);
1007
- // A new order is never a confirm: a model that sets both drops the cart and places nothing.
1325
+ // With carts at more than one store waiting, a new order would drop every one of them: "cancel the
1326
+ // reservation, keep the flowers" started a new conversation and lost the flowers cart (2026-10-20).
1327
+ // The ask goes to the same purchase instead, and Agentcard drops only the cart it names.
1328
+ const newOrder = input.new_order && lastCartCount < 2;
1329
+ // A new order is never a confirm: a model that sets both drops the cart and places nothing, even
1330
+ // when the ask stays in the same purchase ("cancel the reservation" never checks out a cart).
1008
1331
  const confirm = input.confirm && !input.new_order;
1009
1332
  const ask = input.ask.trim();
1010
1333
  if (!confirm && ask === '')
1011
1334
  throw new TypeError("Put the shopper's words in ask.");
1012
1335
  // A pick chooses from the products or stores this conversation showed: never on a confirm, and never on a new order, which shows none yet.
1013
- const picks = !confirm && !input.new_order;
1336
+ const picks = !confirm && !newOrder;
1014
1337
  const pick = picks && input.product_id ? { product_id: input.product_id } : picks && input.store_id ? { store_id: input.store_id } : null;
1015
1338
  const body = {
1016
1339
  resume: true,
1017
- ...(confirm ? { confirm: true, payment_source: 'vault' } : { ask }),
1018
- ...(input.new_order ? { new_order: true } : {}),
1340
+ ...(confirm ? { confirm: true } : { ask }),
1341
+ // Every purchase through the tool pays from the shopper's Vault, so every call says so: an ask's carts are then
1342
+ // quoted with the fees the Vault payment charges, and the estimate the shopper sees is the one the confirm pays
1343
+ // (2026-10-21: a cart read $15.67 on its asks and $17.09 at its confirm).
1344
+ payment_source: 'vault',
1345
+ ...(newOrder ? { new_order: true } : {}),
1019
1346
  ...(pick ? { pick } : {}),
1020
1347
  ...(o.deliveryAddress ? { delivery_address: o.deliveryAddress } : {}),
1021
1348
  ...(client.company ? { external_user_id: externalUserId } : {}),
1022
1349
  };
1350
+ // The cart a confirm sent now confirms, for the yes it carries past a step outside the chat.
1351
+ const yesHash = confirm ? lastShownHash : null;
1023
1352
  // The first call after `instantAck` opens the turn of the message it answered, whose reply is the
1024
1353
  // app's. Any other call that starts while another runs, or right after one ended, is the model
1025
1354
  // working through the same turn. A call made through `buy.turn` belongs to that turn alone.
@@ -1037,16 +1366,21 @@ export function buyTool(options) {
1037
1366
  }
1038
1367
  // The call keeps the turn it started in, whichever turn opens while it runs.
1039
1368
  const t = explicit ?? implicit;
1369
+ // A turn the tool took on its own sends no ask: it reads where the purchase stands, and only the
1370
+ // shopper's own yes to this exact cart goes in, for that cart's hash.
1371
+ const plan = t.event ? await stepPlan(t.event) : null;
1372
+ const sent = plan && 'body' in plan ? plan.body : body;
1373
+ const confirmSent = plan ? 'body' in plan && sent.confirm !== undefined : confirm;
1040
1374
  const callTurn = t.number;
1041
1375
  const send = senderOf(t);
1042
1376
  const progressOf = t.onProgress;
1043
1377
  // A confirm streams as a search does, and only a search's products go out early.
1044
1378
  const streams = o.stream !== false && ((earlyCards && !!send) || !!progressOf);
1045
- const early = earlyCards && !!send && !confirm;
1379
+ const early = earlyCards && !!send && !confirmSent;
1046
1380
  running += 1;
1047
1381
  // A confirm or a pick of what the shopper saw never hears "looking for": once the call is slow, it hears a
1048
1382
  // fixed line that names nothing they said (slowStepLine), so a 20-second checkout or menu read is never silent.
1049
- const ack = holdAck(ask, t, confirm ? 'confirm' : pick?.product_id ? 'product' : pick ? 'pick' : null);
1383
+ const ack = t.event ? null : holdAck(ask, t, confirm ? 'confirm' : pick?.product_id ? 'product' : pick ? 'pick' : null);
1050
1384
  try {
1051
1385
  let turn = null;
1052
1386
  let refusal;
@@ -1070,19 +1404,49 @@ export function buyTool(options) {
1070
1404
  : {}),
1071
1405
  }
1072
1406
  : undefined;
1073
- try {
1074
- turn = await client.buy(body, handlers);
1407
+ if (plan && 'local' in plan) {
1408
+ // Nothing went to Agentcard: the answer is where the purchase stands.
1409
+ }
1410
+ else {
1411
+ try {
1412
+ turn = await client.buy(sent, handlers);
1413
+ }
1414
+ catch (error) {
1415
+ refusal = error;
1416
+ }
1075
1417
  }
1076
- catch (error) {
1077
- refusal = error;
1418
+ // The store was still building the last request's cart: once it is in, the request that was refused goes as it was
1419
+ // sent, a step's own request too (a saved ask is no longer saved once its step sent it).
1420
+ if (!turn && !confirmSent && (await holdForCart(refusal, t, () => ack?.cancel()))) {
1421
+ try {
1422
+ turn = await client.buy(sent, handlers);
1423
+ refusal = undefined;
1424
+ }
1425
+ catch (error) {
1426
+ refusal = error;
1427
+ }
1078
1428
  }
1079
1429
  ack?.cancel();
1080
- const settled = turn ? await settleTurn(turn, startedAt) : await settleRefusal(refusal, confirm, startedAt);
1430
+ const settled = plan && 'local' in plan ? plan.local : turn ? await settleTurn(turn, startedAt) : await settleRefusal(refusal, confirmSent, startedAt);
1081
1431
  await t.ackOut;
1082
1432
  const delivered = await deliver(withMessages(settled, turn), { turn: callTurn, presented: turn?.catalog?.presented === true, owner: t }, send);
1083
1433
  const result = t.ackDelivered ? { ...delivered, shopper_already_told_looking: true } : delivered;
1084
1434
  lastNextAction = result.next_action;
1085
1435
  lastReply = result.reply;
1436
+ // A refusal reports no carts, so only a turn's answer says how many wait.
1437
+ if (turn) {
1438
+ lastCartCount = result.carts.length;
1439
+ lastCarts = result.carts;
1440
+ }
1441
+ if (result.conversation_id)
1442
+ lastConversationId = result.conversation_id;
1443
+ if (result.cart?.hash)
1444
+ lastShownHash = result.cart.hash;
1445
+ // A shopper's own call, or the shopper's yes or words a turn taken on its own sent, leaves what the next step needs.
1446
+ if (!t.event)
1447
+ noteShopperStep(result, turn, { confirm: !!confirm, yesHash, ask, withdrawalsAtStart });
1448
+ else if (plan && 'body' in plan)
1449
+ noteShopperStep(result, turn, { confirm: confirmSent, yesHash: typeof sent.confirm === 'string' ? sent.confirm : null, ask: sent.ask ?? '', withdrawalsAtStart });
1086
1450
  // An answer that lists products replaces the names, even when every one is too short to keep.
1087
1451
  const shown = shownProducts(result.products);
1088
1452
  if (shown.length > 0)
@@ -1146,10 +1510,37 @@ export function buyTool(options) {
1146
1510
  * with another message's, and it sends its instant reply at once, as `instantAck` would read it.
1147
1511
  */
1148
1512
  const turn = (shopperText, options) => {
1513
+ withdrawUnlessGoAhead(String(shopperText ?? ''));
1514
+ return openTurn(String(shopperText ?? ''), options, null);
1515
+ };
1516
+ /**
1517
+ * The turn the agent takes on its own after a step the shopper finished outside the chat, or null
1518
+ * when there is none to take (`BuyTool.nextStep`). The message is answered by the model's words, so
1519
+ * the client never hands its text to a later turn as words the shopper read.
1520
+ */
1521
+ const nextStep = (message, options) => {
1522
+ if (!message || typeof message !== 'object' || typeof message.kind !== 'string')
1523
+ throw new TypeError('Pass the message onMessage got.');
1524
+ if (message.advances !== true || isStale(message))
1525
+ return null;
1526
+ if (client.company && message.externalUserId !== externalUserId)
1527
+ return null;
1528
+ if (message.eventId && stepped.has(message.eventId))
1529
+ return null;
1530
+ // A call for the shopper's own message is still running: its answer tells the model where the purchase stands.
1531
+ if (running > 0)
1532
+ return null;
1533
+ if (message.eventId)
1534
+ stepped.add(message.eventId);
1535
+ if (client.company)
1536
+ client.answered(externalUserId, message);
1537
+ return openTurn(nextStepPrompt(message), options, message);
1538
+ };
1539
+ const openTurn = (shopperText, options, event) => {
1149
1540
  const own = options ?? {};
1150
1541
  if (typeof own !== 'object')
1151
1542
  throw new TypeError('Pass the turn options as an object, or leave them out.');
1152
- for (const key of ['sendLink', 'onWorking', 'onResult', 'onProgress', 'onItem']) {
1543
+ for (const key of ['sendLink', 'onWorking', 'onReaction', 'onResult', 'onProgress', 'onItem', 'onMessage']) {
1153
1544
  if (own[key] !== undefined && typeof own[key] !== 'function')
1154
1545
  throw new TypeError(`Pass ${key} to the turn as a function, or leave it out.`);
1155
1546
  }
@@ -1157,32 +1548,101 @@ export function buyTool(options) {
1157
1548
  if (onItem && (own.sendLink !== undefined || own.onWorking !== undefined)) {
1158
1549
  throw new TypeError('Pass onItem, or sendLink and onWorking, to the turn, not both: onItem hears every link and message of the turn.');
1159
1550
  }
1551
+ if (onItem && own.onMessage !== undefined) {
1552
+ throw new TypeError('Pass onItem or onMessage to the turn, not both: onItem hears every message of the turn.');
1553
+ }
1554
+ const onMessage = onItem ? undefined : own.onMessage;
1555
+ if (own.replyAsWritten !== undefined && typeof own.replyAsWritten !== 'boolean')
1556
+ throw new TypeError('Pass replyAsWritten to the turn as true or false, or leave it out.');
1557
+ const written = own.replyAsWritten ?? writtenByDefault;
1160
1558
  // Messages are spaced only for an app that hears each one as it goes out.
1161
1559
  const pauseOf = paceOf(own.pace);
1162
- const feed = itemFeed(onItem ? pauseOf : null, typingHook(own.onTyping));
1560
+ const feed = itemFeed(onItem || onMessage ? pauseOf : null, typingHook(own.onTyping));
1163
1561
  // With onItem, every link and message goes to it alone, in order. Without, each goes where it went before, and the turn lists it.
1164
1562
  const baseSendLink = onItem ? undefined : own.sendLink ?? sendLink;
1165
1563
  const baseOnWorking = onItem ? undefined : own.onWorking ?? onWorking;
1166
1564
  // `ack: false` sends no instant reply, whoever would hear it.
1167
1565
  const acks = o.ack !== false && (!!onItem || !!baseOnWorking);
1566
+ /**
1567
+ * Step links held for the message that asks for them. A turn whose model writes through it
1568
+ * (`onItem`, or `messageStream()`/`say()` once called) holds each step link a call hands over until
1569
+ * that message went out: the tool answers before the model writes, so the link used to reach the
1570
+ * chat above the words that explain it (2026-10-21). Product and store cards are what the message
1571
+ * is about, and go out at once. A held link goes out once the turn's next message ends, when the
1572
+ * next call starts, or after STEP_LINK_HOLD_MS, so no link is ever kept from the shopper.
1573
+ */
1574
+ const held = [];
1575
+ // `onMessage` says the app writes the model through the turn, so its links wait for the message too (review of #9234).
1576
+ let writesThroughTurn = !!onItem || !!onMessage;
1577
+ let holdTimer = null;
1578
+ let linkNow = () => { };
1579
+ // A held link that failed to go out when no reply was ending (its timer ran out, or the next call started): the next
1580
+ // reply that ends rejects with it, so the app learns the shopper is missing a link (review of #9234).
1581
+ let lateFailure = null;
1582
+ /** Every held link joins the feed now, in order: settled once each went out, rejected with the first failure. */
1583
+ const release = () => {
1584
+ if (holdTimer)
1585
+ clearTimeout(holdTimer);
1586
+ holdTimer = null;
1587
+ const sends = held.splice(0).map((link) => {
1588
+ try {
1589
+ return Promise.resolve(linkNow(link));
1590
+ }
1591
+ catch (error) {
1592
+ return Promise.reject(error);
1593
+ }
1594
+ });
1595
+ return Promise.all(sends).then(() => undefined);
1596
+ };
1597
+ const releaseLate = () => {
1598
+ release().catch((error) => {
1599
+ lateFailure ??= { error };
1600
+ });
1601
+ };
1602
+ /** The release a reply ends with: it waits for the held links, and fails with theirs or an earlier late failure. */
1603
+ const releaseForReply = async () => {
1604
+ await release();
1605
+ if (lateFailure) {
1606
+ const { error } = lateFailure;
1607
+ lateFailure = null;
1608
+ throw error;
1609
+ }
1610
+ };
1168
1611
  const state = {
1169
1612
  ...toolTurn(++modelTurn),
1613
+ event,
1170
1614
  sendLink: (link) => feed.push({ kind: 'link', link }, () => (onItem ? onItem({ kind: 'link', link: { ...link } }) : baseSendLink?.(link))),
1171
- onWorking: acks ? (text) => feed.push({ kind: 'text', text }, () => (onItem ? onItem({ kind: 'text', text }) : baseOnWorking?.(text))) : undefined,
1615
+ // A reaction goes to onReaction even with onItem: it sits on the shopper's bubble, not in the turn's sequence.
1616
+ ...ackRoutes(acks ? (text) => feed.push({ kind: 'text', text }, () => (onItem ? onItem({ kind: 'text', text }) : baseOnWorking?.(text))) : undefined, own.onReaction ?? o.onReaction),
1172
1617
  onResult: own.onResult ?? onResult,
1173
1618
  onProgress: own.onProgress ?? onProgress,
1619
+ tell: onItem || baseOnWorking ? (text) => feed.push({ kind: 'text', text }, () => (onItem ? onItem({ kind: 'text', text }) : baseOnWorking?.(text))) : undefined,
1174
1620
  links: [],
1175
1621
  results: [],
1176
1622
  };
1177
- /** A message the app's model wrote: it goes to onItem, after every item before it, and the turn lists it. */
1178
- const text = (message, skip) => feed.push({ kind: 'text', text: message }, () => onItem?.({ kind: 'text', text: message }), skip);
1623
+ linkNow = state.sendLink;
1624
+ state.release = releaseLate;
1625
+ state.sendLink = (link) => {
1626
+ if (!writesThroughTurn || !STEP_LINK_KINDS.has(link.kind))
1627
+ return linkNow(link);
1628
+ held.push({ ...link });
1629
+ if (!holdTimer) {
1630
+ holdTimer = setTimeout(releaseLate, STEP_LINK_HOLD_MS);
1631
+ holdTimer.unref?.();
1632
+ }
1633
+ return Promise.resolve();
1634
+ };
1635
+ /** A message the app's model wrote: it goes to onItem, else onMessage, after every item before it, and the turn lists it. */
1636
+ const text = (message, skip) => feed.push({ kind: 'text', text: message }, () => (onItem ? onItem({ kind: 'text', text: message }) : onMessage?.(message)), skip);
1179
1637
  let ack = null;
1180
1638
  const working = state.onWorking;
1181
- if (working) {
1639
+ const reacting = state.onReaction;
1640
+ // A turn the agent takes on its own answers no shopper message, so nothing gets a 👀 or an instant reply.
1641
+ if (!event && (working || reacting)) {
1182
1642
  const message = String(shopperText ?? '');
1183
1643
  const read = ackOfMessage(message);
1184
1644
  let text = read.text;
1185
- if (text !== null && writeAck) {
1645
+ if (text !== null && writeAck && working) {
1186
1646
  try {
1187
1647
  text = writeAck(message) ?? null;
1188
1648
  }
@@ -1199,7 +1659,11 @@ export function buyTool(options) {
1199
1659
  // A message that starts no search, such as `yes`: a slow call still tells the shopper, in words that name nothing.
1200
1660
  state.neutral = true;
1201
1661
  }
1202
- else if (text !== null) {
1662
+ else if (reacting) {
1663
+ // A search gets its 👀 the moment the turn opens, and no text: the reaction reads nothing back.
1664
+ react(state, reacting);
1665
+ }
1666
+ else if (text !== null && working) {
1203
1667
  ack = text;
1204
1668
  state.acked = true;
1205
1669
  let sent;
@@ -1256,23 +1720,47 @@ export function buyTool(options) {
1256
1720
  };
1257
1721
  return toolOf((raw) => answerIn(raw, state), {}, {
1258
1722
  agentcardMessages: { get: () => [...(handed ?? kept)], enumerable: false },
1723
+ event: { value: event, enumerable: false },
1724
+ release: {
1725
+ value: () => {
1726
+ if (!event)
1727
+ return;
1728
+ if (event.eventId)
1729
+ stepped.delete(event.eventId);
1730
+ if (client.company)
1731
+ client.unanswered(externalUserId, event);
1732
+ },
1733
+ enumerable: false,
1734
+ },
1259
1735
  messages: { value: messagesFor, enumerable: false },
1260
1736
  ack: { value: ack, enumerable: false },
1737
+ reaction: { get: () => state.reaction, enumerable: false },
1261
1738
  links: { get: () => [...links], enumerable: false },
1262
1739
  results: { get: () => [...results], enumerable: false },
1740
+ replyAsWritten: { get: () => (written && results.length > 0 ? replyAsWritten(results[results.length - 1]) : null), enumerable: false },
1263
1741
  items: { get: () => feed.items.map((item) => (item.kind === 'link' ? { kind: 'link', link: { ...item.link } } : { ...item })), enumerable: false },
1264
1742
  // 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.
1265
- messageStream: { value: () => feedStream(text), enumerable: false },
1743
+ // A step link held for this message goes out right after its last paragraph, once the stream ends.
1744
+ messageStream: {
1745
+ value: () => {
1746
+ writesThroughTurn = true;
1747
+ return feedStream(text, releaseForReply);
1748
+ },
1749
+ enumerable: false,
1750
+ },
1266
1751
  say: {
1267
1752
  value: (whole) => {
1753
+ writesThroughTurn = true;
1268
1754
  const reply = feedReply(text);
1269
1755
  for (const message of toMessages(whole))
1270
1756
  reply.add(message);
1271
- return reply.settled();
1757
+ const released = releaseForReply();
1758
+ released.catch(() => { });
1759
+ return reply.settled().then(() => released);
1272
1760
  },
1273
1761
  enumerable: false,
1274
1762
  },
1275
1763
  });
1276
1764
  };
1277
- return toolOf(answer, { instantAck, turn }, {});
1765
+ return toolOf(answer, { instantAck, instantReaction, turn, nextStep }, {});
1278
1766
  }