@agent-cards/commerce-sdk 0.15.0 → 0.17.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/README.md +56 -9
- package/dist/ack.d.ts +40 -20
- package/dist/ack.js +93 -5
- package/dist/browser.d.ts +1 -0
- package/dist/browser.js +2 -0
- package/dist/buy-tool.d.ts +61 -15
- package/dist/buy-tool.js +491 -41
- package/dist/client.d.ts +38 -0
- package/dist/client.js +15 -1
- package/dist/definition.js +3 -0
- package/dist/direct.d.ts +16 -3
- package/dist/direct.js +34 -7
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -1
- package/dist/messages.d.ts +27 -6
- package/dist/messages.js +72 -12
- package/dist/model-view.d.ts +51 -4
- package/dist/model-view.js +392 -42
- package/dist/order.d.ts +119 -0
- package/dist/order.js +155 -0
- package/dist/sentences.d.ts +16 -0
- package/dist/sentences.js +57 -0
- package/dist/shopper-message.d.ts +10 -0
- package/dist/shopper-message.js +51 -11
- package/dist/texting.d.ts +83 -0
- package/dist/texting.js +352 -0
- package/package.json +1 -1
package/dist/buy-tool.js
CHANGED
|
@@ -33,12 +33,13 @@
|
|
|
33
33
|
*/
|
|
34
34
|
import { AgentcardApiError, AgentcardClient, cartBuildingOf, } from './client.js';
|
|
35
35
|
import { buyToolDefinition, buyToolStandardSchema, openAIBuyTool, parseBuyToolInput, } from './definition.js';
|
|
36
|
-
import { directCall } from './direct.js';
|
|
37
|
-
import { forModel, forModelError, pickId, productPrice, productCardKey,
|
|
38
|
-
import {
|
|
36
|
+
import { directCall, isProductAsk } from './direct.js';
|
|
37
|
+
import { forModel, forModelError, pickId, productPrice, productCardKey, shownProducts, shownStores, stripLinks } from './model-view.js';
|
|
38
|
+
import { choiceLines, leadsIntoList, messageForShopper, messagesListChoice, replyAsWritten, textingStyle } from './texting.js';
|
|
39
|
+
import { joinLeadIns, leadInJoiner, messageStream, paceOf, paragraphsOf, sleep, toMessages, typingHook } from './messages.js';
|
|
39
40
|
import { isStale } from './shopper-message.js';
|
|
40
41
|
import { actionLinkId, isActionLink } from './action-links.js';
|
|
41
|
-
import { ACK_REACTION, ackAt, answersQuestion, instantAck as instantAckOf, isNewRequest, isPickText, keepsYes, namesStore, nextTemplate,
|
|
42
|
+
import { ACK_REACTION, ackAt, answersCheckout, answersQuestion, holdsBack, instantAck as instantAckOf, isNewRequest, isPickText, keepsYes, namesStore, nextTemplate, readsAsAckLine, slowStepLine } from './ack.js';
|
|
42
43
|
/** The action a product of `merchant` takes (BuyToolProductAction). */
|
|
43
44
|
export function productAction(merchant) {
|
|
44
45
|
if (merchant === 'reservations')
|
|
@@ -109,8 +110,8 @@ const RESUME_SKEW_MS = 2 * 60_000;
|
|
|
109
110
|
const STOPPED_ORDER = new Set(['failed', 'cancelled']);
|
|
110
111
|
/** The refusals a confirm gets once the order took the cart, or while the placement still runs. */
|
|
111
112
|
const AFTER_PLACEMENT = new Set(['nothing_to_confirm', 'conversation_closed', 'turn_in_progress']);
|
|
112
|
-
/** How long
|
|
113
|
-
const ACK_DELAY_MS =
|
|
113
|
+
/** How long a turn shows the shopper nothing before the line that says it is on it goes out. */
|
|
114
|
+
const ACK_DELAY_MS = 1_000;
|
|
114
115
|
/** A call that starts this soon after the last one ended belongs to the same model turn, which gets one instant reply. */
|
|
115
116
|
const SAME_TURN_MS = 15_000;
|
|
116
117
|
/** A product name shorter than this, such as `AA`, is too short to tell a pick from a search. */
|
|
@@ -145,6 +146,70 @@ const NOTHING_TO_CONFIRM = 'There is no cart to confirm yet. Ask the shopper wha
|
|
|
145
146
|
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.';
|
|
146
147
|
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.";
|
|
147
148
|
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.";
|
|
149
|
+
/** One cart line as the order holds it: the product, its variant, its choices and its quantity. Prices are left out. */
|
|
150
|
+
const lineKey = (line) => JSON.stringify([line.product_id ?? String(line.name ?? '').trim().toLowerCase(), line.variant ?? null, [...(line.options ?? [])].sort(), line.qty]);
|
|
151
|
+
/**
|
|
152
|
+
* How two matched lines' prices stand: false when a price they both name moved, true when one they both name agrees, and
|
|
153
|
+
* null when they name none in common, so the lines say nothing about what they cost.
|
|
154
|
+
*/
|
|
155
|
+
function linePriceStands(a, b) {
|
|
156
|
+
const named = (x, y) => typeof x === 'number' && typeof y === 'number' ? x === y : null;
|
|
157
|
+
const read = [named(a.priceCents, b.priceCents), named(a.lineTotalCents, b.lineTotalCents)];
|
|
158
|
+
if (read.includes(false))
|
|
159
|
+
return false;
|
|
160
|
+
return read.includes(true) ? true : null;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* True when two carts are the same order: the same store, the same items in the same quantities with the same choices,
|
|
164
|
+
* and the same price for those items. Only the total may differ, as when the store prices its fees, tax and delivery in
|
|
165
|
+
* once the shopper's account holds the cart (Mendocino Farms on DoorDash, 2026-10-27: about $15.90, then $17.89).
|
|
166
|
+
*
|
|
167
|
+
* What the items cost has to be shown to be the same: every matched line's prices agree, or both carts name a subtotal
|
|
168
|
+
* and it is equal. A line whose price moved, two lines whose prices swapped under one subtotal, and a cart nothing
|
|
169
|
+
* prices are each a different order, so the shopper is asked again (review of #11087).
|
|
170
|
+
*/
|
|
171
|
+
function sameOrder(a, b) {
|
|
172
|
+
if (a.merchant !== b.merchant || (a.store?.id ?? null) !== (b.store?.id ?? null))
|
|
173
|
+
return false;
|
|
174
|
+
if ((a.currency ?? null) !== (b.currency ?? null))
|
|
175
|
+
return false;
|
|
176
|
+
const subtotals = typeof a.subtotalCents === 'number' && typeof b.subtotalCents === 'number' ? a.subtotalCents === b.subtotalCents : null;
|
|
177
|
+
if (subtotals === false)
|
|
178
|
+
return false;
|
|
179
|
+
const lines = (cart) => [...(cart.items ?? [])].sort((p, q) => (lineKey(p) < lineKey(q) ? -1 : lineKey(p) > lineKey(q) ? 1 : 0));
|
|
180
|
+
const [x, y] = [lines(a), lines(b)];
|
|
181
|
+
if (x.length === 0 || x.length !== y.length || !x.every((line, i) => lineKey(line) === lineKey(y[i])))
|
|
182
|
+
return false;
|
|
183
|
+
const prices = x.map((line, i) => linePriceStands(line, y[i]));
|
|
184
|
+
if (prices.includes(false))
|
|
185
|
+
return false;
|
|
186
|
+
return subtotals === true || prices.every((stands) => stands === true);
|
|
187
|
+
}
|
|
188
|
+
/** True when `now` holds an item `before` did not: the call put something new in the cart. */
|
|
189
|
+
function addsItem(now, before) {
|
|
190
|
+
if (!now || (now.items ?? []).length === 0)
|
|
191
|
+
return false;
|
|
192
|
+
const had = new Set((before && before.merchant === now.merchant ? before.items ?? [] : []).map((line) => lineKey({ ...line, qty: 0 })));
|
|
193
|
+
return (now.items ?? []).some((line) => !had.has(lineKey({ ...line, qty: 0 })));
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* The yes left standing when a confirm stopped for a step outside the chat, or null. The cart the confirm stopped on must
|
|
197
|
+
* be the same order as the one the yes covers; the total the shopper said yes to stays the one they saw.
|
|
198
|
+
*/
|
|
199
|
+
function yesAfter(result, yes) {
|
|
200
|
+
const stopped = result.cart ?? (result.carts.length === 1 ? result.carts[0] : null);
|
|
201
|
+
if ('carried' in yes) {
|
|
202
|
+
if (stopped && !sameOrder(stopped, yes.carried.cart))
|
|
203
|
+
return null;
|
|
204
|
+
return { ...yes.carried, cart: stopped ?? yes.carried.cart, conversationId: result.conversation_id ?? yes.carried.conversationId };
|
|
205
|
+
}
|
|
206
|
+
const seen = yes.shown ?? stopped;
|
|
207
|
+
if (!seen || (yes.shown && stopped && !sameOrder(yes.shown, stopped)))
|
|
208
|
+
return null;
|
|
209
|
+
return { conversationId: result.conversation_id, cart: stopped ?? seen, totalCents: seen.totalCents };
|
|
210
|
+
}
|
|
211
|
+
/** The words of the call a `nextStep` turn's `direct` makes. They never reach Agentcard: the turn's calls send no ask. */
|
|
212
|
+
const NEXT_STEP_ASK = 'take the next step';
|
|
148
213
|
/** What a call of a `nextStep` turn answers with when the shopper has not said yes to the cart on the table. */
|
|
149
214
|
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.";
|
|
150
215
|
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.';
|
|
@@ -264,6 +329,7 @@ function answerOf(turn) {
|
|
|
264
329
|
store_pick: turn.store_pick,
|
|
265
330
|
hashes: hashesOf(turn.cart, turn.carts),
|
|
266
331
|
cart_building: turn.cart_building,
|
|
332
|
+
choice: turn.choice ?? null,
|
|
267
333
|
envelope: turn.raw,
|
|
268
334
|
};
|
|
269
335
|
}
|
|
@@ -304,7 +370,8 @@ function onlyNextStepLinks(result, next) {
|
|
|
304
370
|
*/
|
|
305
371
|
function withMessages(result, turn) {
|
|
306
372
|
const sent = turn && result.reply === turn.reply && turn.messages.length > 0 ? turn.messages : [result.reply];
|
|
307
|
-
|
|
373
|
+
// One joiner across every message, so a message that is only a lead-in goes with the next one rather than out of the list.
|
|
374
|
+
return { ...result, messages: joinLeadIns(sent.flatMap(paragraphsOf)) };
|
|
308
375
|
}
|
|
309
376
|
/** True when the app showed every message of an answer that has one, each as written and in order. */
|
|
310
377
|
function readEvery(told, messages) {
|
|
@@ -484,6 +551,8 @@ function senderOf(t) {
|
|
|
484
551
|
*/
|
|
485
552
|
function itemFeed(pauseOf, typing) {
|
|
486
553
|
const items = [];
|
|
554
|
+
// How many items joined the feed, sent or still going out: one means the shopper has something to read.
|
|
555
|
+
let pushed = 0;
|
|
487
556
|
// The item going out now, or null when none is: an item pushed while none is goes out at once.
|
|
488
557
|
let tail = null;
|
|
489
558
|
let lastAt = null;
|
|
@@ -518,6 +587,7 @@ function itemFeed(pauseOf, typing) {
|
|
|
518
587
|
* unlisted, with no pause, as for the rest of a reply whose earlier message failed.
|
|
519
588
|
*/
|
|
520
589
|
const push = (item, send, skip = () => false) => {
|
|
590
|
+
pushed += 1;
|
|
521
591
|
// Each message's pause is chosen once, when it joins the feed, so a pace that varies never makes it wait twice.
|
|
522
592
|
const pause = item.kind === 'text' && pauseOf ? pauseOf(item.text) : 0;
|
|
523
593
|
const waitFor = () => (pause > 0 && lastAt !== null ? lastAt + pause - Date.now() : 0);
|
|
@@ -545,7 +615,7 @@ function itemFeed(pauseOf, typing) {
|
|
|
545
615
|
tail = settled;
|
|
546
616
|
return run;
|
|
547
617
|
};
|
|
548
|
-
return { items, push };
|
|
618
|
+
return { items, push, pushed: () => pushed };
|
|
549
619
|
}
|
|
550
620
|
/**
|
|
551
621
|
* One reply's messages in a turn's feed: each one takes its place in the feed the moment it is cut,
|
|
@@ -682,6 +752,8 @@ export function buyTool(options) {
|
|
|
682
752
|
throw new TypeError('Pass earlyCards as true or false.');
|
|
683
753
|
// A search's cards go out the moment it answers, unless the app waits for the answer that names them.
|
|
684
754
|
const earlyCards = o.earlyCards !== false;
|
|
755
|
+
// Every call streams unless the app waits for whole answers (`stream: false`).
|
|
756
|
+
const streamCalls = o.stream !== false;
|
|
685
757
|
if (o.onResult !== undefined && typeof o.onResult !== 'function') {
|
|
686
758
|
throw new TypeError('Pass onResult as a function that takes each answer.');
|
|
687
759
|
}
|
|
@@ -689,6 +761,9 @@ export function buyTool(options) {
|
|
|
689
761
|
if (o.replyAsWritten !== undefined && typeof o.replyAsWritten !== 'boolean')
|
|
690
762
|
throw new TypeError('Pass replyAsWritten as true or false.');
|
|
691
763
|
const writtenByDefault = o.replyAsWritten === true;
|
|
764
|
+
if (o.textingStyle !== undefined && typeof o.textingStyle !== 'boolean')
|
|
765
|
+
throw new TypeError('Pass textingStyle as true or false.');
|
|
766
|
+
const textingByDefault = o.textingStyle === true;
|
|
692
767
|
if (o.direct !== undefined && typeof o.direct !== 'boolean')
|
|
693
768
|
throw new TypeError('Pass direct as true or false.');
|
|
694
769
|
const directOn = o.direct === true;
|
|
@@ -749,6 +824,7 @@ export function buyTool(options) {
|
|
|
749
824
|
onAvailability: o.onAvailability,
|
|
750
825
|
onProducts: o.onProducts,
|
|
751
826
|
onText: o.onText,
|
|
827
|
+
texting: textingByDefault,
|
|
752
828
|
onCart: o.onCart,
|
|
753
829
|
links: null,
|
|
754
830
|
results: null,
|
|
@@ -781,14 +857,25 @@ export function buyTool(options) {
|
|
|
781
857
|
/** The conversation and the carts of the last answer, for a `nextStep` call that cannot read the conversation. */
|
|
782
858
|
let lastConversationId = null;
|
|
783
859
|
let lastCarts = [];
|
|
784
|
-
/** The
|
|
785
|
-
let
|
|
860
|
+
/** The cart the last answer showed: the cart a confirm sent now confirms. */
|
|
861
|
+
let lastShownCart = null;
|
|
786
862
|
/**
|
|
787
|
-
* The yes the shopper gave in the chat to one cart,
|
|
788
|
-
*
|
|
789
|
-
*
|
|
863
|
+
* The yes the shopper gave in the chat to one cart, when the confirm it sent stopped for a step outside the chat: a card
|
|
864
|
+
* to add or a store to sign in to. A `nextStep` turn confirms the cart once the step is done, as long as it holds the
|
|
865
|
+
* same items at the same store (`sameOrder`): a total that moved because the store priced its fees and tax in keeps the
|
|
866
|
+
* yes, and the turn says the new total. Any shopper message that is not a plain go-ahead, `ok` or `done` takes it back,
|
|
867
|
+
* and so does a declined approval.
|
|
790
868
|
*/
|
|
791
869
|
let carriedYes = null;
|
|
870
|
+
/**
|
|
871
|
+
* The go-ahead of the shopper's pick, when they picked what is in the cart and answered only the checkout's questions
|
|
872
|
+
* since (address, phone, name, email, a yes to an address read back). A `nextStep` turn after a saved card or a store
|
|
873
|
+
* sign-in confirms that cart as it stands, when it is the same order (`sameOrder`), whatever the Vault does: the approval
|
|
874
|
+
* link is the shopper's approval. Any other shopper message takes it back, and so do a cart that changed and a declined approval.
|
|
875
|
+
*/
|
|
876
|
+
let pickedOrder = null;
|
|
877
|
+
/** How many shopper messages took a pick's go-ahead back: a call that started before one leaves no pick behind. */
|
|
878
|
+
let pickWithdrawals = 0;
|
|
792
879
|
/** The add-card link an answer carried while it asked for the store sign-in first: the card is the step after it. */
|
|
793
880
|
let cardAfterSignIn = null;
|
|
794
881
|
/** The shopper's words that never ran because they were not connected yet: they run once their card is saved. */
|
|
@@ -809,6 +896,8 @@ export function buyTool(options) {
|
|
|
809
896
|
};
|
|
810
897
|
/** What the last answer showed, products and stores with the ids a pick sends, and the turn the shopper opened last. */
|
|
811
898
|
let lastAnswerProducts = [];
|
|
899
|
+
/** Every product card of the last answer's call the shopper can tap by name, each once (the answer's and the streamed). */
|
|
900
|
+
let lastTappable = [];
|
|
812
901
|
let lastAnswerStores = [];
|
|
813
902
|
let previousTurn = null;
|
|
814
903
|
/** The names of the stores the last answer that listed stores showed: a message that names one, as a tap on its card does, picks it. */
|
|
@@ -834,7 +923,17 @@ export function buyTool(options) {
|
|
|
834
923
|
* way a shopper message reaches the tool runs it: `turn`, `instantAck` and `instantReaction` (review of #9220).
|
|
835
924
|
*/
|
|
836
925
|
const withdrawUnlessGoAhead = (shopperText) => {
|
|
837
|
-
|
|
926
|
+
const text = String(shopperText ?? '');
|
|
927
|
+
const goAhead = keepsYes(text);
|
|
928
|
+
// A pick's go-ahead also stands through the answers the checkout asks for, and through the answer that lets the store
|
|
929
|
+
// cart the pick, such as a size, while no cart holds it yet.
|
|
930
|
+
// Counted with no pick standing too, so a pick still running when the shopper says "no wait" leaves none behind.
|
|
931
|
+
// A short `cancel`, `wait` or `no` answers the question's shape, so it never counts as the answer the pick waits on.
|
|
932
|
+
if (!goAhead && !answersCheckout(text, { nextAction: lastNextAction, asked: lastReply }) && !(pickedOrder?.cart === null && answersQuestion(text, lastReply) && !holdsBack(text))) {
|
|
933
|
+
pickedOrder = null;
|
|
934
|
+
pickWithdrawals++;
|
|
935
|
+
}
|
|
936
|
+
if (goAhead)
|
|
838
937
|
return;
|
|
839
938
|
carriedYes = null;
|
|
840
939
|
unconnectedAsk = null;
|
|
@@ -873,8 +972,42 @@ export function buyTool(options) {
|
|
|
873
972
|
const holdAck = (ask, t, step = null) => {
|
|
874
973
|
const onWorking = t.onWorking;
|
|
875
974
|
const reacting = t.onReaction;
|
|
876
|
-
|
|
975
|
+
// A turn the app ended (`turn.end()`) holds no acknowledgement for a call it still runs.
|
|
976
|
+
if ((!onWorking && !reacting) || t.acked || t.ended)
|
|
877
977
|
return null;
|
|
978
|
+
const model = t.modelTurn;
|
|
979
|
+
if (model && onWorking && model.writesThrough()) {
|
|
980
|
+
// The app's model called the tool. When it wrote its own line first, that line is the turn's acknowledgement; when
|
|
981
|
+
// its first output is this call, the shopper hears the wait line, no sooner than ackDelayMs after the turn opened.
|
|
982
|
+
model.callStarted = true;
|
|
983
|
+
if (model.spoke || model.shown())
|
|
984
|
+
return null;
|
|
985
|
+
t.acked = true;
|
|
986
|
+
const line = slowStepLine(step === 'confirm' ? 'confirm' : step ?? t.picked ?? 'wait');
|
|
987
|
+
let waiting = true;
|
|
988
|
+
const waitTimer = setTimeout(() => {
|
|
989
|
+
waiting = false;
|
|
990
|
+
// The model may have written its own line while this call ran: that line is the turn's acknowledgement, so the
|
|
991
|
+
// wait line is dropped and the turn's reply is free for a later call.
|
|
992
|
+
if (model.spoke || model.shown() || t.ended) {
|
|
993
|
+
t.acked = false;
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
t.waitLineOut = true;
|
|
997
|
+
t.ackOut = Promise.resolve()
|
|
998
|
+
.then(() => onWorking(line))
|
|
999
|
+
.then(() => { t.ackDelivered = true; }, () => { });
|
|
1000
|
+
}, Math.max(0, model.openedAt + ackDelayMs - Date.now()));
|
|
1001
|
+
return {
|
|
1002
|
+
cancel: () => {
|
|
1003
|
+
if (!waiting)
|
|
1004
|
+
return;
|
|
1005
|
+
waiting = false;
|
|
1006
|
+
clearTimeout(waitTimer);
|
|
1007
|
+
t.acked = false;
|
|
1008
|
+
},
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
878
1011
|
// A confirm, or a pick of what the shopper saw, gets fixed words that never say `looking for`.
|
|
879
1012
|
const slow = step ?? t.picked ?? (!t.neutral && (namesShown(ask) || isPickText(ask)) ? 'pick' : null);
|
|
880
1013
|
// A short answer to the question the last reply asked, such as `medium` after `what size?`, is no
|
|
@@ -883,10 +1016,19 @@ export function buyTool(options) {
|
|
|
883
1016
|
// A slow call of any message may get the 👀, which reads nothing back; only a text reply needs one of these.
|
|
884
1017
|
if (!reacting && !slow && !neutral && !isNewRequest(ask))
|
|
885
1018
|
return null;
|
|
1019
|
+
// A streamed search's line is Agentcard's (onAck), written from the conversation while it searches: the tool adds no
|
|
1020
|
+
// template of its own, unless the app writes the words.
|
|
1021
|
+
if (!reacting && !slow && !neutral && streamCalls && !writeAck)
|
|
1022
|
+
return null;
|
|
886
1023
|
t.acked = true;
|
|
887
1024
|
let held = true;
|
|
888
1025
|
const timer = setTimeout(() => {
|
|
889
1026
|
held = false;
|
|
1027
|
+
// The app ended the turn while this call ran, as after its model failed: no 👀 and no line follow its error.
|
|
1028
|
+
if (t.ended) {
|
|
1029
|
+
t.acked = false;
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
890
1032
|
// With reactions, a slow call puts the message's one 👀 on it, and never adds a line of text.
|
|
891
1033
|
if (reacting) {
|
|
892
1034
|
react(t, reacting);
|
|
@@ -911,7 +1053,7 @@ export function buyTool(options) {
|
|
|
911
1053
|
lastTemplate = nextTemplate(lastTemplate);
|
|
912
1054
|
// A neutral turn's message, such as `yes` or `ok lets checkout`, is no search: its reply says the
|
|
913
1055
|
// tool is working, and never that it is looking for anything.
|
|
914
|
-
text = neutral ?
|
|
1056
|
+
text = neutral ? slowStepLine('wait') : ackAt(lastTemplate, ask);
|
|
915
1057
|
}
|
|
916
1058
|
if (typeof text !== 'string' || text.trim() === '') {
|
|
917
1059
|
t.acked = false;
|
|
@@ -1322,7 +1464,7 @@ export function buyTool(options) {
|
|
|
1322
1464
|
return;
|
|
1323
1465
|
const outside = result.status === 'needs_card' || result.next_action === 'add_card' || result.next_action === 'store_login';
|
|
1324
1466
|
if (call.confirm)
|
|
1325
|
-
carriedYes = outside
|
|
1467
|
+
carriedYes = outside ? yesAfter(result, call.yes) : null;
|
|
1326
1468
|
if (result.next_action === 'store_login' && turn?.add_card_url)
|
|
1327
1469
|
cardAfterSignIn = { url: turn.add_card_url, expiresAt: turn.add_card_expires_at };
|
|
1328
1470
|
else if (result.next_action !== 'store_login')
|
|
@@ -1330,6 +1472,45 @@ export function buyTool(options) {
|
|
|
1330
1472
|
if (!call.confirm && call.ask !== '')
|
|
1331
1473
|
unconnectedAsk = result.error_code === 'user_not_connected' ? call.ask : null;
|
|
1332
1474
|
};
|
|
1475
|
+
/**
|
|
1476
|
+
* What a shopper's own call leaves of a pick's go-ahead (`pickedOrder`). A pick, a tap on a product's card or a request
|
|
1477
|
+
* to buy it, that leaves one cart and no list of products to choose from starts one, with that cart; one whose store still
|
|
1478
|
+
* asks a question before it carts the pick starts one with no cart yet. A later answer keeps it while the cart stays the
|
|
1479
|
+
* same order, and notes the cart as the chat shows it now; an answer whose cart changed, another purchase, more than one
|
|
1480
|
+
* cart, or a confirm of the shopper's own ends it. `heard` is false for a call whose shopper message no turn read
|
|
1481
|
+
* (`withdrawUnlessGoAhead`): its words must then answer the checkout too.
|
|
1482
|
+
*/
|
|
1483
|
+
const notePick = (result, call) => {
|
|
1484
|
+
if (call.pickWithdrawalsAtStart !== pickWithdrawals)
|
|
1485
|
+
return;
|
|
1486
|
+
const now = result.cart ?? (result.carts.length === 1 ? result.carts[0] : null);
|
|
1487
|
+
if (call.picking) {
|
|
1488
|
+
// A search that listed products beside a cart already there picked nothing in it; one that carted what it names did.
|
|
1489
|
+
const listed = shownProducts(result.products).length > 0;
|
|
1490
|
+
pickedOrder = result.carts.length > 1
|
|
1491
|
+
? null
|
|
1492
|
+
: now && (!listed || call.added)
|
|
1493
|
+
? { conversationId: result.conversation_id, cart: now }
|
|
1494
|
+
: !now && !listed && result.error_code === null
|
|
1495
|
+
? { conversationId: result.conversation_id, cart: null }
|
|
1496
|
+
: null;
|
|
1497
|
+
return;
|
|
1498
|
+
}
|
|
1499
|
+
const picked = pickedOrder;
|
|
1500
|
+
if (!picked)
|
|
1501
|
+
return;
|
|
1502
|
+
if (call.confirm || result.carts.length > 1 || (picked.conversationId && result.conversation_id && result.conversation_id !== picked.conversationId)) {
|
|
1503
|
+
pickedOrder = null;
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
if (!call.heard && !keepsYes(call.ask) && !answersCheckout(call.ask, { nextAction: call.asked.nextAction, asked: call.asked.reply })) {
|
|
1507
|
+
pickedOrder = null;
|
|
1508
|
+
return;
|
|
1509
|
+
}
|
|
1510
|
+
if (!now)
|
|
1511
|
+
return;
|
|
1512
|
+
pickedOrder = picked.cart === null || sameOrder(now, picked.cart) ? { conversationId: picked.conversationId ?? result.conversation_id, cart: now } : null;
|
|
1513
|
+
};
|
|
1333
1514
|
/** An answer the tool writes itself, from where the purchase stands, with nothing sent to Agentcard. */
|
|
1334
1515
|
const stepResult = (fields) => ({
|
|
1335
1516
|
messages: [],
|
|
@@ -1371,6 +1552,7 @@ export function buyTool(options) {
|
|
|
1371
1552
|
const standing = { cart: one ?? carts.at(-1) ?? null, carts, hashes: carts.map((c) => c.hash), conversation_id: conversationId };
|
|
1372
1553
|
if (event.kind === 'approval_declined') {
|
|
1373
1554
|
carriedYes = null;
|
|
1555
|
+
pickedOrder = null;
|
|
1374
1556
|
return { local: stepResult({ ...standing, status: 'needs_input', reply: carts.length > 0 ? STEP_AFTER_DECLINE : NO_CART_WAITING }) };
|
|
1375
1557
|
}
|
|
1376
1558
|
if (event.kind === 'card_added' && unconnectedAsk) {
|
|
@@ -1384,9 +1566,28 @@ export function buyTool(options) {
|
|
|
1384
1566
|
cardAfterSignIn = null;
|
|
1385
1567
|
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' }) };
|
|
1386
1568
|
}
|
|
1387
|
-
|
|
1569
|
+
// The shopper's yes goes on past the step they just finished: the same items at the same store, in the same purchase,
|
|
1570
|
+
// confirmed by the cart's hash as it stands now. A total that moved because the store priced its fees and tax in keeps
|
|
1571
|
+
// the yes, and the answer says the new total beside the next ask; the approval link is still the shopper's consent to
|
|
1572
|
+
// the payment (Karen, 2026-10-27).
|
|
1573
|
+
const yes = carriedYes;
|
|
1574
|
+
const inPurchase = (at) => at === null || at === conversationId;
|
|
1575
|
+
if (yes && one && conversationId && inPurchase(yes.conversationId) && sameOrder(one, yes.cart)) {
|
|
1388
1576
|
carriedYes = null;
|
|
1389
|
-
return { body: { confirm: one.hash, conversation_id: conversationId, payment_source: 'vault', ...who } };
|
|
1577
|
+
return { body: { confirm: one.hash, conversation_id: conversationId, payment_source: 'vault', ...who }, yes: { ...yes, cart: one, conversationId } };
|
|
1578
|
+
}
|
|
1579
|
+
// The shopper picked what is in the cart and finished the step outside the chat: finishing it is their go-ahead for the
|
|
1580
|
+
// same order, confirmed by the cart's hash as it stands now, and the approval link stays their consent to the payment
|
|
1581
|
+
// (Karen, 2026-10-27: "we are again asking for confirmation after user adds a card. we should assume they want to move
|
|
1582
|
+
// forward"). That holds whatever the shopper's Vault does: the approval link is the approval (Karen, 2026-10-27: "That
|
|
1583
|
+
// is the goal of the approval link. You shouldn't be asking again. Take the approval from the approval link as the
|
|
1584
|
+
// approval.").
|
|
1585
|
+
const picked = pickedOrder;
|
|
1586
|
+
if (picked?.cart && one && conversationId && inPurchase(picked.conversationId) && (event.kind === 'card_added' || event.kind === 'store_connected')) {
|
|
1587
|
+
pickedOrder = null;
|
|
1588
|
+
if (sameOrder(one, picked.cart)) {
|
|
1589
|
+
return { body: { confirm: one.hash, conversation_id: conversationId, payment_source: 'vault', ...who }, yes: { conversationId, cart: one, totalCents: picked.cart.totalCents, picked: true } };
|
|
1590
|
+
}
|
|
1390
1591
|
}
|
|
1391
1592
|
// A checkout the shopper already moved past their yes (an approval link waiting, or the order going through) is reported as
|
|
1392
1593
|
// it stands: the cart stays while it waits, and asking for another yes would contradict the link they have (review of #9220).
|
|
@@ -1443,13 +1644,22 @@ export function buyTool(options) {
|
|
|
1443
1644
|
...(client.company ? { external_user_id: externalUserId } : {}),
|
|
1444
1645
|
};
|
|
1445
1646
|
// The cart a confirm sent now confirms, for the yes it carries past a step outside the chat.
|
|
1446
|
-
const
|
|
1647
|
+
const yesShown = confirm ? lastShownCart : null;
|
|
1648
|
+
// A pick of what the shopper saw, by a product's id or its name, or a request to buy something: when its answer carts
|
|
1649
|
+
// it, the shopper's go-ahead for that cart (`notePick`). Read before the answer replaces what the shopper saw.
|
|
1650
|
+
const picksShown = !confirm && (!!pick?.product_id || pickOf(ask) !== null || isProductAsk(ask));
|
|
1651
|
+
const cartBefore = lastShownCart;
|
|
1652
|
+
const pickWithdrawalsAtStart = pickWithdrawals;
|
|
1653
|
+
const askedBefore = { nextAction: lastNextAction, reply: lastReply };
|
|
1447
1654
|
// The first call after `instantAck` opens the turn of the message it answered, whose reply is the
|
|
1448
1655
|
// app's. Any other call that starts while another runs, or right after one ended, is the model
|
|
1449
1656
|
// working through the same turn. A call made through `buy.turn` belongs to that turn alone.
|
|
1657
|
+
// Whether a turn read the shopper's message (`withdrawUnlessGoAhead`): `buy.turn`, or the app's instant reply.
|
|
1658
|
+
let heard = !!explicit;
|
|
1450
1659
|
if (!explicit) {
|
|
1451
1660
|
const opened = appAck && Date.now() - appAck.at < APP_TURN_MS ? appAck : null;
|
|
1452
1661
|
appAck = null;
|
|
1662
|
+
heard = !!opened;
|
|
1453
1663
|
if (opened) {
|
|
1454
1664
|
// Only a reply the app sent quiets onWorking. A message such as `yes` that starts a slow search
|
|
1455
1665
|
// still gets a reply that names nothing, and a slow pick gets one that says it is on it.
|
|
@@ -1464,20 +1674,31 @@ export function buyTool(options) {
|
|
|
1464
1674
|
// A turn the tool took on its own sends no ask: it reads where the purchase stands, and only the
|
|
1465
1675
|
// shopper's own yes to this exact cart goes in, for that cart's hash.
|
|
1466
1676
|
const plan = t.event ? await stepPlan(t.event) : null;
|
|
1467
|
-
const
|
|
1468
|
-
const confirmSent = plan ? 'body' in plan &&
|
|
1677
|
+
const planned = plan && 'body' in plan ? plan.body : body;
|
|
1678
|
+
const confirmSent = plan ? 'body' in plan && planned.confirm !== undefined : confirm;
|
|
1679
|
+
// Agentcard writes the line that says what it is looking for (onAck) only where the shopper can read it before the
|
|
1680
|
+
// cards and has read nothing yet: a streamed ask, with somewhere to show it, that no reaction, reply or model's words
|
|
1681
|
+
// acknowledged already, in an app that asked for no acknowledgement of its own (`ack: 'none'` or `ack: false` gets
|
|
1682
|
+
// none, there as here) and acknowledges in text: an app that reacts (`ack: 'reaction'`, or an `onReaction` the turn
|
|
1683
|
+
// routes to) reads no line of text, even through `onText`. Every other ask says so, and Agentcard spends no model call on it.
|
|
1684
|
+
const agentcardAcks = streamCalls && ackMode !== 'none' && ackMode !== 'reaction' && !t.onReaction && !t.event && !confirmSent && !pick && !t.acked && (!!t.onWorking || !!t.onText) && (t.agentcardAcks?.(ask) ?? false);
|
|
1685
|
+
/** What goes to Agentcard: the planned request, with `ack: false` when the shopper reads no line of Agentcard's. */
|
|
1686
|
+
let sent = planned;
|
|
1469
1687
|
const callTurn = t.number;
|
|
1470
1688
|
const send = senderOf(t);
|
|
1471
1689
|
const progressOf = t.onProgress;
|
|
1472
1690
|
// Every call streams unless the app waits for whole answers, so each part reaches the app as Agentcard produces it. A
|
|
1473
1691
|
// confirm streams as a search does, and only a search's products go out early. A call with no callback to hear any part
|
|
1474
1692
|
// has no handler, and the client asks for the whole answer.
|
|
1475
|
-
const streams =
|
|
1693
|
+
const streams = streamCalls;
|
|
1476
1694
|
const early = earlyCards && !!send && !confirmSent;
|
|
1477
1695
|
running += 1;
|
|
1696
|
+
t.called = true;
|
|
1478
1697
|
// A confirm or a pick of what the shopper saw never hears "looking for": once the call is slow, it hears a
|
|
1479
1698
|
// fixed line that names nothing they said (slowStepLine), so a 20-second checkout or menu read is never silent.
|
|
1480
1699
|
const ack = t.event ? null : holdAck(ask, t, confirm ? 'confirm' : pick?.product_id ? 'product' : pick ? 'pick' : null);
|
|
1700
|
+
if (ack)
|
|
1701
|
+
(t.holds ??= new Set()).add(ack.cancel);
|
|
1481
1702
|
/** Says `unknown` of every card the call left `checking`; it runs once the answer's cards went out, or when the call fails. */
|
|
1482
1703
|
let settleLeft = null;
|
|
1483
1704
|
try {
|
|
@@ -1490,10 +1711,53 @@ export function buyTool(options) {
|
|
|
1490
1711
|
* and every message after it, so the shopper reads them in the order Agentcard wrote them.
|
|
1491
1712
|
*/
|
|
1492
1713
|
const waiting = [];
|
|
1714
|
+
/** True when the last message the shopper read ended in a colon or a question mark, so choices that open the next one are its list. */
|
|
1715
|
+
let leadIn = false;
|
|
1716
|
+
/** A message that leads in with a colon waits for the one after it (leadInJoiner), and `heldTexts` keeps it as Agentcard wrote it. */
|
|
1717
|
+
let leads = leadInJoiner();
|
|
1718
|
+
let heldTexts = [];
|
|
1719
|
+
/** Shows a message through onText, its lists as bullets (messageForShopper); `told` keeps each of Agentcard's messages in it as written. */
|
|
1720
|
+
const showMessage = async (message, texts, conversationId) => {
|
|
1721
|
+
await t.onText(messageForShopper(message, leadIn, t.texting), { conversationId });
|
|
1722
|
+
leadIn = leadsIntoList(message);
|
|
1723
|
+
told.push(...texts);
|
|
1724
|
+
};
|
|
1725
|
+
/**
|
|
1726
|
+
* Shows one of Agentcard's messages through onText. One that ends on a colon, such as `two options came in under $20:`,
|
|
1727
|
+
* goes out with the message after it, so the shopper never reads a bubble ending on a colon.
|
|
1728
|
+
*/
|
|
1729
|
+
const showText = async (text, conversationId) => {
|
|
1730
|
+
const message = leads.add(text);
|
|
1731
|
+
heldTexts.push({ text, conversationId });
|
|
1732
|
+
if (message === null)
|
|
1733
|
+
return;
|
|
1734
|
+
const texts = heldTexts.map((held) => held.text);
|
|
1735
|
+
heldTexts = [];
|
|
1736
|
+
await showMessage(message, texts, conversationId);
|
|
1737
|
+
};
|
|
1738
|
+
/**
|
|
1739
|
+
* A lead-in no message followed, once the answer is in: its list went (the cards show it), so it goes out less its colon
|
|
1740
|
+
* sentence, and not at all when that was all of it. It counts as read when anything was, so the model never words again
|
|
1741
|
+
* what the shopper read.
|
|
1742
|
+
*/
|
|
1743
|
+
const showLeftLead = async () => {
|
|
1744
|
+
if (heldTexts.length === 0)
|
|
1745
|
+
return;
|
|
1746
|
+
const rest = leads.end();
|
|
1747
|
+
const texts = heldTexts.map((held) => held.text);
|
|
1748
|
+
const conversationId = heldTexts[0].conversationId;
|
|
1749
|
+
heldTexts = [];
|
|
1750
|
+
if (rest)
|
|
1751
|
+
await showMessage(rest, texts, conversationId);
|
|
1752
|
+
else if (told.length > 0)
|
|
1753
|
+
told.push(...texts);
|
|
1754
|
+
};
|
|
1493
1755
|
/** The products past the cards of the catalog this call heard last, for the count that follows it. */
|
|
1494
1756
|
let moreFound = null;
|
|
1495
1757
|
/** Whether this call's cards went out, and a count that came before them. */
|
|
1496
1758
|
let cardsOut = false;
|
|
1759
|
+
/** The product cards this call's searches sent out as they answered, which the shopper can tap whatever the answer lists. */
|
|
1760
|
+
const streamedCards = [];
|
|
1497
1761
|
let heldCount = null;
|
|
1498
1762
|
/** The card each catalog id of this call went out as (its `productId`), and the cards still `checking`, by conversation. */
|
|
1499
1763
|
const cardOfId = new Map();
|
|
@@ -1602,6 +1866,7 @@ export function buyTool(options) {
|
|
|
1602
1866
|
await deliverPagesInTurn(earlyPages(update.conversationId, update.asOf, withAnswers(productsOfCatalog(update.catalog))), { turn: callTurn, presented: true, owner: t }, send);
|
|
1603
1867
|
// A place shown `checking`, on a card or behind "show more", waits for the live search's word on it.
|
|
1604
1868
|
noteCards([...productsOfCatalog(update.catalog), ...productsOfCatalog({ ...update.catalog, items: update.more })], update.conversationId);
|
|
1869
|
+
streamedCards.push(...productsOfCatalog(update.catalog));
|
|
1605
1870
|
keepMore();
|
|
1606
1871
|
cardsOut = true;
|
|
1607
1872
|
// A count held for these cards goes out now, unless the catalog carries its own, which follows it.
|
|
@@ -1631,8 +1896,7 @@ export function buyTool(options) {
|
|
|
1631
1896
|
return;
|
|
1632
1897
|
}
|
|
1633
1898
|
try {
|
|
1634
|
-
await
|
|
1635
|
-
told.push(text);
|
|
1899
|
+
await showText(text, conversationId);
|
|
1636
1900
|
}
|
|
1637
1901
|
catch {
|
|
1638
1902
|
// A message the app failed to show was never read, so the model words the answer.
|
|
@@ -1640,6 +1904,24 @@ export function buyTool(options) {
|
|
|
1640
1904
|
},
|
|
1641
1905
|
}
|
|
1642
1906
|
: {}),
|
|
1907
|
+
...(agentcardAcks
|
|
1908
|
+
? {
|
|
1909
|
+
// Agentcard's line for the search, such as `one sec, checking target for la croix`: it goes out as the turn's
|
|
1910
|
+
// instant reply, through onWorking, else as Agentcard's message, and only while the shopper has read nothing
|
|
1911
|
+
// else of the turn. The model then reads `shopper_already_told_looking`.
|
|
1912
|
+
onAck: async ({ text, conversationId }) => {
|
|
1913
|
+
if (t.acked || cardsOut || told.length > 0 || waiting.length > 0)
|
|
1914
|
+
return;
|
|
1915
|
+
ack?.cancel();
|
|
1916
|
+
t.acked = true;
|
|
1917
|
+
const line = text.trim();
|
|
1918
|
+
t.ackOut = Promise.resolve()
|
|
1919
|
+
.then(() => (t.onWorking ? t.onWorking(line) : t.onText?.(line, { conversationId })))
|
|
1920
|
+
.then(() => { t.ackDelivered = true; }, () => { });
|
|
1921
|
+
await t.ackOut;
|
|
1922
|
+
},
|
|
1923
|
+
}
|
|
1924
|
+
: {}),
|
|
1643
1925
|
...(t.onCart
|
|
1644
1926
|
? {
|
|
1645
1927
|
onCart: async ({ cart, carts, conversationId }) => {
|
|
@@ -1654,6 +1936,9 @@ export function buyTool(options) {
|
|
|
1654
1936
|
: {}),
|
|
1655
1937
|
}
|
|
1656
1938
|
: undefined;
|
|
1939
|
+
// A streamed ask for something new that the tool shows no line of Agentcard's for says so. A pick never gets one.
|
|
1940
|
+
const streamed = !!handlers && Object.values(handlers).some((handler) => typeof handler === 'function');
|
|
1941
|
+
sent = planned.ask !== undefined && !planned.pick && streamed && !agentcardAcks ? { ...planned, ack: false } : planned;
|
|
1657
1942
|
if (plan && 'local' in plan) {
|
|
1658
1943
|
// Nothing went to Agentcard: the answer is where the purchase stands.
|
|
1659
1944
|
}
|
|
@@ -1671,6 +1956,8 @@ export function buyTool(options) {
|
|
|
1671
1956
|
// Only the answer of the request sent again counts toward what the shopper read.
|
|
1672
1957
|
told.length = 0;
|
|
1673
1958
|
waiting.length = 0;
|
|
1959
|
+
leads = leadInJoiner();
|
|
1960
|
+
heldTexts = [];
|
|
1674
1961
|
try {
|
|
1675
1962
|
turn = await client.buy(sent, handlers);
|
|
1676
1963
|
refusal = undefined;
|
|
@@ -1688,15 +1975,15 @@ export function buyTool(options) {
|
|
|
1688
1975
|
let replaced = false;
|
|
1689
1976
|
if (waiting.length > 0 && t.onText) {
|
|
1690
1977
|
const rewritten = !!turn && settled.reply !== turn.reply;
|
|
1691
|
-
|
|
1978
|
+
// Paragraph for paragraph, as `told` keeps Agentcard's messages, before any lead-in joins the one after it.
|
|
1979
|
+
const kept = new Set((rewritten ? paragraphsOf(settled.reply) : []).map((message) => message.trim()));
|
|
1692
1980
|
for (const message of waiting.splice(0)) {
|
|
1693
1981
|
if (rewritten && waitsForAnswer(message.text) && !kept.has(message.text.trim())) {
|
|
1694
1982
|
replaced = true;
|
|
1695
1983
|
continue;
|
|
1696
1984
|
}
|
|
1697
1985
|
try {
|
|
1698
|
-
await
|
|
1699
|
-
told.push(message.text);
|
|
1986
|
+
await showText(message.text, message.conversationId);
|
|
1700
1987
|
}
|
|
1701
1988
|
catch {
|
|
1702
1989
|
// A message the app failed to show was never read, so the model words the answer.
|
|
@@ -1705,17 +1992,37 @@ export function buyTool(options) {
|
|
|
1705
1992
|
const stepAsk = turn?.next_action ? CHAT_STEP_ASKS[turn.next_action] : undefined;
|
|
1706
1993
|
if (replaced && stepAsk && settled.reply.endsWith(stepAsk)) {
|
|
1707
1994
|
try {
|
|
1708
|
-
await
|
|
1709
|
-
told.push(stepAsk);
|
|
1995
|
+
await showText(stepAsk, settled.conversation_id);
|
|
1710
1996
|
}
|
|
1711
1997
|
catch {
|
|
1712
1998
|
// Not shown, so the model asks for the step.
|
|
1713
1999
|
}
|
|
1714
2000
|
}
|
|
1715
2001
|
}
|
|
2002
|
+
if (t.onText) {
|
|
2003
|
+
try {
|
|
2004
|
+
await showLeftLead();
|
|
2005
|
+
}
|
|
2006
|
+
catch {
|
|
2007
|
+
// A message the app failed to show was never read, so the model words the answer.
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
1716
2010
|
// Every message of Agentcard's own answer reached the shopper through onText, so the model adds nothing to it. An answer
|
|
1717
2011
|
// whose card ask gave way to the named step was read the same way once every message of it, that ask last, went out.
|
|
1718
|
-
|
|
2012
|
+
// A reply the shopper read that asks them to choose, with the values named inside a sentence or not at all, is followed
|
|
2013
|
+
// by one more message that lists every value on its own line (choiceLines), so no choice is read buried in prose.
|
|
2014
|
+
let choiceShown = true;
|
|
2015
|
+
if (t.onText && turn?.choice && told.length > 0 && !messagesListChoice(told, turn.choice)) {
|
|
2016
|
+
const lines = choiceLines(turn.choice);
|
|
2017
|
+
try {
|
|
2018
|
+
await t.onText(t.texting ? textingStyle(lines) : lines, { conversationId: turn.conversation_id || null });
|
|
2019
|
+
}
|
|
2020
|
+
catch {
|
|
2021
|
+
// Not shown, so the shopper has not read the whole answer: the model words it, with the choice it reads.
|
|
2022
|
+
choiceShown = false;
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
const read = choiceShown && !!turn && (settled.reply === turn.reply ? readEvery(told, turn.messages) : replaced && readEvery(told, paragraphsOf(settled.reply)));
|
|
1719
2026
|
const answer = withMessages(settled, turn);
|
|
1720
2027
|
const delivered = await deliver(answered.size > 0 ? { ...answer, products: withAnswers(answer.products) } : answer, { turn: callTurn, presented: turn?.catalog?.presented === true, owner: t }, send);
|
|
1721
2028
|
// A count that came before any card (find mode puts its cards in the answer) goes out once the answer's cards did.
|
|
@@ -1733,10 +2040,19 @@ export function buyTool(options) {
|
|
|
1733
2040
|
// The call waits for it, and one that fails to send fails the call, as a step link the turn does not hold does.
|
|
1734
2041
|
if (read)
|
|
1735
2042
|
await t.releaseNow?.();
|
|
2043
|
+
// A confirm the turn sent on the shopper's earlier yes names the total they said yes to beside the cart's total now. A
|
|
2044
|
+
// confirm Agentcard refused, or one whose answer holds another order or asks for a yes, carried nothing: the model asks.
|
|
2045
|
+
const carried = plan && 'body' in plan && plan.yes && turn && delivered.next_action !== 'confirm' ? plan.yes : null;
|
|
2046
|
+
const nowCart = !carried ? null : !delivered.cart ? carried.cart : sameOrder(delivered.cart, carried.cart) ? delivered.cart : null;
|
|
2047
|
+
const yesCarried = carried && nowCart
|
|
2048
|
+
? { said_yes_to_cents: carried.totalCents, total_cents: nowCart.totalCents, currency: nowCart.currency ?? null, ...(carried.picked ? { picked: true } : {}) }
|
|
2049
|
+
: null;
|
|
1736
2050
|
const result = {
|
|
1737
2051
|
...delivered,
|
|
1738
2052
|
...(t.ackDelivered ? { shopper_already_told_looking: true } : {}),
|
|
1739
2053
|
...(read ? { shopper_already_read_reply: true } : {}),
|
|
2054
|
+
...(confirmSent ? { confirm_sent: true } : {}),
|
|
2055
|
+
...(yesCarried ? { yes_carried: yesCarried } : {}),
|
|
1740
2056
|
};
|
|
1741
2057
|
lastNextAction = result.next_action;
|
|
1742
2058
|
lastReply = result.reply;
|
|
@@ -1749,12 +2065,19 @@ export function buyTool(options) {
|
|
|
1749
2065
|
if (result.conversation_id)
|
|
1750
2066
|
lastConversationId = result.conversation_id;
|
|
1751
2067
|
if (result.cart?.hash)
|
|
1752
|
-
|
|
2068
|
+
lastShownCart = result.cart;
|
|
1753
2069
|
// 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.
|
|
1754
2070
|
if (!t.event)
|
|
1755
|
-
noteShopperStep(result, turn, { confirm: !!confirm,
|
|
2071
|
+
noteShopperStep(result, turn, { confirm: !!confirm, yes: { shown: yesShown }, ask, withdrawalsAtStart });
|
|
1756
2072
|
else if (plan && 'body' in plan)
|
|
1757
|
-
noteShopperStep(result, turn, { confirm: confirmSent,
|
|
2073
|
+
noteShopperStep(result, turn, { confirm: confirmSent && !!plan.yes, yes: plan.yes ? { carried: plan.yes } : { shown: null }, ask: sent.ask ?? '', withdrawalsAtStart });
|
|
2074
|
+
if (!t.event && turn) {
|
|
2075
|
+
// Words that name something to buy and put a new item in the cart picked it too, as `the chicken pesto caprese from
|
|
2076
|
+
// mendocino farms` carts the dish it names.
|
|
2077
|
+
const added = result.pick?.in_cart === true || addsItem(result.cart, cartBefore);
|
|
2078
|
+
const picking = !confirm && (picksShown || result.pick?.in_cart === true || (isNewRequest(ask) && added));
|
|
2079
|
+
notePick(result, { picking, added, confirm: !!confirm, ask, heard, asked: askedBefore, pickWithdrawalsAtStart });
|
|
2080
|
+
}
|
|
1758
2081
|
// An answer that lists products replaces the names, even when every one is too short to keep.
|
|
1759
2082
|
const shown = shownProducts(result.products);
|
|
1760
2083
|
if (shown.length > 0)
|
|
@@ -1763,7 +2086,23 @@ export function buyTool(options) {
|
|
|
1763
2086
|
const storesShown = shownStores(result.stores);
|
|
1764
2087
|
if (storesShown.length > 0)
|
|
1765
2088
|
shownStoreNames = storesShown.map((store) => store.name);
|
|
2089
|
+
// The cards the shopper can tap by name are the answer's and the ones this call's searches already sent: a restaurant turn that
|
|
2090
|
+
// read the Burrito's options answered with the Burrito alone, beside the eight menu cards it sent first, and a tap on the
|
|
2091
|
+
// Burrito Bowl then found no card of its name. A place ("the last one") reads the answer's own list, in its order, and never
|
|
2092
|
+
// this one, so it picks the product shown in that place (Greptile on #11163).
|
|
2093
|
+
// Each card once, by its id: a call that sends one search's products again (a refreshed catalog) would otherwise hold the
|
|
2094
|
+
// same card twice, and a tap on it would read as two cards named alike (Greptile on #11163).
|
|
2095
|
+
const tappable = [];
|
|
2096
|
+
const held = new Set();
|
|
2097
|
+
for (const product of [...shown, ...shownProducts(streamedCards)]) {
|
|
2098
|
+
const id = pickId(product) ?? `name:${product.name}`;
|
|
2099
|
+
if (held.has(id))
|
|
2100
|
+
continue;
|
|
2101
|
+
held.add(id);
|
|
2102
|
+
tappable.push(product);
|
|
2103
|
+
}
|
|
1766
2104
|
lastAnswerProducts = shown.map((product) => ({ productId: pickId(product), name: product.name }));
|
|
2105
|
+
lastTappable = tappable.map((product) => ({ productId: pickId(product), name: product.name }));
|
|
1767
2106
|
lastAnswerStores = storesShown.map((store) => ({ storeId: store.id, name: store.name }));
|
|
1768
2107
|
t.results?.push(result);
|
|
1769
2108
|
if (t.onResult) {
|
|
@@ -1780,6 +2119,9 @@ export function buyTool(options) {
|
|
|
1780
2119
|
// A call that fails, Agentcard's error or a thrown handler, still settles the cards it left `checking`.
|
|
1781
2120
|
await settleLeft?.().catch(() => { });
|
|
1782
2121
|
ack?.cancel();
|
|
2122
|
+
if (ack)
|
|
2123
|
+
t.holds?.delete(ack.cancel);
|
|
2124
|
+
t.dropWaitLine?.();
|
|
1783
2125
|
running -= 1;
|
|
1784
2126
|
lastEndedAt = Date.now();
|
|
1785
2127
|
}
|
|
@@ -1867,6 +2209,9 @@ export function buyTool(options) {
|
|
|
1867
2209
|
if (own.replyAsWritten !== undefined && typeof own.replyAsWritten !== 'boolean')
|
|
1868
2210
|
throw new TypeError('Pass replyAsWritten to the turn as true or false, or leave it out.');
|
|
1869
2211
|
const written = own.replyAsWritten ?? writtenByDefault;
|
|
2212
|
+
if (own.textingStyle !== undefined && typeof own.textingStyle !== 'boolean')
|
|
2213
|
+
throw new TypeError('Pass textingStyle to the turn as true or false, or leave it out.');
|
|
2214
|
+
const texting = own.textingStyle ?? textingByDefault;
|
|
1870
2215
|
const hearText = own.onText ?? o.onText;
|
|
1871
2216
|
// Messages are spaced only for an app that hears each one as it goes out.
|
|
1872
2217
|
const pauseOf = paceOf(own.pace);
|
|
@@ -1936,6 +2281,7 @@ export function buyTool(options) {
|
|
|
1936
2281
|
// Agentcard's message takes its place in the turn's sequence, after the items before it, and goes to onText alone, so
|
|
1937
2282
|
// an app that passes onItem too hears it once.
|
|
1938
2283
|
onText: hearText ? (text, info) => feed.push({ kind: 'text', text }, () => hearText(text, info)) : undefined,
|
|
2284
|
+
texting,
|
|
1939
2285
|
onCart: own.onCart ?? o.onCart,
|
|
1940
2286
|
tell: onItem || baseOnWorking ? (text) => feed.push({ kind: 'text', text }, () => (onItem ? onItem({ kind: 'text', text }) : baseOnWorking?.(text))) : undefined,
|
|
1941
2287
|
links: [],
|
|
@@ -1955,7 +2301,29 @@ export function buyTool(options) {
|
|
|
1955
2301
|
return Promise.resolve();
|
|
1956
2302
|
};
|
|
1957
2303
|
/** A message the app's model wrote: it goes to onItem, else onMessage, after every item before it, and the turn lists it. */
|
|
1958
|
-
|
|
2304
|
+
let modelWrote = false;
|
|
2305
|
+
/** Whether the model's first words came before any call of the turn: set by its first delta or `say`, since a paragraph is cut later. */
|
|
2306
|
+
let wordsBeforeCall = null;
|
|
2307
|
+
const firstWords = (words) => {
|
|
2308
|
+
if (wordsBeforeCall === null && /\S/.test(String(words ?? '')))
|
|
2309
|
+
wordsBeforeCall = !state.called;
|
|
2310
|
+
};
|
|
2311
|
+
const text = (message, skip) => {
|
|
2312
|
+
const first = !modelWrote;
|
|
2313
|
+
modelWrote = true;
|
|
2314
|
+
const show = (left) => feed.push({ kind: 'text', text: message }, () => (onItem ? onItem({ kind: 'text', text: message }) : onMessage?.(message)), left);
|
|
2315
|
+
// The wait line already told the shopper the turn is on it: the model's own acknowledgement, its first message
|
|
2316
|
+
// written before any call, such as `on it, looking for a dryer under $20`, would be a second line, so it stays in
|
|
2317
|
+
// the model's own history and never reaches the shopper. A first message that asks, answers or says what a search
|
|
2318
|
+
// came to still goes out, and so does every message after it. (2026-10-27: "dryer under 20" read `one moment`,
|
|
2319
|
+
// then `on it, looking for a dryer under $20`.)
|
|
2320
|
+
// A wait line the app failed to send reached nobody (`ackDelivered`), so the model's own line goes out in its
|
|
2321
|
+
// place, in the feed's order: a failed send costs that line alone, never both.
|
|
2322
|
+
if (first && state.waitLineOut && (wordsBeforeCall ?? !state.called) && readsAsAckLine(message)) {
|
|
2323
|
+
return show(() => state.ackDelivered || (skip?.() ?? false));
|
|
2324
|
+
}
|
|
2325
|
+
return show(skip);
|
|
2326
|
+
};
|
|
1959
2327
|
let ack = null;
|
|
1960
2328
|
const working = state.onWorking;
|
|
1961
2329
|
const reacting = state.onReaction;
|
|
@@ -1985,7 +2353,9 @@ export function buyTool(options) {
|
|
|
1985
2353
|
// A search gets its 👀 the moment the turn opens, and no text: the reaction reads nothing back.
|
|
1986
2354
|
react(state, reacting);
|
|
1987
2355
|
}
|
|
1988
|
-
else if (text !== null && working) {
|
|
2356
|
+
else if (text !== null && working && (writeAck || !streamCalls)) {
|
|
2357
|
+
// A streamed search's line is Agentcard's to write (onAck), from the conversation, while it searches; the
|
|
2358
|
+
// tool's template goes out at once only for an answer the app waits for whole, or in the app's own words.
|
|
1989
2359
|
ack = text;
|
|
1990
2360
|
state.acked = true;
|
|
1991
2361
|
let sent;
|
|
@@ -2004,9 +2374,65 @@ export function buyTool(options) {
|
|
|
2004
2374
|
const results = state.results;
|
|
2005
2375
|
// A pick or an answer goes straight only after a turn that ended on Agentcard's answer, and never while a call still runs.
|
|
2006
2376
|
const answered = previousTurn !== null && (previousTurn.results?.length ?? 0) > 0;
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2377
|
+
// A `nextStep` turn's call is the same whatever the model writes (`stepPlan` reads where the purchase stands), so it goes
|
|
2378
|
+
// straight too: with `replyAsWritten`, a step whose answer is a link, such as the approval after a saved card, needs no model.
|
|
2379
|
+
const direct = !directOn || running > 0
|
|
2380
|
+
? null
|
|
2381
|
+
: event
|
|
2382
|
+
? { input: { ask: NEXT_STEP_ASK, confirm: false, new_order: false, product_id: null, store_id: null }, reason: 'next_step' }
|
|
2383
|
+
: directCall(String(shopperText ?? ''), { answered, nextAction: lastNextAction, asked: lastReply, products: lastAnswerProducts, tappable: lastTappable, stores: lastAnswerStores });
|
|
2384
|
+
// Agentcard writes the line for a search the turn sends straight. A turn the app's model runs gets the model's own line,
|
|
2385
|
+
// or the wait line below.
|
|
2386
|
+
state.agentcardAcks = (ask) => direct !== null && direct.reason === 'product_ask' && ask.trim() === direct.input.ask.trim();
|
|
2387
|
+
/** Says the turn is on it, once, in fixed words that name nothing the shopper said, unless something went out first. */
|
|
2388
|
+
const sayWaiting = (line) => {
|
|
2389
|
+
if (state.acked || state.ended || feed.pushed() > 0 || !writesThroughTurn)
|
|
2390
|
+
return;
|
|
2391
|
+
state.acked = true;
|
|
2392
|
+
state.waitLineOut = true;
|
|
2393
|
+
state.ackOut = Promise.resolve()
|
|
2394
|
+
.then(() => working(line))
|
|
2395
|
+
.then(() => { state.ackDelivered = true; }, () => { });
|
|
2396
|
+
};
|
|
2397
|
+
// Only where the tool sees what the shopper read: the app writes the model's words through the turn.
|
|
2398
|
+
if (!event && working && !reacting && direct !== null && direct.reason !== 'product_ask') {
|
|
2399
|
+
// A pick, a confirm or an answer the turn sends straight that has shown nothing after ackDelayMs says it is on it. A
|
|
2400
|
+
// product ask hears Agentcard's line (onAck).
|
|
2401
|
+
const waitTimer = setTimeout(() => sayWaiting(slowStepLine(direct.reason === 'confirm' ? 'confirm' : state.picked ?? 'wait')), ackDelayMs);
|
|
2402
|
+
waitTimer.unref?.();
|
|
2403
|
+
// A call that ended, by its answer or by its error, leaves the turn to its reply: no wait line follows a failed pick.
|
|
2404
|
+
state.dropWaitLine = () => clearTimeout(waitTimer);
|
|
2405
|
+
}
|
|
2406
|
+
else if (!event && working && !reacting && direct !== null) {
|
|
2407
|
+
// A search the turn sends straight hears Agentcard's line (onAck). When that line never comes, as when Agentcard's
|
|
2408
|
+
// model writes none or its line is dropped, and the shopper has seen nothing, no line and no card, by twice
|
|
2409
|
+
// ackDelayMs, the wait line goes out then, the same rule as a model that has written nothing and called nothing.
|
|
2410
|
+
// Once it went out, a line of Agentcard's that comes later is dropped (onAck reads `acked`), so the shopper reads one
|
|
2411
|
+
// line. A call that already answered leaves the turn to its reply. (2026-10-27: "i want one called ardor" showed
|
|
2412
|
+
// nothing for 9.1 s.)
|
|
2413
|
+
const waitTimer = setTimeout(() => {
|
|
2414
|
+
if (results.length === 0)
|
|
2415
|
+
sayWaiting(slowStepLine('wait'));
|
|
2416
|
+
}, 2 * ackDelayMs);
|
|
2417
|
+
waitTimer.unref?.();
|
|
2418
|
+
// A call that ended, by its answer or by its error, leaves the turn to its reply: no wait line follows a failed search.
|
|
2419
|
+
state.dropWaitLine = () => clearTimeout(waitTimer);
|
|
2420
|
+
}
|
|
2421
|
+
else if (!event && working && !reacting && direct === null) {
|
|
2422
|
+
// The app's model runs this turn, and its first output decides: its own line first means no wait line; a call first
|
|
2423
|
+
// gets the wait line from that call (holdAck); and a model that has written nothing and called nothing after twice
|
|
2424
|
+
// ackDelayMs gets it then.
|
|
2425
|
+
const model = { openedAt: Date.now(), spoke: false, callStarted: false, writesThrough: () => writesThroughTurn, shown: () => feed.pushed() > 0 };
|
|
2426
|
+
state.modelTurn = model;
|
|
2427
|
+
const waitTimer = setTimeout(() => {
|
|
2428
|
+
if (!model.spoke && !model.callStarted)
|
|
2429
|
+
sayWaiting(slowStepLine(state.picked ?? 'wait'));
|
|
2430
|
+
}, 2 * ackDelayMs);
|
|
2431
|
+
waitTimer.unref?.();
|
|
2432
|
+
// `turn.end()`, as when the app's model failed before it wrote or called anything, stops it: no `one moment` follows
|
|
2433
|
+
// the app's own error. A call that ends stops it too, since a call started means the timer would send nothing.
|
|
2434
|
+
state.dropWaitLine = () => clearTimeout(waitTimer);
|
|
2435
|
+
}
|
|
2010
2436
|
if (!event)
|
|
2011
2437
|
previousTurn = state;
|
|
2012
2438
|
// What Agentcard told the shopper that no history the model ran with holds yet.
|
|
@@ -2061,26 +2487,50 @@ export function buyTool(options) {
|
|
|
2061
2487
|
},
|
|
2062
2488
|
enumerable: false,
|
|
2063
2489
|
},
|
|
2490
|
+
end: {
|
|
2491
|
+
value: () => {
|
|
2492
|
+
state.ended = true;
|
|
2493
|
+
state.dropWaitLine?.();
|
|
2494
|
+
// The acknowledgement a call still running holds, a 👀 or a line for a slow pick, confirm or answer, is cancelled too.
|
|
2495
|
+
for (const cancel of state.holds ?? [])
|
|
2496
|
+
cancel();
|
|
2497
|
+
state.holds?.clear();
|
|
2498
|
+
},
|
|
2499
|
+
enumerable: false,
|
|
2500
|
+
},
|
|
2064
2501
|
messages: { value: messagesFor, enumerable: false },
|
|
2065
2502
|
ack: { value: ack, enumerable: false },
|
|
2066
2503
|
reaction: { get: () => state.reaction, enumerable: false },
|
|
2067
2504
|
links: { get: () => [...links], enumerable: false },
|
|
2068
2505
|
results: { get: () => [...results], enumerable: false },
|
|
2069
2506
|
direct: { value: direct, enumerable: false },
|
|
2070
|
-
replyAsWritten: { get: () => (written && results.length > 0 ? replyAsWritten(results[results.length - 1]) : null), enumerable: false },
|
|
2507
|
+
replyAsWritten: { get: () => (written && results.length > 0 ? replyAsWritten(results[results.length - 1], { textingStyle: texting }) : null), enumerable: false },
|
|
2071
2508
|
items: { get: () => feed.items.map((item) => (item.kind === 'link' ? { kind: 'link', link: { ...item.link } } : { ...item })), enumerable: false },
|
|
2072
2509
|
// 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.
|
|
2073
2510
|
// A step link held for this message goes out right after its last paragraph, once the stream ends.
|
|
2074
2511
|
messageStream: {
|
|
2075
2512
|
value: () => {
|
|
2076
2513
|
writesThroughTurn = true;
|
|
2077
|
-
|
|
2514
|
+
const stream = feedStream(text, releaseForReply);
|
|
2515
|
+
return {
|
|
2516
|
+
push: (delta) => {
|
|
2517
|
+
firstWords(delta);
|
|
2518
|
+
// The model's first words: its own line is the turn's acknowledgement, so no wait line follows.
|
|
2519
|
+
if (state.modelTurn && /\S/.test(String(delta ?? '')))
|
|
2520
|
+
state.modelTurn.spoke = true;
|
|
2521
|
+
stream.push(delta);
|
|
2522
|
+
},
|
|
2523
|
+
end: () => stream.end(),
|
|
2524
|
+
};
|
|
2078
2525
|
},
|
|
2079
2526
|
enumerable: false,
|
|
2080
2527
|
},
|
|
2081
2528
|
say: {
|
|
2082
2529
|
value: (whole) => {
|
|
2083
2530
|
writesThroughTurn = true;
|
|
2531
|
+
firstWords(whole);
|
|
2532
|
+
if (state.modelTurn && /\S/.test(String(whole ?? '')))
|
|
2533
|
+
state.modelTurn.spoke = true;
|
|
2084
2534
|
const reply = feedReply(text);
|
|
2085
2535
|
for (const message of toMessages(whole))
|
|
2086
2536
|
reply.add(message);
|