@curless/hotel-storefront 0.27.2 → 0.27.4

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.
Files changed (60) hide show
  1. package/dist/__tests__/audit-p2.test.d.ts +2 -0
  2. package/dist/__tests__/audit-p2.test.d.ts.map +1 -0
  3. package/dist/__tests__/audit-p2.test.js +98 -0
  4. package/dist/__tests__/audit-p2.test.js.map +1 -0
  5. package/dist/__tests__/demo-hotels.test.js +3 -1
  6. package/dist/__tests__/demo-hotels.test.js.map +1 -1
  7. package/dist/__tests__/huazhu-audit.test.js +27 -3
  8. package/dist/__tests__/huazhu-audit.test.js.map +1 -1
  9. package/dist/__tests__/huazhu-backend.test.js +88 -1
  10. package/dist/__tests__/huazhu-backend.test.js.map +1 -1
  11. package/dist/__tests__/tools.test.js +21 -5
  12. package/dist/__tests__/tools.test.js.map +1 -1
  13. package/dist/backend.d.ts +8 -0
  14. package/dist/backend.d.ts.map +1 -1
  15. package/dist/backend.js +2 -0
  16. package/dist/backend.js.map +1 -1
  17. package/dist/card.generated.d.ts +1 -1
  18. package/dist/card.generated.js +2 -2
  19. package/dist/card.generated.js.map +1 -1
  20. package/dist/config.d.ts +7 -0
  21. package/dist/config.d.ts.map +1 -1
  22. package/dist/config.js.map +1 -1
  23. package/dist/data.js +2 -2
  24. package/dist/data.js.map +1 -1
  25. package/dist/demo-hotels.d.ts +1 -1
  26. package/dist/demo-hotels.d.ts.map +1 -1
  27. package/dist/demo-hotels.js +9 -3
  28. package/dist/demo-hotels.js.map +1 -1
  29. package/dist/huazhu/backend.d.ts +6 -0
  30. package/dist/huazhu/backend.d.ts.map +1 -1
  31. package/dist/huazhu/backend.js +76 -13
  32. package/dist/huazhu/backend.js.map +1 -1
  33. package/dist/huazhu/sellable.d.ts +3 -1
  34. package/dist/huazhu/sellable.d.ts.map +1 -1
  35. package/dist/huazhu/sellable.js.map +1 -1
  36. package/dist/legal.d.ts.map +1 -1
  37. package/dist/legal.js +16 -8
  38. package/dist/legal.js.map +1 -1
  39. package/dist/present.d.ts +1 -1
  40. package/dist/present.d.ts.map +1 -1
  41. package/dist/present.js +2 -1
  42. package/dist/present.js.map +1 -1
  43. package/dist/server.d.ts +8 -8
  44. package/dist/server.d.ts.map +1 -1
  45. package/dist/server.js +5 -4
  46. package/dist/server.js.map +1 -1
  47. package/dist/surface.d.ts.map +1 -1
  48. package/dist/surface.js +14 -3
  49. package/dist/surface.js.map +1 -1
  50. package/dist/tools.d.ts +75 -19
  51. package/dist/tools.d.ts.map +1 -1
  52. package/dist/tools.js +177 -26
  53. package/dist/tools.js.map +1 -1
  54. package/dist/trip.d.ts +12 -1
  55. package/dist/trip.d.ts.map +1 -1
  56. package/dist/trip.js +32 -0
  57. package/dist/trip.js.map +1 -1
  58. package/dist/types.d.ts +34 -2
  59. package/dist/types.d.ts.map +1 -1
  60. package/package.json +1 -1
package/dist/tools.js CHANGED
@@ -1,11 +1,11 @@
1
- import { DEFAULT_PAGE, FULL_CAPABILITIES, LASTING_REQUESTS, MAX_PAGE, REQUEST_KINDS, REQUEST_NOTE_MAX, guestTooLong, } from './backend.js';
2
- import { isDay, nights as nightsBetween } from './dates.js';
1
+ import { DEFAULT_PAGE, FULL_CAPABILITIES, LASTING_REQUESTS, MAX_PAGE, REQUEST_KINDS, REQUEST_NOTE_MAX, guestTooLong, unitWords, } from './backend.js';
2
+ import { MAX_GUESTS, isDay, nights as nightsBetween } from './dates.js';
3
3
  import { FACILITIES } from './facilities.js';
4
4
  import { DESCRIPTION_MAX, POLICY_MAX, hotelText } from './fence.js';
5
5
  import { formatMoney, money, multiplyMoney, toMinor } from './money.js';
6
6
  import { MAX_COMPARED, MAX_CONS, MAX_DAYS, MAX_ITEMS_PER_DAY, MAX_MOMENTS, MAX_PROS, buildItinerary, compareHotels, } from './present.js';
7
7
  import { regionOf } from './regions.js';
8
- import { MAX_EXTRA_QUANTITY, MAX_ROOMS_PER_LINE, TRIP_TTL_MS, clampCount, tripKey, } from './trip.js';
8
+ import { MAX_EXTRA_QUANTITY, MAX_ROOMS_PER_LINE, TRIP_TTL_MS, clampCount, shownOf, tripKey, } from './trip.js';
9
9
  import { DEMO_NOTE } from './vocab.js';
10
10
  // The eleven things a guest can do: find hotels, look at one on dates, see what
11
11
  // else it offers, build a trip, ask for the rooms, and deal with a booking they
@@ -60,7 +60,13 @@ const cancels = (title, openWorld) => ({
60
60
  idempotentHint: true,
61
61
  openWorldHint: openWorld,
62
62
  });
63
- const DATE = (what) => ({ type: 'string', description: `${what}, as YYYY-MM-DD` });
63
+ // ⚠️ THE SHAPE IS IN THE SCHEMA, not only in the words (2026-10-05 audit, P2):
64
+ // a date, a count of rooms and a number of guests each say what they may be.
65
+ const DATE = (what) => ({
66
+ type: 'string',
67
+ pattern: '^\\d{4}-\\d{2}-\\d{2}$',
68
+ description: `${what}, as YYYY-MM-DD`,
69
+ });
64
70
  /** How many ${nounPlural} one look-up reads - a city's worth, and what a comparison can hold and then some. */
65
71
  export const LOOK_UP_MAX = 6;
66
72
  /**
@@ -150,10 +156,16 @@ const forCapabilities = (tools, n, v, caps) => {
150
156
  };
151
157
  const allTools = (n, v) => {
152
158
  const open = !v.demo;
159
+ const places = v.hints.places ?? [];
153
160
  const stayProps = {
154
161
  checkIn: DATE('The arrival date'),
155
162
  checkOut: DATE('The departure date - the night before it is the last night'),
156
- guests: { type: 'number', description: 'People in the room. Default 2' },
163
+ guests: {
164
+ type: 'integer',
165
+ minimum: 1,
166
+ maximum: MAX_GUESTS,
167
+ description: 'People in the room. Default 2',
168
+ },
157
169
  };
158
170
  return [
159
171
  {
@@ -169,11 +181,13 @@ const allTools = (n, v) => {
169
181
  },
170
182
  city: {
171
183
  type: 'string',
172
- description: 'One city or one country, e.g. "Hong Kong" or "Japan"',
184
+ description: `One city or one country${places.length ? `, e.g. ${places.map((p) => `"${p}"`).join(' or ')}` : ''}`,
173
185
  },
174
186
  ...stayProps,
175
187
  rooms: {
176
- type: 'number',
188
+ type: 'integer',
189
+ minimum: 1,
190
+ maximum: MAX_ROOMS_PER_LINE,
177
191
  description: `With dates: how many rooms of one type the party needs, 1 to ${MAX_ROOMS_PER_LINE}. Default 1`,
178
192
  },
179
193
  sort: {
@@ -230,7 +244,9 @@ const allTools = (n, v) => {
230
244
  },
231
245
  ...stayProps,
232
246
  rooms: {
233
- type: 'number',
247
+ type: 'integer',
248
+ minimum: 1,
249
+ maximum: MAX_ROOMS_PER_LINE,
234
250
  description: `How many rooms of one type, 1 to ${MAX_ROOMS_PER_LINE}. Default 1`,
235
251
  },
236
252
  },
@@ -261,7 +277,9 @@ const allTools = (n, v) => {
261
277
  },
262
278
  ...stayProps,
263
279
  rooms: {
264
- type: 'number',
280
+ type: 'integer',
281
+ minimum: 1,
282
+ maximum: MAX_ROOMS_PER_LINE,
265
283
  description: `How many rooms of one type, 1 to ${MAX_ROOMS_PER_LINE}. Default 1`,
266
284
  },
267
285
  },
@@ -305,11 +323,15 @@ const allTools = (n, v) => {
305
323
  },
306
324
  ...stayProps,
307
325
  rooms: {
308
- type: 'number',
326
+ type: 'integer',
327
+ minimum: 0,
328
+ maximum: MAX_ROOMS_PER_LINE,
309
329
  description: `How many of that room type, 1 to ${MAX_ROOMS_PER_LINE}. Default 1; 0 removes the line`,
310
330
  },
311
331
  quantity: {
312
- type: 'number',
332
+ type: 'integer',
333
+ minimum: 0,
334
+ maximum: MAX_EXTRA_QUANTITY,
313
335
  description: `For an add-on: how many, 1 to ${MAX_EXTRA_QUANTITY}. Default 1; 0 removes it`,
314
336
  },
315
337
  },
@@ -330,7 +352,12 @@ const allTools = (n, v) => {
330
352
  },
331
353
  {
332
354
  name: n.requestBooking,
333
- description: `Asks ${v.brand} to hold the trip's rooms for a named guest and returns a confirmation code and the moment the hold lapses. No payment is taken and no card is collected; the rooms are held, not paid for. Preferences - a bed type, a floor, a late arrival - ride along on the booking as requests the desk tries to meet.`,
355
+ // ⚠️ A DEMONSTRATION BOOKING IS SAID AS ONE (2026-10-05 audit, P2):
356
+ // "asks Shangri-La to hold the rooms" read as a real hold, and a guest
357
+ // could take the code to the real hotel.
358
+ description: v.demo
359
+ ? `Makes a demonstration booking request for the trip's rooms, for a named guest: it returns a confirmation code and the moment the demonstration hold lapses on its own. ${v.brand} does not receive it and no real room is held. No payment is taken and no card is collected. Preferences - a bed type, a floor, a late arrival - ride along on the request.`
360
+ : `Asks ${v.brand} to hold the trip's rooms for a named guest and returns a confirmation code and the moment the hold lapses. No payment is taken and no card is collected; the rooms are held, not paid for. Preferences - a bed type, a floor, a late arrival - ride along on the booking as requests the desk tries to meet.`,
334
361
  annotations: books(`Request a ${v.brand} booking`, open),
335
362
  inputSchema: {
336
363
  type: 'object',
@@ -436,7 +463,9 @@ const allTools = (n, v) => {
436
463
  description: 'The new check-out, YYYY-MM-DD. Leave out to keep it',
437
464
  },
438
465
  rooms: {
439
- type: 'number',
466
+ type: 'integer',
467
+ minimum: 1,
468
+ maximum: MAX_ROOMS_PER_LINE,
440
469
  description: `How many of that room type, 1 to ${MAX_ROOMS_PER_LINE}. Leave out to keep it`,
441
470
  },
442
471
  room: {
@@ -448,7 +477,9 @@ const allTools = (n, v) => {
448
477
  description: `An add-on at this ${v.noun}, by id or name, to put on the booking, take off or change - with \`quantity\``,
449
478
  },
450
479
  quantity: {
451
- type: 'number',
480
+ type: 'integer',
481
+ minimum: 0,
482
+ maximum: MAX_EXTRA_QUANTITY,
452
483
  description: `How many of that add-on the booking should hold, 0 to ${MAX_EXTRA_QUANTITY}; 0 takes it off`,
453
484
  },
454
485
  email: {
@@ -476,7 +507,9 @@ const allTools = (n, v) => {
476
507
  description: 'The new check-out, YYYY-MM-DD. Leave out to keep it',
477
508
  },
478
509
  rooms: {
479
- type: 'number',
510
+ type: 'integer',
511
+ minimum: 1,
512
+ maximum: MAX_ROOMS_PER_LINE,
480
513
  description: `How many of that room type, 1 to ${MAX_ROOMS_PER_LINE}. Leave out to keep it`,
481
514
  },
482
515
  room: {
@@ -488,7 +521,9 @@ const allTools = (n, v) => {
488
521
  description: `An add-on at this ${v.noun}, by id or name, to put on the booking, take off or change - with \`quantity\``,
489
522
  },
490
523
  quantity: {
491
- type: 'number',
524
+ type: 'integer',
525
+ minimum: 0,
526
+ maximum: MAX_EXTRA_QUANTITY,
492
527
  description: `How many of that add-on the booking should hold, 0 to ${MAX_EXTRA_QUANTITY}; 0 takes it off`,
493
528
  },
494
529
  email: {
@@ -555,7 +590,10 @@ const allTools = (n, v) => {
555
590
  items: {
556
591
  type: 'object',
557
592
  properties: {
558
- label: { type: 'string', description: 'e.g. "Day 1 — Arrive in Hong Kong"' },
593
+ label: {
594
+ type: 'string',
595
+ description: `e.g. "Day 1 — Arrive${places[0] ? ` in ${places[0]}` : ''}"`,
596
+ },
559
597
  note: {
560
598
  type: 'string',
561
599
  description: 'One or two lines on the day, in your own words',
@@ -722,6 +760,31 @@ const CHANGE_SCHEMA = {
722
760
  },
723
761
  required: ['view'],
724
762
  };
763
+ /**
764
+ * The output schemas with what this backend never fills taken out.
765
+ *
766
+ * ⚠️ A SCHEMA PROMISES. A Huazhu hotel's still promised its add-ons and
767
+ * policies, and every trip a member badge and saving (2026-10-05 audit) -
768
+ * fields no answer there ever carries.
769
+ */
770
+ export const outputSchemasFor = (schemas, caps) => {
771
+ const gone = [
772
+ ...(caps.members ? [] : ['member', 'memberSaving']),
773
+ ...(caps.extras ? [] : ['extras']),
774
+ ...(caps.policies === false ? ['policies'] : []),
775
+ ];
776
+ if (!gone.length)
777
+ return schemas;
778
+ return Object.fromEntries(Object.entries(schemas).map(([name, schema]) => {
779
+ const s = schema;
780
+ if (!s?.properties)
781
+ return [name, schema];
782
+ const properties = { ...s.properties };
783
+ for (const k of gone)
784
+ delete properties[k];
785
+ return [name, { ...s, properties }];
786
+ }));
787
+ };
725
788
  export const buildOutputSchemas = (n) => ({
726
789
  [n.listHotels]: {
727
790
  type: 'object',
@@ -990,8 +1053,25 @@ const bookedLine = (deps, b) => {
990
1053
  ? `${total} all in, to be paid at the hotel. ${deps.vocab.brand} has not confirmed this booking yet - that takes up to eight minutes, and ${deps.tools.listBookings} shows when it has. Nothing has been charged.`
991
1054
  : `${total} all in, paid at the hotel. **Nothing has been charged here** - this is ${deps.vocab.brand}'s own reservation, kept until it is cancelled, and ${b.guest.name} pays at check-in.`;
992
1055
  }
993
- return `${total} all in. **Nothing has been charged** - the rooms are held until ${b.holdUntil?.slice(0, 16).replace('T', ' ')} UTC, and ${b.guest.name} presents a card at check-in.`;
1056
+ const lapses = b.holdUntilLocal
1057
+ ? `${b.holdUntilLocal} hotel time`
1058
+ : `${b.holdUntil?.slice(0, 16).replace('T', ' ')} UTC`;
1059
+ // ⚠️ NOT "PRESENTS A CARD AT CHECK-IN" (2026-10-05 audit, P2): a demo
1060
+ // booking has no check-in, and saying so sent a guest towards a real desk.
1061
+ if (deps.vocab.demo)
1062
+ return `${total} all in. **${demoBookingWords(deps)}** Nothing has been charged and no card is needed. The demonstration hold lapses on its own at ${lapses}.`;
1063
+ return `${total} all in. **Nothing has been charged** - the rooms are held until ${lapses}, and ${b.guest.name} presents a card at check-in.`;
994
1064
  };
1065
+ /** What a demonstration booking is, in one sentence - see `demoBookingNote`. */
1066
+ const demoBookingWords = (deps) => `This is a demonstration booking: ${deps.vocab.brand} has not received it and is not holding any room.`;
1067
+ /**
1068
+ * Said on every answer about a demonstration booking - made, changed or
1069
+ * cancelled - and on nothing else: a hold of the demo's that a guest took for
1070
+ * the hotel's would send them to a desk with a code nobody there has.
1071
+ */
1072
+ const demoBookingNote = (deps) => deps.vocab.demo && (deps.backend.capabilities ?? FULL_CAPABILITIES).booking === 'hold'
1073
+ ? demoBookingWords(deps)
1074
+ : '';
995
1075
  const common = (deps, key) => ({
996
1076
  brand: deps.vocab.brand,
997
1077
  ...(deps.logoUrl ? { logoUrl: deps.logoUrl } : {}),
@@ -1038,7 +1118,7 @@ const quoteForCard = (quote) => ({
1038
1118
  day: l.day,
1039
1119
  })),
1040
1120
  subtotal: formatMoney(quote.subtotal),
1041
- taxesAndFees: formatMoney(quote.taxesAndFees),
1121
+ taxesAndFees: quote.taxesIncluded ? 'Included' : formatMoney(quote.taxesAndFees),
1042
1122
  total: formatMoney(quote.total),
1043
1123
  // ⚠️ NOT `member`: that key means "who is signed in" on every payload, and
1044
1124
  // the quote's block is a different shape (a tier NAME, a saving, points).
@@ -1057,7 +1137,7 @@ const tripText = (quote, demo) => {
1057
1137
  : `- ${l.ordered ?? l.quantity}× ${l.title}${l.ordered !== undefined && l.quantity !== undefined && l.quantity !== l.ordered ? ` (${l.quantity} in all, over the stay)` : ''} — ${formatMoney(l.total)}`);
1058
1138
  return [
1059
1139
  ...lines,
1060
- `Taxes and fees ${formatMoney(quote.taxesAndFees)} · **Total ${formatMoney(quote.total)}**`,
1140
+ `Taxes and fees ${quote.taxesIncluded ? 'included in the prices' : formatMoney(quote.taxesAndFees)} · **Total ${formatMoney(quote.total)}**`,
1061
1141
  ...(quote.dropped ?? []).map((d) => `Left out: ${d.title} — ${d.why}`),
1062
1142
  demo ? DEMO_NOTE : '',
1063
1143
  ]
@@ -1265,7 +1345,11 @@ const planGap = (plan) => {
1265
1345
  const bookingLine = (b) => {
1266
1346
  const kinds = b.requests?.kinds ?? [];
1267
1347
  return [
1268
- `${b.confirmationCode} · ${b.hotelName} · ${b.stay.checkIn} to ${b.stay.checkOut} · ${b.status}`,
1348
+ `${b.confirmationCode} · ${b.hotelName} · ${b.stay.checkIn} to ${b.stay.checkOut} · ${
1349
+ // ⚠️ SAID AS WHAT IT IS - see BookingStatus. Neither booked nor failed.
1350
+ b.status === 'unconfirmed'
1351
+ ? 'unconfirmed: whether it was made is not known yet, and a person is confirming it'
1352
+ : b.status}`,
1269
1353
  kinds.length ? ` · asked for ${kinds.map(bare).join(', ')}` : '',
1270
1354
  ].join('');
1271
1355
  };
@@ -1394,6 +1478,42 @@ const remember = (deps, k, amount) => {
1394
1478
  m.delete(oldest);
1395
1479
  }
1396
1480
  };
1481
+ /** The counts a call can carry, and what each may be. */
1482
+ const COUNTS = ['guests', 'rooms', 'quantity'];
1483
+ /** "2" is 2: a host that sends a count as a string has not asked for something else. */
1484
+ const countsAsNumbers = (args) => {
1485
+ const out = { ...args };
1486
+ for (const f of COUNTS) {
1487
+ const v = out[f];
1488
+ if (typeof v === 'string' && /^\s*-?\d+\s*$/.test(v))
1489
+ out[f] = Number(v);
1490
+ }
1491
+ return out;
1492
+ };
1493
+ /**
1494
+ * Why a count in this call is not one this connector takes - or nothing.
1495
+ *
1496
+ * ⚠️ REFUSED, NOT QUIETLY MADE INTO SOMETHING ELSE (2026-10-05 audit, P2):
1497
+ * six rooms became five, -1 became 1 and a billion became five, and the answer
1498
+ * spoke as if that was what had been asked. A guest who asked for six rooms
1499
+ * has to hear that five is the most, not be booked five.
1500
+ */
1501
+ const countProblem = (t, name, args) => {
1502
+ const check = (field, min, max) => {
1503
+ const v = args[field];
1504
+ if (v === undefined || v === null)
1505
+ return undefined;
1506
+ if (typeof v === 'number' && Number.isInteger(v) && v >= min && v <= max)
1507
+ return undefined;
1508
+ return `\`${field}\` has to be a whole number from ${min} to ${max}, and ${JSON.stringify(v)} is not - nothing was changed. Ask the guest, or use a number in that range.`;
1509
+ };
1510
+ // 0 means something here: take the line out of the trip, or - on a change -
1511
+ // the backend's own "a booking keeps at least one room; cancel it instead".
1512
+ const removes = name === t.addToTrip || name === t.previewChange || name === t.changeBooking;
1513
+ return (check('guests', 1, MAX_GUESTS) ??
1514
+ check('rooms', removes ? 0 : 1, MAX_ROOMS_PER_LINE) ??
1515
+ check('quantity', 0, MAX_EXTRA_QUANTITY));
1516
+ };
1397
1517
  /**
1398
1518
  * What a backend's refusal says, for the guest - or, when the failure is the
1399
1519
  * code's own, a sentence that says nothing about the code.
@@ -1499,6 +1619,11 @@ const needsRoomIn = (t, lines, parts) => {
1499
1619
  const currentTrip = async (deps, key, who) => {
1500
1620
  const lines = deps.trips.read(key);
1501
1621
  const parts = await quoteParts(deps, lines, who);
1622
+ // ⚠️ WHAT THE GUEST IS SHOWN IS WHAT IS BOOKED - see `ShownQuote`. Every
1623
+ // priced trip answer goes through here: add_to_trip, view_trip, and the
1624
+ // card's own trip view.
1625
+ if (lines.length)
1626
+ deps.trips.show(key, shownOf(parts));
1502
1627
  const [only] = parts;
1503
1628
  const orphans = needsRoomIn(deps.tools, lines, parts);
1504
1629
  const needs = orphans.needsRoom.length ? { needsRoom: orphans.needsRoom } : {};
@@ -1773,9 +1898,17 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
1773
1898
  : answer;
1774
1899
  };
1775
1900
  const answerTool = async (deps, name, rawArgs, ctx) => {
1776
- const args = (rawArgs ?? {});
1901
+ const args = countsAsNumbers((rawArgs ?? {}));
1777
1902
  const t = deps.tools;
1778
1903
  const key = ctx.tripKey ?? tripKey(ctx.session, deps.vocab.brand);
1904
+ const badCount = countProblem(t, name, args);
1905
+ if (badCount) {
1906
+ return {
1907
+ structured: { view: 'error', ...common(deps, key), error: 'bad-count', detail: badCount },
1908
+ text: badCount,
1909
+ isError: true,
1910
+ };
1911
+ }
1779
1912
  if (name === t.listHotels) {
1780
1913
  const query = str(args.query);
1781
1914
  const city = str(args.city);
@@ -2353,7 +2486,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2353
2486
  ].join('\n')
2354
2487
  : (detail.noRooms ?? `No room type here has ${roomsWanted} left for those nights.`),
2355
2488
  extras.length
2356
- ? `Add-ons: ${extras.map((e) => `${e.name} (id \`${e.id}\`, ${formatMoney(e.price)} per ${e.per}${e.when && e.when !== 'any' ? `, on ${e.when}` : ''})`).join('; ')}`
2489
+ ? `Add-ons: ${extras.map((e) => `${e.name} (id \`${e.id}\`, ${formatMoney(e.price)} ${unitWords(e.per)}${e.when && e.when !== 'any' ? `, on ${e.when}` : ''})`).join('; ')}`
2357
2490
  : '',
2358
2491
  ]
2359
2492
  .filter(Boolean)
@@ -2451,7 +2584,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2451
2584
  },
2452
2585
  text: extras.length
2453
2586
  ? extras
2454
- .map((e) => `- ${e.name} — ${formatMoney(e.price)} per ${e.per}${held[e.id] ? ` (${booked?.confirmationCode} holds ${held[e.id]})` : ''}`)
2587
+ .map((e) => `- ${e.name} — ${formatMoney(e.price)} ${unitWords(e.per)}${held[e.id] ? ` (${booked?.confirmationCode} holds ${held[e.id]})` : ''}`)
2455
2588
  .concat(deps.vocab.demo ? [DEMO_NOTE] : [])
2456
2589
  .join('\n')
2457
2590
  : `${detail.hotel.name} has no add-ons listed here.`,
@@ -2599,7 +2732,8 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2599
2732
  }
2600
2733
  const { requests, dropped } = readRequests(args);
2601
2734
  try {
2602
- const made = await deps.backend.requestBooking({ lines }, {
2735
+ const shown = deps.trips.shown(key);
2736
+ const made = await deps.backend.requestBooking({ lines, ...(shown ? { shown } : {}) }, {
2603
2737
  ...guest,
2604
2738
  ...(str(args.phone) ? { phone: str(args.phone) } : {}),
2605
2739
  ...(requests?.note ? { notes: requests.note } : {}),
@@ -2714,6 +2848,13 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2714
2848
  }
2715
2849
  catch (err) {
2716
2850
  const words = guestWords(err, BOOKING_FAULT);
2851
+ // The price or terms moved since the guest saw them: nothing was booked,
2852
+ // and the trip is shown again at today's - which is then what the guest
2853
+ // has seen, and what a second ask books.
2854
+ if (err.code === 'price-changed') {
2855
+ const again = await currentTrip(deps, key, identity(deps, ctx, key));
2856
+ return { ...again, text: [words, again.text].join('\n\n') };
2857
+ }
2717
2858
  return {
2718
2859
  structured: {
2719
2860
  view: 'error',
@@ -2830,6 +2971,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2830
2971
  : 'The total is unchanged.',
2831
2972
  preview.rule,
2832
2973
  `Say the word and it moves (${t.changeBooking} with \`confirmed\`).`,
2974
+ demoBookingNote(deps),
2833
2975
  deps.vocab.demo ? DEMO_NOTE : '',
2834
2976
  ]
2835
2977
  .filter(Boolean)
@@ -2892,6 +3034,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2892
3034
  text: [
2893
3035
  `${changed.confirmationCode} now holds ${changed.stay.checkIn} to ${changed.stay.checkOut}, ${formatMoney(changed.quote.total)} all in.`,
2894
3036
  'The confirmation code has not changed. **Nothing has been charged.**',
3037
+ demoBookingNote(deps),
2895
3038
  deps.vocab.demo ? DEMO_NOTE : '',
2896
3039
  ]
2897
3040
  .filter(Boolean)
@@ -2937,6 +3080,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2937
3080
  preview.cancellable
2938
3081
  ? `Say the word and it is cancelled (${t.cancelBooking} with \`confirmed\`).`
2939
3082
  : '',
3083
+ demoBookingNote(deps),
2940
3084
  deps.vocab.demo ? DEMO_NOTE : '',
2941
3085
  ]
2942
3086
  .filter(Boolean)
@@ -2983,7 +3127,14 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2983
3127
  cancellable: false,
2984
3128
  },
2985
3129
  text: [
2986
- `${cancelled.confirmationCode} is cancelled. ${charged.minor === 0 ? 'No fee.' : `The hotel charges ${formatMoney(charged)}.`}`,
3130
+ deps.vocab.sandbox
3131
+ ? // A test booking cancelled in the simulation: no hotel, no charge (2026-10-05 audit, P2).
3132
+ `${cancelled.confirmationCode} is cancelled in the simulated system. ${charged.minor === 0 ? 'No fee.' : `A real booking on this rate would be charged ${formatMoney(charged)}; this one is a test, and nothing is charged.`}`
3133
+ : deps.vocab.demo
3134
+ ? // Nobody charges a demonstration anything (2026-10-05 audit, P2).
3135
+ `${cancelled.confirmationCode} is cancelled. ${charged.minor === 0 ? 'No fee.' : `A real booking on this rate would be charged ${formatMoney(charged)}; this is a demonstration, and nothing is charged.`}`
3136
+ : `${cancelled.confirmationCode} is cancelled. ${charged.minor === 0 ? 'No fee.' : `The hotel charges ${formatMoney(charged)}.`}`,
3137
+ demoBookingNote(deps),
2987
3138
  deps.vocab.demo ? DEMO_NOTE : '',
2988
3139
  ]
2989
3140
  .filter(Boolean)