@danceiny/gotry 0.0.1-rc.18 → 0.0.1-rc.20

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 (176) hide show
  1. package/README.md +42 -16
  2. package/README.zh-CN.md +37 -15
  3. package/bin/gotry-bootstrap.js +309 -30
  4. package/bin/gotry-inner.js +74 -23
  5. package/bin/gotry-runtime-resolution.d.ts +1 -1
  6. package/bin/gotry-runtime-resolution.js +7 -8
  7. package/cordis.gotry-patch.yml +22 -8
  8. package/data/stations-12306-verify.json +524 -0
  9. package/dist/capabilities/agent-reach.js +4 -4
  10. package/dist/capabilities/anything.js +62 -29
  11. package/dist/capabilities/channel-health.js +103 -0
  12. package/dist/capabilities/channel-registry.js +213 -0
  13. package/dist/capabilities/doctor.js +307 -0
  14. package/dist/capabilities/effect.js +178 -5
  15. package/dist/capabilities/flyai.js +13 -0
  16. package/dist/capabilities/hbcli.js +28 -2
  17. package/dist/capabilities/session/adapters/ctrip-hotel.js +207 -0
  18. package/dist/capabilities/session/adapters/rail-12306.js +285 -0
  19. package/dist/capabilities/session/extension-bridge.js +14 -1
  20. package/dist/capabilities/session/extension-channel.js +4 -2
  21. package/dist/capabilities/session/extension-distribution.js +1 -1
  22. package/dist/capabilities/session-search.js +311 -0
  23. package/dist/capabilities/visa-policy.js +126 -0
  24. package/dist/scripts/agent-planning-turn-deadline-e2e.js +305 -0
  25. package/dist/scripts/agent-planning-turn-deadline-tests.js +281 -0
  26. package/dist/scripts/anything-tests.js +70 -21
  27. package/dist/scripts/benchmark-environment-bridge-e2e.js +139 -37
  28. package/dist/scripts/benchmark-environment-bridge-tests.js +1099 -306
  29. package/dist/scripts/booking-copilot-availability-ledger-binding-tests.js +26 -26
  30. package/dist/scripts/{booking-copilot-availability-policy-v2-tests.js → booking-copilot-availability-policy-tests.js} +115 -115
  31. package/dist/scripts/booking-copilot-crossrepo-fixture-server.js +41 -1
  32. package/dist/scripts/booking-copilot-dsh-core-proof-tests.js +75 -120
  33. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +370 -61
  34. package/dist/scripts/booking-copilot-dsh-plugin-proof-tests.js +2 -2
  35. package/dist/scripts/booking-copilot-event-sequence-concurrency-proof-tests.js +7 -3
  36. package/dist/scripts/booking-copilot-gap-code-contract-proof-tests.js +15 -9
  37. package/dist/scripts/booking-copilot-operation-ledger-concurrency-proof-tests.js +19 -11
  38. package/dist/scripts/booking-copilot-receipt-ledger-concurrency-proof-tests.js +16 -52
  39. package/dist/scripts/booking-copilot-runtime-proof-tests.js +3953 -155
  40. package/dist/scripts/booking-copilot-server-proof-tests.js +58 -23
  41. package/dist/scripts/booking-copilot-startup-proof-tests.js +10 -119
  42. package/dist/scripts/booking-surface-contract-proof-tests.js +1154 -326
  43. package/dist/scripts/bootstrap-tests.js +113 -2
  44. package/dist/scripts/build-metrics-report.js +339 -0
  45. package/dist/scripts/channel-probe-tests.js +148 -0
  46. package/dist/scripts/channel-probe.js +252 -0
  47. package/dist/scripts/channel-registry-tests.js +262 -0
  48. package/dist/scripts/doctor-tests.js +175 -0
  49. package/dist/scripts/effect-tests.js +274 -1
  50. package/dist/scripts/extension-tests.js +129 -11
  51. package/dist/scripts/fact-gate-tests.js +91 -2
  52. package/dist/scripts/flyai-tests.js +17 -1
  53. package/dist/scripts/hbcli-e2e-tests.js +1 -3
  54. package/dist/scripts/hbcli-tests.js +38 -1
  55. package/dist/scripts/metrics-report-tests.js +288 -0
  56. package/dist/scripts/persona-surface-guard-tests.js +19 -0
  57. package/dist/scripts/session-tests.js +401 -1
  58. package/dist/scripts/smoke.js +100 -83
  59. package/dist/scripts/turn-handoff-collect-tests.js +216 -0
  60. package/dist/scripts/turn-handoff-collect.js +189 -0
  61. package/dist/scripts/turn-policy-tests.js +56 -0
  62. package/dist/scripts/typed-contract-canary.js +261 -0
  63. package/dist/scripts/visa-policy-tests.js +75 -0
  64. package/dist/scripts/wish-channel-gate-tests.js +111 -0
  65. package/dist/src/artifact-gate.js +91 -3
  66. package/dist/src/benchmark-agent-conformance.js +39 -16
  67. package/dist/src/benchmark-environment-bridge.js +316 -52
  68. package/dist/src/bookable-facts.js +62 -3
  69. package/dist/src/booking-surface/{availability-policy-v2.js → availability-policy.js} +89 -89
  70. package/dist/src/booking-surface/canonical-schema.js +8 -22
  71. package/dist/src/booking-surface/contracts.js +70 -19
  72. package/dist/src/booking-surface/dsh-planner.js +310 -177
  73. package/dist/src/booking-surface/dsh-plugin.js +3 -3
  74. package/dist/src/booking-surface/index.js +1 -5
  75. package/dist/src/booking-surface/profile.js +1 -1
  76. package/dist/src/booking-surface/runtime.js +1682 -247
  77. package/dist/src/booking-surface/server.js +371 -185
  78. package/dist/src/booking-surface/startup.js +17 -27
  79. package/dist/src/booking-surface/validation.js +270 -760
  80. package/dist/src/index.js +739 -150
  81. package/dist/src/turn-deadline.js +292 -0
  82. package/dist/src/turn-policy.js +146 -0
  83. package/dist/src/wish-pool.js +5 -0
  84. package/extension/background.js +23 -4
  85. package/extension/content-main.js +47 -9
  86. package/extension/manifest.json +27 -9
  87. package/package.json +7 -26
  88. package/schemas/booking.surface.schema.json +2593 -0
  89. package/ts/capabilities/agent-reach.ts +4 -4
  90. package/ts/capabilities/anything.ts +89 -38
  91. package/ts/capabilities/flyai.ts +21 -3
  92. package/ts/capabilities/hbcli.ts +53 -4
  93. package/ts/capabilities/session/action-cache.ts +1 -1
  94. package/ts/capabilities/session/adapters/ctrip-hotel.ts +238 -0
  95. package/ts/capabilities/session/adapters/rail-12306.ts +293 -0
  96. package/ts/capabilities/session/extension-bridge.ts +32 -2
  97. package/ts/capabilities/session/extension-channel.ts +9 -8
  98. package/ts/capabilities/session/extension-distribution.ts +3 -1
  99. package/ts/capabilities/session/health-watch.ts +1 -1
  100. package/ts/capabilities/session/read-guard.ts +1 -1
  101. package/ts/capabilities/session/wizard.ts +1 -1
  102. package/ts/capabilities/session-search.ts +323 -1
  103. package/ts/dsh-runtime/vendor/README.md +60 -0
  104. package/ts/dsh-runtime/vendor/dsh-map-tools/LICENSE +21 -0
  105. package/ts/dsh-runtime/vendor/dsh-map-tools/README.en.md +200 -0
  106. package/ts/dsh-runtime/vendor/dsh-map-tools/README.md +202 -0
  107. package/ts/dsh-runtime/vendor/dsh-map-tools/client/client.js +216 -0
  108. package/ts/dsh-runtime/vendor/dsh-map-tools/cordis.patch.yml +5 -0
  109. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/amap.js +371 -0
  110. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/nominatim.js +63 -0
  111. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/osrm.js +56 -0
  112. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/photon.js +55 -0
  113. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/config-file.js +87 -0
  114. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/config-route.js +126 -0
  115. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/config.js +16 -0
  116. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/index.js +109 -0
  117. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/settings-ns.js +27 -0
  118. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/tools/geocode.js +127 -0
  119. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/tools/poi.js +84 -0
  120. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/tools/routes.js +170 -0
  121. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/amap.d.ts +53 -0
  122. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/nominatim.d.ts +17 -0
  123. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/osrm.d.ts +15 -0
  124. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/photon.d.ts +22 -0
  125. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/config-file.d.ts +33 -0
  126. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/config-route.d.ts +20 -0
  127. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/config.d.ts +23 -0
  128. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/index.d.ts +18 -0
  129. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/settings-ns.d.ts +14 -0
  130. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/tools/geocode.d.ts +11 -0
  131. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/tools/poi.d.ts +9 -0
  132. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/tools/routes.d.ts +16 -0
  133. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/types.d.ts +55 -0
  134. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types.js +16 -0
  135. package/ts/dsh-runtime/vendor/dsh-map-tools/package.json +91 -0
  136. package/ts/package.json +9 -0
  137. package/ts/scripts/turn-handoff-collect.ts +178 -0
  138. package/ts/src/artifact-gate.ts +82 -2
  139. package/ts/src/benchmark-agent-conformance.ts +54 -15
  140. package/ts/src/benchmark-environment-bridge.ts +234 -46
  141. package/ts/src/bookable-facts.ts +109 -5
  142. package/ts/src/booking-saga.ts +1 -1
  143. package/ts/src/booking-surface/{availability-policy-v2.ts → availability-policy.ts} +124 -125
  144. package/ts/src/booking-surface/canonical-schema.js +8 -22
  145. package/ts/src/booking-surface/contracts.ts +278 -239
  146. package/ts/src/booking-surface/dsh-planner.ts +333 -194
  147. package/ts/src/booking-surface/dsh-plugin.js +3 -3
  148. package/ts/src/booking-surface/index.ts +1 -5
  149. package/ts/src/booking-surface/profile.ts +11 -11
  150. package/ts/src/booking-surface/runtime.ts +1351 -351
  151. package/ts/src/booking-surface/server.ts +282 -175
  152. package/ts/src/booking-surface/startup.ts +36 -48
  153. package/ts/src/booking-surface/validation.ts +194 -442
  154. package/ts/src/contracts.ts +1 -1
  155. package/ts/src/index.ts +492 -172
  156. package/ts/src/loop.ts +1 -1
  157. package/ts/src/state-ledger.ts +1 -1
  158. package/ts/src/turn-deadline.ts +365 -0
  159. package/ts/src/turn-policy.ts +154 -0
  160. package/ts/src/wish-pool.ts +17 -4
  161. package/dist/scripts/agent-planning-budget-e2e.js +0 -227
  162. package/dist/scripts/agent-planning-budget-tests.js +0 -173
  163. package/dist/scripts/booking-copilot-v2-runtime-proof-tests.js +0 -3935
  164. package/dist/scripts/booking-surface-v2-contract-proof-tests.js +0 -1174
  165. package/dist/src/booking-surface/contracts-v2.js +0 -89
  166. package/dist/src/booking-surface/runtime-v2.js +0 -1771
  167. package/dist/src/booking-surface/server-v2.js +0 -334
  168. package/dist/src/booking-surface/validation-v2.js +0 -319
  169. package/dist/src/tool-budget.js +0 -136
  170. package/schemas/booking.surface.v1.schema.json +0 -927
  171. package/schemas/booking.surface.v2.schema.json +0 -61
  172. package/ts/src/booking-surface/contracts-v2.ts +0 -118
  173. package/ts/src/booking-surface/runtime-v2.ts +0 -1466
  174. package/ts/src/booking-surface/server-v2.ts +0 -247
  175. package/ts/src/booking-surface/validation-v2.ts +0 -205
  176. package/ts/src/tool-budget.ts +0 -165
@@ -1,3935 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import { mkdtempSync, rmSync } from 'node:fs';
3
- import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
5
- import { ensureLedger } from '../src/state-ledger.js';
6
- import { BookingCopilotTaskRuntime } from '../src/booking-surface/runtime.js';
7
- import { BookingCopilotTaskRuntimeV2, bookingV2Digest } from '../src/booking-surface/runtime-v2.js';
8
- import { startBookingCopilotServer } from '../src/booking-surface/server.js';
9
- import { BOOKING_READ_ACTION_KINDS_V2, BOOKING_SURFACE_SCHEMA_VERSION_V2 } from '../src/booking-surface/contracts-v2.js';
10
- import { BOOKING_SURFACE_SCHEMA_V2_SHA256 } from '../src/booking-surface/contracts-v2.js';
11
- import { BOOKING_SURFACE_SCHEMA_SHA256, BOOKING_SURFACE_SCHEMA_VERSION } from '../src/booking-surface/contracts.js';
12
- import { validateBookingSurfaceV2 } from '../src/booking-surface/validation-v2.js';
13
- const stateRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-runtime-'));
14
- const workspace = (revision = 0)=>({
15
- schemaVersion: 'booking.surface.v2',
16
- contextRef: 'ctx-v2',
17
- surface: 'tenant',
18
- revision,
19
- locale: 'en-US',
20
- currency: 'AED',
21
- searchDraft: {},
22
- results: {
23
- status: 'idle'
24
- },
25
- visibleHotels: [],
26
- loadedOffers: [],
27
- shortlistedOfferRefs: [],
28
- capabilities: {
29
- surface: 'tenant',
30
- allowedActions: [
31
- ...BOOKING_READ_ACTION_KINDS_V2
32
- ]
33
- }
34
- });
35
- const loadedOffer = (offerRef, hotelRef, offerVersionRef = `${offerRef}:v1`)=>({
36
- offerRef,
37
- offerVersionRef,
38
- hotelRef,
39
- evidenceLevel: 'rate_loaded',
40
- factRefs: []
41
- });
42
- const verifiedCapability = (offerRef, offerVersionRef = `${offerRef}:v1`, expiresAt = '2026-09-01T10:30:00.000Z')=>({
43
- offerRef,
44
- offerVersionRef,
45
- verifiedOfferRef: `verified-${offerRef}`,
46
- expiresAt
47
- });
48
- const availabilityObservation = (offerRef, checkedOfferVersionRef = `${offerRef}:v1`, available = false, currentOfferVersionRef)=>({
49
- kind: 'offer.availability',
50
- offerRef,
51
- checkedOfferVersionRef,
52
- currentOfferVersionRef: available ? currentOfferVersionRef ?? checkedOfferVersionRef : undefined,
53
- available,
54
- changedFactRefs: [],
55
- gapCodes: []
56
- });
57
- const oldWorkspaceDigest = (value)=>{
58
- const { contextRef: _contextRef, ...withoutContext } = value;
59
- return bookingV2Digest(withoutContext);
60
- };
61
- const oldWorkspaceSemanticDigest = (value)=>{
62
- const { contextRef: _contextRef, revision: _revision, ...semantic } = value;
63
- return bookingV2Digest(semantic);
64
- };
65
- const legacyWorkspace = (value, verifiedOfferRef)=>{
66
- const workspaceValue = structuredClone(value);
67
- const loadedOffers = Array.isArray(workspaceValue.loadedOffers) ? workspaceValue.loadedOffers : [];
68
- workspaceValue.loadedOffers = loadedOffers.map((loaded)=>{
69
- const oldOffer = {
70
- ...loaded
71
- };
72
- delete oldOffer.offerVersionRef;
73
- return oldOffer;
74
- });
75
- delete workspaceValue.verifiedOffer;
76
- if (verifiedOfferRef !== undefined) workspaceValue.verifiedOfferRef = verifiedOfferRef;
77
- return workspaceValue;
78
- };
79
- const legacyAvailability = (value, workspaceDigest)=>{
80
- const availability = structuredClone(value);
81
- const hotels = availability.hotels;
82
- if (hotels && typeof hotels === 'object' && !Array.isArray(hotels)) {
83
- for (const hotel of Object.values(hotels)){
84
- if (!hotel || typeof hotel !== 'object' || Array.isArray(hotel)) continue;
85
- const oldHotel = hotel;
86
- delete oldHotel.tombstonedOfferVersionRefs;
87
- const generation = oldHotel.currentGeneration;
88
- if (workspaceDigest && generation && typeof generation === 'object' && !Array.isArray(generation)) {
89
- const source = generation.source;
90
- if (source && typeof source === 'object' && !Array.isArray(source) && source.kind === 'workspace_snapshot') source.workspaceDigest = workspaceDigest;
91
- }
92
- }
93
- }
94
- if (Array.isArray(availability.attempts)) {
95
- availability.attempts = availability.attempts.map((attempt)=>{
96
- if (!attempt || typeof attempt !== 'object' || Array.isArray(attempt)) return attempt;
97
- const oldAttempt = {
98
- ...attempt
99
- };
100
- delete oldAttempt.offerVersionRef;
101
- return oldAttempt;
102
- });
103
- }
104
- return availability;
105
- };
106
- const stripLegacyReceipt = (value)=>{
107
- const receipt = structuredClone(value);
108
- const observation = receipt.observation;
109
- if (observation && typeof observation === 'object' && !Array.isArray(observation)) {
110
- const oldObservation = observation;
111
- delete oldObservation.offerVersionRef;
112
- delete oldObservation.checkedOfferVersionRef;
113
- delete oldObservation.currentOfferVersionRef;
114
- }
115
- return receipt;
116
- };
117
- const legacyTurnDigest = (taskId, turnId, workspaceValue, text)=>bookingV2Digest({
118
- schemaVersion: 'booking.surface.v2',
119
- kind: 'user.turn',
120
- taskId,
121
- turnId,
122
- workspace: workspaceValue,
123
- request: {
124
- text
125
- }
126
- });
127
- const turn = (taskId, revision = 0)=>({
128
- schemaVersion: 'booking.surface.v2',
129
- kind: 'user.turn',
130
- taskId,
131
- turnId: `${taskId}-turn-${revision}`,
132
- workspace: workspace(revision),
133
- request: {
134
- text: 'find hotels in Dubai'
135
- }
136
- });
137
- const action = (id, revision = 0, extra = {})=>({
138
- schemaVersion: 'booking.surface.v2',
139
- kind: 'search.run',
140
- actionId: id,
141
- contextRef: 'ctx-v2',
142
- expectedRevision: revision,
143
- reason: 'search current workspace',
144
- factRefs: [],
145
- input: {},
146
- ...extra
147
- });
148
- let id = 0;
149
- const runtime = new BookingCopilotTaskRuntimeV2(ensureLedger(stateRoot), {
150
- idFactory: (prefix)=>`${prefix}-${++id}`,
151
- now: ()=>'2026-09-01T10:00:00.000Z'
152
- });
153
- const task = runtime.startTask(turn('task-v2'));
154
- assert.equal(task.taskId, 'task-v2');
155
- assert.throws(()=>runtime.startTask({
156
- ...turn('task-no-id'),
157
- taskId: undefined
158
- }), /invalid_planner_turn/);
159
- const directIngressRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-missing-task-'));
160
- const directIngressLedger = ensureLedger(directIngressRoot);
161
- const directIngressRuntime = new BookingCopilotTaskRuntimeV2(directIngressLedger);
162
- const directIngressBefore = directIngressLedger.countEvents();
163
- assert.throws(()=>directIngressRuntime.startTask({
164
- schemaVersion: 'booking.surface.v2',
165
- kind: 'user.turn.ingress',
166
- requestKey: 'ingress-no-task',
167
- surfaceHint: 'tenant',
168
- workspace: {
169
- schemaVersion: 'booking.surface.v2',
170
- revision: 0,
171
- locale: 'en-US',
172
- currency: 'AED',
173
- searchDraft: {},
174
- results: {
175
- status: 'idle'
176
- },
177
- visibleHotels: [],
178
- loadedOffers: [],
179
- shortlistedOfferRefs: []
180
- },
181
- request: {
182
- text: 'find hotels in Dubai'
183
- }
184
- }), /ingress_binding_required/);
185
- assert.equal(directIngressLedger.countEvents(), directIngressBefore, 'browser ingress has no direct runtime ledger side effects');
186
- const selectedIngressRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-selected-ingress-'));
187
- const selectedIngressLedger = ensureLedger(selectedIngressRoot);
188
- const selectedIngressRuntime = new BookingCopilotTaskRuntimeV2(selectedIngressLedger, {
189
- contextRefFactory: ()=>'ctx-selected-ingress'
190
- });
191
- const selectedIngress = {
192
- schemaVersion: 'booking.surface.v2',
193
- kind: 'user.turn.ingress',
194
- requestKey: 'selected-ingress-request',
195
- taskHandle: 'opaque-task-handle',
196
- surfaceHint: 'tenant',
197
- workspace: {
198
- schemaVersion: 'booking.surface.v2',
199
- revision: 0,
200
- locale: 'en-US',
201
- currency: 'AED',
202
- searchDraft: {},
203
- results: {
204
- status: 'idle'
205
- },
206
- visibleHotels: [
207
- {
208
- hotelRef: 'hotel-selected',
209
- name: 'Selected Hotel',
210
- factRefs: []
211
- }
212
- ],
213
- loadedOffers: [
214
- loadedOffer('offer-selected', 'hotel-selected')
215
- ],
216
- focusedHotelRef: 'hotel-selected',
217
- shortlistedOfferRefs: [],
218
- selectedOfferRef: 'offer-selected'
219
- },
220
- request: {
221
- text: 'is this selected room still bookable?'
222
- }
223
- };
224
- const selectedIngressTask = selectedIngressRuntime.startTask(turn('task-selected-ingress'));
225
- const selectedPlannerTurn = selectedIngressRuntime.bindIngressTurn(selectedIngress, selectedIngressTask, 'turn-selected-ingress');
226
- assert.equal(selectedPlannerTurn.workspace.focusedHotelRef, 'hotel-selected');
227
- assert.equal(selectedPlannerTurn.workspace.selectedOfferRef, 'offer-selected');
228
- assert.equal(selectedPlannerTurn.workspace.verifiedOffer, undefined, 'initial ingress cannot assert verified availability authority');
229
- selectedIngressLedger.close();
230
- rmSync(selectedIngressRoot, {
231
- recursive: true,
232
- force: true
233
- });
234
- const duplicateOfferWorkspace = {
235
- ...workspace(0),
236
- visibleHotels: [
237
- {
238
- hotelRef: 'hotel-dup-a',
239
- name: 'Hotel Dup A',
240
- factRefs: []
241
- },
242
- {
243
- hotelRef: 'hotel-dup-b',
244
- name: 'Hotel Dup B',
245
- factRefs: []
246
- }
247
- ],
248
- loadedOffers: [
249
- loadedOffer('duplicate-offer-ref', 'hotel-dup-a'),
250
- loadedOffer('duplicate-offer-ref', 'hotel-dup-b')
251
- ]
252
- };
253
- const duplicateRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-duplicate-offer-'));
254
- const duplicateLedger = ensureLedger(duplicateRoot);
255
- const duplicateRuntime = new BookingCopilotTaskRuntimeV2(duplicateLedger, {
256
- contextRefFactory: ()=>'ctx-v2'
257
- });
258
- assert.equal(validateBookingSurfaceV2({
259
- ...turn('validator-duplicate-logical'),
260
- workspace: duplicateOfferWorkspace
261
- }).ok, false, 'published validator rejects duplicate logical offer refs');
262
- const duplicateVersionWorkspace = {
263
- ...workspace(0),
264
- visibleHotels: [
265
- {
266
- hotelRef: 'hotel-dup-a',
267
- name: 'Hotel Dup A',
268
- factRefs: []
269
- },
270
- {
271
- hotelRef: 'hotel-dup-b',
272
- name: 'Hotel Dup B',
273
- factRefs: []
274
- }
275
- ],
276
- loadedOffers: [
277
- loadedOffer('offer-dup-a', 'hotel-dup-a', 'shared-offer:v1'),
278
- loadedOffer('offer-dup-b', 'hotel-dup-b', 'shared-offer:v1')
279
- ]
280
- };
281
- assert.equal(validateBookingSurfaceV2({
282
- ...turn('validator-duplicate-version'),
283
- workspace: duplicateVersionWorkspace
284
- }).ok, false, 'published validator rejects duplicate offer version refs');
285
- const verifiedVersionMismatchWorkspace = {
286
- ...workspace(0),
287
- visibleHotels: [
288
- {
289
- hotelRef: 'hotel-verified',
290
- name: 'Verified Hotel',
291
- factRefs: []
292
- }
293
- ],
294
- loadedOffers: [
295
- loadedOffer('offer-verified', 'hotel-verified', 'offer-verified:v1')
296
- ],
297
- verifiedOffer: verifiedCapability('offer-verified', 'offer-verified:v2')
298
- };
299
- assert.equal(validateBookingSurfaceV2({
300
- ...turn('validator-verified-version-mismatch'),
301
- workspace: verifiedVersionMismatchWorkspace
302
- }).ok, false, 'standalone workspace validation rejects a verified capability for an unloaded version');
303
- assert.equal(validateBookingSurfaceV2({
304
- ...turn('validator-verified-logical-mismatch'),
305
- workspace: {
306
- ...verifiedVersionMismatchWorkspace,
307
- verifiedOffer: verifiedCapability('other-offer', 'other-offer:v1')
308
- }
309
- }).ok, false, 'standalone workspace validation rejects a verified capability for another logical offer');
310
- assert.throws(()=>duplicateRuntime.startTask({
311
- ...turn('task-duplicate-offer'),
312
- workspace: duplicateOfferWorkspace
313
- }), /invalid_planner_turn|availability_duplicate_offer_ref/);
314
- assert.equal(duplicateLedger.countEvents(), 0, 'duplicate loaded OfferRef is rejected at admission before ledger writes');
315
- const duplicateContinuationRuntime = new BookingCopilotTaskRuntimeV2(duplicateLedger, {
316
- contextRefFactory: ()=>'ctx-v2'
317
- });
318
- const duplicateContinuationTask = duplicateContinuationRuntime.startTask({
319
- ...turn('task-duplicate-continuation'),
320
- workspace: {
321
- ...workspace(0),
322
- visibleHotels: [
323
- {
324
- hotelRef: 'hotel-dup-a',
325
- name: 'Hotel Dup A',
326
- factRefs: []
327
- }
328
- ],
329
- loadedOffers: [
330
- loadedOffer('unique-offer-ref', 'hotel-dup-a')
331
- ]
332
- }
333
- });
334
- duplicateContinuationRuntime.issueOperation(duplicateContinuationTask.taskId, {
335
- ...action('duplicate-continuation-check'),
336
- kind: 'offer.check',
337
- input: {
338
- offerRef: 'unique-offer-ref',
339
- offerVersionRef: 'unique-offer-ref:v1'
340
- }
341
- });
342
- assert.throws(()=>duplicateContinuationRuntime.continueWithReceipt({
343
- schemaVersion: 'booking.surface.v2',
344
- kind: 'action.receipt.continuation',
345
- taskId: duplicateContinuationTask.taskId,
346
- workspace: {
347
- ...duplicateOfferWorkspace,
348
- revision: 1
349
- },
350
- receipt: {
351
- schemaVersion: 'booking.surface.v2',
352
- kind: 'action.receipt',
353
- actionId: 'duplicate-continuation-check',
354
- contextRef: 'ctx-v2',
355
- status: 'unavailable',
356
- revision: 1,
357
- observation: availabilityObservation('unique-offer-ref'),
358
- resultContract: {
359
- outcome: 'empty',
360
- hardCriteriaMet: false,
361
- factRefs: [],
362
- gapCodes: [],
363
- blockers: [],
364
- relaxationsApplied: []
365
- }
366
- }
367
- }), /invalid_receipt_continuation|availability_duplicate_offer_ref/);
368
- duplicateContinuationRuntime.appendTurn('task-duplicate-continuation', 'ctx-v2', 'tampered', 'tampered', 'tampered', 2, 'duplicate-replay-turn', duplicateOfferWorkspace);
369
- assert.throws(()=>duplicateContinuationRuntime.resumeTask('task-duplicate-continuation'), /ledger_corrupt:task-duplicate-continuation:turn_workspace/, 'duplicate loaded OfferRef is fail-closed during ledger replay');
370
- duplicateLedger.close();
371
- rmSync(duplicateRoot, {
372
- recursive: true,
373
- force: true
374
- });
375
- const selectRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-select-receipt-binding-'));
376
- const selectLedger = ensureLedger(selectRoot);
377
- const selectRuntime = new BookingCopilotTaskRuntimeV2(selectLedger, {
378
- contextRefFactory: ()=>'ctx-v2'
379
- });
380
- const selectWorkspace = {
381
- ...workspace(0),
382
- visibleHotels: [
383
- {
384
- hotelRef: 'hotel-a',
385
- name: 'Hotel A',
386
- factRefs: []
387
- },
388
- {
389
- hotelRef: 'hotel-b',
390
- name: 'Hotel B',
391
- factRefs: []
392
- }
393
- ],
394
- loadedOffers: [
395
- loadedOffer('offer-a', 'hotel-a'),
396
- loadedOffer('offer-b', 'hotel-b')
397
- ],
398
- shortlistedOfferRefs: [
399
- 'offer-a',
400
- 'offer-b'
401
- ]
402
- };
403
- const selectTask = selectRuntime.startTask({
404
- ...turn('task-select-receipt-binding'),
405
- workspace: selectWorkspace
406
- });
407
- selectRuntime.issueOperation(selectTask.taskId, {
408
- ...action('select-a'),
409
- kind: 'offer.select',
410
- input: {
411
- offerRef: 'offer-a',
412
- offerVersionRef: 'offer-a:v1'
413
- }
414
- });
415
- assert.throws(()=>selectRuntime.continueWithReceipt({
416
- schemaVersion: 'booking.surface.v2',
417
- kind: 'action.receipt.continuation',
418
- taskId: selectTask.taskId,
419
- workspace: {
420
- ...selectWorkspace,
421
- revision: 1,
422
- selectedOfferRef: 'offer-b'
423
- },
424
- receipt: {
425
- schemaVersion: 'booking.surface.v2',
426
- kind: 'action.receipt',
427
- actionId: 'select-a',
428
- contextRef: 'ctx-v2',
429
- status: 'applied',
430
- revision: 1,
431
- observation: {
432
- kind: 'offer.selection',
433
- offerRef: 'offer-a',
434
- offerVersionRef: 'offer-a:v1'
435
- },
436
- resultContract: {
437
- outcome: 'complete',
438
- hardCriteriaMet: true,
439
- factRefs: [],
440
- gapCodes: [],
441
- blockers: [],
442
- relaxationsApplied: []
443
- }
444
- }
445
- }), /workspace_mismatch|receipt_transition/, 'offer.select receipt A cannot select offer B');
446
- selectLedger.close();
447
- rmSync(selectRoot, {
448
- recursive: true,
449
- force: true
450
- });
451
- const checkoutWorkspace = (revision, capability)=>({
452
- ...workspace(revision),
453
- visibleHotels: [
454
- {
455
- hotelRef: 'hotel-a',
456
- name: 'Hotel A',
457
- factRefs: []
458
- },
459
- {
460
- hotelRef: 'hotel-b',
461
- name: 'Hotel B',
462
- factRefs: []
463
- }
464
- ],
465
- loadedOffers: [
466
- loadedOffer('offer-a', 'hotel-a'),
467
- loadedOffer('offer-b', 'hotel-b')
468
- ],
469
- selectedOfferRef: 'offer-a',
470
- verifiedOffer: capability
471
- });
472
- const checkoutReceipt = (actionId, offerRef, offerVersionRef, verifiedOfferRef)=>({
473
- schemaVersion: 'booking.surface.v2',
474
- kind: 'action.receipt',
475
- actionId,
476
- contextRef: 'ctx-v2',
477
- status: 'applied',
478
- revision: 1,
479
- observation: {
480
- kind: 'checkout.handoff',
481
- offerRef,
482
- offerVersionRef,
483
- verifiedOfferRef,
484
- handoffRef: `${offerRef}-checkout`
485
- },
486
- resultContract: {
487
- outcome: 'complete',
488
- hardCriteriaMet: true,
489
- factRefs: [],
490
- gapCodes: [],
491
- blockers: [],
492
- relaxationsApplied: []
493
- }
494
- });
495
- {
496
- const root = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-checkout-capability-'));
497
- const ledger = ensureLedger(root);
498
- const rt = new BookingCopilotTaskRuntimeV2(ledger, {
499
- contextRefFactory: ()=>'ctx-v2',
500
- now: ()=>'2026-09-01T10:00:00.000Z'
501
- });
502
- const capability = verifiedCapability('offer-a', 'offer-a:v1', '2026-09-01T10:30:00.000Z');
503
- const t = rt.startTask({
504
- ...turn('task-checkout-capability-exact'),
505
- workspace: checkoutWorkspace(0, capability)
506
- });
507
- const checkout = {
508
- ...action('checkout-exact'),
509
- kind: 'checkout.prepare',
510
- input: {
511
- offerRef: 'offer-a',
512
- offerVersionRef: 'offer-a:v1',
513
- verifiedOfferRef: capability.verifiedOfferRef
514
- }
515
- };
516
- rt.issueOperation(t.taskId, checkout);
517
- rt.continueWithReceipt({
518
- schemaVersion: 'booking.surface.v2',
519
- kind: 'action.receipt.continuation',
520
- taskId: t.taskId,
521
- workspace: checkoutWorkspace(1, capability),
522
- receipt: checkoutReceipt('checkout-exact', 'offer-a', 'offer-a:v1', capability.verifiedOfferRef)
523
- });
524
- assert.equal(rt.resumeTask(t.taskId)?.lastReceipt?.observation.kind, 'checkout.handoff', 'exact unexpired capability reaches checkout handoff');
525
- ledger.close();
526
- rmSync(root, {
527
- recursive: true,
528
- force: true
529
- });
530
- }{
531
- const root = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-checkout-capability-cross-offer-'));
532
- const ledger = ensureLedger(root);
533
- const rt = new BookingCopilotTaskRuntimeV2(ledger, {
534
- contextRefFactory: ()=>'ctx-v2',
535
- now: ()=>'2026-09-01T10:00:00.000Z'
536
- });
537
- const capability = verifiedCapability('offer-a', 'offer-a:v1', '2026-09-01T10:30:00.000Z');
538
- const t = rt.startTask({
539
- ...turn('task-checkout-capability-cross-offer'),
540
- workspace: checkoutWorkspace(0, capability)
541
- });
542
- assert.throws(()=>rt.issueOperation(t.taskId, {
543
- ...action('checkout-cross-offer'),
544
- kind: 'checkout.prepare',
545
- input: {
546
- offerRef: 'offer-b',
547
- offerVersionRef: 'offer-b:v1',
548
- verifiedOfferRef: capability.verifiedOfferRef
549
- }
550
- }), /verified_offer|offer_capability|offer_version/, 'offer A capability cannot authorize offer B');
551
- ledger.close();
552
- rmSync(root, {
553
- recursive: true,
554
- force: true
555
- });
556
- }{
557
- const root = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-checkout-capability-expired-'));
558
- const ledger = ensureLedger(root);
559
- const rt = new BookingCopilotTaskRuntimeV2(ledger, {
560
- contextRefFactory: ()=>'ctx-v2',
561
- now: ()=>'2026-09-01T10:00:00.000Z'
562
- });
563
- const expired = verifiedCapability('offer-a', 'offer-a:v1', '2026-09-01T09:59:59.000Z');
564
- const t = rt.startTask({
565
- ...turn('task-checkout-capability-expired'),
566
- workspace: checkoutWorkspace(0, expired)
567
- });
568
- assert.throws(()=>rt.issueOperation(t.taskId, {
569
- ...action('checkout-expired'),
570
- kind: 'checkout.prepare',
571
- input: {
572
- offerRef: 'offer-a',
573
- offerVersionRef: 'offer-a:v1',
574
- verifiedOfferRef: expired.verifiedOfferRef
575
- }
576
- }), /verified_offer_expired|offer_capability_expired|capability_expired|offer_version_not_loaded/, 'expired verified capability is rejected');
577
- ledger.close();
578
- rmSync(root, {
579
- recursive: true,
580
- force: true
581
- });
582
- }{
583
- const root = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-expired-receipt-write-'));
584
- const ledger = ensureLedger(root);
585
- const rt = new BookingCopilotTaskRuntimeV2(ledger, {
586
- contextRefFactory: ()=>'ctx-v2',
587
- now: ()=>'2026-09-01T11:00:00.000Z'
588
- });
589
- const expired = verifiedCapability('offer-a', 'offer-a:v1', '2026-09-01T10:30:00.000Z');
590
- const before = checkoutWorkspace(0, expired);
591
- const after = checkoutWorkspace(1, expired);
592
- const t = rt.startTask({
593
- ...turn('task-expired-receipt-write'),
594
- workspace: before
595
- });
596
- rt.issueOperation(t.taskId, {
597
- ...action('expired-receipt-check'),
598
- kind: 'offer.check',
599
- input: {
600
- offerRef: 'offer-a',
601
- offerVersionRef: 'offer-a:v1'
602
- }
603
- });
604
- const receipt = {
605
- schemaVersion: 'booking.surface.v2',
606
- kind: 'action.receipt',
607
- actionId: 'expired-receipt-check',
608
- contextRef: 'ctx-v2',
609
- status: 'applied',
610
- revision: 1,
611
- observation: {
612
- kind: 'offer.availability',
613
- offerRef: 'offer-a',
614
- checkedOfferVersionRef: 'offer-a:v1',
615
- currentOfferVersionRef: 'offer-a:v1',
616
- verifiedOfferRef: expired.verifiedOfferRef,
617
- available: true,
618
- changedFactRefs: [],
619
- gapCodes: []
620
- },
621
- resultContract: {
622
- outcome: 'complete',
623
- hardCriteriaMet: true,
624
- factRefs: [],
625
- gapCodes: [],
626
- blockers: [],
627
- relaxationsApplied: []
628
- }
629
- };
630
- assert.throws(()=>rt.continueWithReceipt({
631
- schemaVersion: 'booking.surface.v2',
632
- kind: 'action.receipt.continuation',
633
- taskId: t.taskId,
634
- workspace: after,
635
- receipt
636
- }), /receipt_verified_offer_expired/, 'expired capability is rejected at receipt write time');
637
- ledger.close();
638
- rmSync(root, {
639
- recursive: true,
640
- force: true
641
- });
642
- }{
643
- const root = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-expired-receipt-replay-'));
644
- const ledger = ensureLedger(root);
645
- const capability = verifiedCapability('offer-a', 'offer-a:v1', '2026-09-01T10:30:00.000Z');
646
- const before = checkoutWorkspace(0, capability);
647
- const after = checkoutWorkspace(1, capability);
648
- const writer = new BookingCopilotTaskRuntimeV2(ledger, {
649
- contextRefFactory: ()=>'ctx-v2',
650
- now: ()=>'2026-09-01T10:00:00.000Z'
651
- });
652
- const t = writer.startTask({
653
- ...turn('task-expired-receipt-replay'),
654
- workspace: before
655
- });
656
- writer.issueOperation(t.taskId, {
657
- ...action('historical-check'),
658
- kind: 'offer.check',
659
- input: {
660
- offerRef: 'offer-a',
661
- offerVersionRef: 'offer-a:v1'
662
- }
663
- });
664
- const historicalReceipt = {
665
- schemaVersion: 'booking.surface.v2',
666
- kind: 'action.receipt',
667
- actionId: 'historical-check',
668
- contextRef: 'ctx-v2',
669
- status: 'applied',
670
- revision: 1,
671
- observation: {
672
- kind: 'offer.availability',
673
- offerRef: 'offer-a',
674
- checkedOfferVersionRef: 'offer-a:v1',
675
- currentOfferVersionRef: 'offer-a:v1',
676
- verifiedOfferRef: capability.verifiedOfferRef,
677
- available: true,
678
- changedFactRefs: [],
679
- gapCodes: []
680
- },
681
- resultContract: {
682
- outcome: 'complete',
683
- hardCriteriaMet: true,
684
- factRefs: [],
685
- gapCodes: [],
686
- blockers: [],
687
- relaxationsApplied: []
688
- }
689
- };
690
- writer.continueWithReceipt({
691
- schemaVersion: 'booking.surface.v2',
692
- kind: 'action.receipt.continuation',
693
- taskId: t.taskId,
694
- workspace: after,
695
- receipt: historicalReceipt
696
- });
697
- const reader = new BookingCopilotTaskRuntimeV2(ensureLedger(root), {
698
- contextRefFactory: ()=>'ctx-v2',
699
- now: ()=>'2026-09-01T11:00:00.000Z'
700
- });
701
- const replayed = reader.resumeTask(t.taskId);
702
- assert.equal(replayed?.lastReceipt?.status, 'applied', 'historical accepted availability receipt remains replayable after capability expiry');
703
- assert.equal(replayed?.lastReceipt?.observation.kind === 'offer.availability' ? replayed.lastReceipt.observation.verifiedOfferRef : undefined, capability.verifiedOfferRef);
704
- reader['ledger'].close();
705
- ledger.close();
706
- rmSync(root, {
707
- recursive: true,
708
- force: true
709
- });
710
- }{
711
- const root = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-partial-verified-offer-'));
712
- const ledger = ensureLedger(root);
713
- const capability = verifiedCapability('offer-a', 'offer-a:v1', '2026-09-01T10:30:00.000Z');
714
- const before = checkoutWorkspace(0, capability);
715
- const after = checkoutWorkspace(1, capability);
716
- const rt = new BookingCopilotTaskRuntimeV2(ledger, {
717
- contextRefFactory: ()=>'ctx-v2',
718
- now: ()=>'2026-09-01T10:00:00.000Z'
719
- });
720
- const t = rt.startTask({
721
- ...turn('task-partial-verified-offer'),
722
- workspace: before
723
- });
724
- rt.issueOperation(t.taskId, {
725
- ...action('partial-verified-check'),
726
- kind: 'offer.check',
727
- input: {
728
- offerRef: 'offer-a',
729
- offerVersionRef: 'offer-a:v1'
730
- }
731
- });
732
- const partialReceipt = {
733
- schemaVersion: 'booking.surface.v2',
734
- kind: 'action.receipt',
735
- actionId: 'partial-verified-check',
736
- contextRef: 'ctx-v2',
737
- status: 'applied',
738
- revision: 1,
739
- observation: {
740
- kind: 'offer.availability',
741
- offerRef: 'offer-a',
742
- checkedOfferVersionRef: 'offer-a:v1',
743
- currentOfferVersionRef: 'offer-a:v1',
744
- verifiedOfferRef: capability.verifiedOfferRef,
745
- available: true,
746
- changedFactRefs: [],
747
- gapCodes: [
748
- 'check_avail_unverified'
749
- ]
750
- },
751
- resultContract: {
752
- outcome: 'partial',
753
- hardCriteriaMet: false,
754
- factRefs: [],
755
- gapCodes: [
756
- 'check_avail_unverified'
757
- ],
758
- blockers: [],
759
- relaxationsApplied: []
760
- }
761
- };
762
- assert.throws(()=>rt.continueWithReceipt({
763
- schemaVersion: 'booking.surface.v2',
764
- kind: 'action.receipt.continuation',
765
- taskId: t.taskId,
766
- workspace: after,
767
- receipt: partialReceipt
768
- }), /workspace_mismatch|receipt_verified|availability_receipt|invalid_receipt/, 'partial applied availability cannot publish a checkout-authorizing verified capability');
769
- assert.throws(()=>rt.issueOperation(t.taskId, {
770
- ...action('partial-verified-checkout'),
771
- kind: 'checkout.prepare',
772
- input: {
773
- offerRef: 'offer-a',
774
- offerVersionRef: 'offer-a:v1',
775
- verifiedOfferRef: capability.verifiedOfferRef
776
- }
777
- }), /receipt_required|verified_offer|offer_version/, 'partial availability cannot authorize a later checkout');
778
- ledger.close();
779
- rmSync(root, {
780
- recursive: true,
781
- force: true
782
- });
783
- }const operation = runtime.issueOperation(task.taskId, action('action-v2'));
784
- assert.equal(operation.action.actionId, 'action-v2');
785
- assert.equal(operation.action.kind, 'search.run');
786
- assert.equal(runtime.resumeTask(task.taskId)?.pendingAction?.actionId, 'action-v2');
787
- assert.throws(()=>runtime.issueOperation(task.taskId, action('wrong-context', 0, {
788
- contextRef: 'ctx-other'
789
- })), /receipt_required/);
790
- assert.throws(()=>runtime.continueWithReceipt({
791
- schemaVersion: 'booking.surface.v2',
792
- kind: 'action.receipt.continuation',
793
- taskId: task.taskId,
794
- workspace: workspace(1),
795
- receipt: {
796
- schemaVersion: 'booking.surface.v2',
797
- kind: 'action.receipt',
798
- actionId: 'wrong',
799
- contextRef: 'ctx-v2',
800
- status: 'applied',
801
- revision: 1,
802
- observation: {
803
- kind: 'search.state',
804
- resultCount: 1
805
- },
806
- resultContract: {
807
- outcome: 'complete',
808
- hardCriteriaMet: true,
809
- factRefs: [],
810
- gapCodes: [],
811
- blockers: [],
812
- relaxationsApplied: []
813
- }
814
- }
815
- }), /receipt_action_mismatch/);
816
- const receipt = {
817
- schemaVersion: 'booking.surface.v2',
818
- kind: 'action.receipt',
819
- actionId: 'action-v2',
820
- contextRef: 'ctx-v2',
821
- status: 'needs_input',
822
- revision: 1,
823
- observation: {
824
- kind: 'gap',
825
- code: 'criterion_must_not_met',
826
- factRefs: [
827
- 'fact-v2'
828
- ]
829
- },
830
- resultContract: {
831
- outcome: 'partial',
832
- hardCriteriaMet: false,
833
- factRefs: [
834
- 'fact-v2'
835
- ],
836
- gapCodes: [
837
- 'criterion_must_not_met'
838
- ],
839
- blockers: [
840
- {
841
- blockerId: 'blocker-v2',
842
- sourceActionId: 'action-v2',
843
- sourceReceiptDigest: '',
844
- scope: 'search',
845
- code: 'criterion_must_not_met',
846
- criterionPath: 'searchDraft.destination',
847
- strength: 'must',
848
- valueDigest: 'b'.repeat(64),
849
- valueLabel: 'Dubai',
850
- evidence: {
851
- factRefs: [
852
- 'fact-v2'
853
- ],
854
- gapCodes: [
855
- 'criterion_must_not_met'
856
- ]
857
- }
858
- }
859
- ],
860
- relaxationsApplied: []
861
- }
862
- };
863
- const receiptWithDigest = runtime.withReceiptDigest(receipt);
864
- const afterReceipt = runtime.continueWithReceipt({
865
- schemaVersion: 'booking.surface.v2',
866
- kind: 'action.receipt.continuation',
867
- taskId: task.taskId,
868
- workspace: workspace(1),
869
- receipt: receiptWithDigest
870
- });
871
- assert.equal(afterReceipt.awaitingApproval?.blocker.blockerId, 'blocker-v2');
872
- const offerRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-offer-target-'));
873
- const offerRuntime = new BookingCopilotTaskRuntimeV2(ensureLedger(offerRoot), {
874
- contextRefFactory: ()=>'ctx-v2'
875
- });
876
- const offerTurn = turn('task-offer-target');
877
- const offerTask = offerRuntime.startTask({
878
- ...offerTurn,
879
- workspace: {
880
- ...offerTurn.workspace,
881
- visibleHotels: [
882
- {
883
- hotelRef: 'hotel-a',
884
- name: 'Hotel A',
885
- factRefs: []
886
- }
887
- ],
888
- loadedOffers: [
889
- loadedOffer('offer-a', 'hotel-a')
890
- ]
891
- }
892
- });
893
- offerRuntime.issueOperation(offerTask.taskId, {
894
- ...action('offer-action'),
895
- kind: 'offer.check',
896
- input: {
897
- offerRef: 'offer-a',
898
- offerVersionRef: 'offer-a:v1'
899
- }
900
- });
901
- const offerReceipt = offerRuntime.withReceiptDigest({
902
- ...receipt,
903
- actionId: 'offer-action',
904
- observation: availabilityObservation('offer-b', 'offer-b:v1', true),
905
- resultContract: {
906
- ...receipt.resultContract,
907
- blockers: [],
908
- gapCodes: []
909
- }
910
- });
911
- const offerWorkspace1 = {
912
- ...offerTurn.workspace,
913
- revision: 1,
914
- visibleHotels: [
915
- {
916
- hotelRef: 'hotel-a',
917
- name: 'Hotel A',
918
- factRefs: []
919
- }
920
- ],
921
- loadedOffers: [
922
- loadedOffer('offer-a', 'hotel-a')
923
- ]
924
- };
925
- assert.throws(()=>offerRuntime.continueWithReceipt({
926
- schemaVersion: 'booking.surface.v2',
927
- kind: 'action.receipt.continuation',
928
- taskId: offerTask.taskId,
929
- workspace: offerWorkspace1,
930
- receipt: offerReceipt
931
- }), /receipt_target_mismatch/);
932
- const offerCheckWorkspace = (revision, selectedOfferRef, shortlistedOfferRefs = [
933
- 'offer-cleanup',
934
- 'offer-peer'
935
- ], verifiedOffer)=>({
936
- ...workspace(revision),
937
- visibleHotels: [
938
- {
939
- hotelRef: 'hotel-cleanup',
940
- name: 'Hotel Cleanup',
941
- factRefs: []
942
- }
943
- ],
944
- loadedOffers: [
945
- loadedOffer('offer-cleanup', 'hotel-cleanup'),
946
- loadedOffer('offer-peer', 'hotel-cleanup')
947
- ],
948
- selectedOfferRef,
949
- shortlistedOfferRefs,
950
- ...verifiedOffer ? {
951
- verifiedOffer
952
- } : {}
953
- });
954
- const checkReceipt = (actionId, status, available = status === 'changed')=>({
955
- schemaVersion: 'booking.surface.v2',
956
- kind: 'action.receipt',
957
- actionId,
958
- contextRef: 'ctx-v2',
959
- status,
960
- revision: 1,
961
- observation: {
962
- kind: 'offer.availability',
963
- offerRef: 'offer-cleanup',
964
- checkedOfferVersionRef: 'offer-cleanup:v1',
965
- currentOfferVersionRef: available ? status === 'changed' ? 'offer-cleanup:v2' : 'offer-cleanup:v1' : undefined,
966
- available,
967
- verifiedOfferRef: available && status !== 'changed' ? 'verified-offer-cleanup' : undefined,
968
- changedFactRefs: status === 'changed' ? [
969
- 'price'
970
- ] : [],
971
- gapCodes: []
972
- },
973
- resultContract: {
974
- outcome: status === 'changed' ? 'partial' : available ? 'complete' : status === 'unavailable' || status === 'no_match' ? 'empty' : 'partial',
975
- hardCriteriaMet: available && status !== 'changed',
976
- factRefs: [],
977
- gapCodes: [],
978
- blockers: [],
979
- relaxationsApplied: []
980
- }
981
- });
982
- const assertOfferCheckContinuation = (name, before, after, receipt, pattern)=>{
983
- const root = mkdtempSync(join(tmpdir(), `gotry-booking-v2-${name}-`));
984
- const rt = new BookingCopilotTaskRuntimeV2(ensureLedger(root), {
985
- contextRefFactory: ()=>'ctx-v2',
986
- now: ()=>'2026-09-01T10:00:00.000Z'
987
- });
988
- const t = rt.startTask({
989
- ...turn(`task-${name}`),
990
- workspace: before
991
- });
992
- rt.issueOperation(t.taskId, {
993
- ...action(`${name}-action`),
994
- kind: 'offer.check',
995
- input: {
996
- offerRef: 'offer-cleanup',
997
- offerVersionRef: 'offer-cleanup:v1'
998
- }
999
- });
1000
- if (pattern) assert.throws(()=>rt.continueWithReceipt({
1001
- schemaVersion: 'booking.surface.v2',
1002
- kind: 'action.receipt.continuation',
1003
- taskId: t.taskId,
1004
- workspace: after,
1005
- receipt: {
1006
- ...receipt,
1007
- actionId: `${name}-action`
1008
- }
1009
- }), pattern);
1010
- else rt.continueWithReceipt({
1011
- schemaVersion: 'booking.surface.v2',
1012
- kind: 'action.receipt.continuation',
1013
- taskId: t.taskId,
1014
- workspace: after,
1015
- receipt: {
1016
- ...receipt,
1017
- actionId: `${name}-action`
1018
- }
1019
- });
1020
- rt['ledger'].close();
1021
- rmSync(root, {
1022
- recursive: true,
1023
- force: true
1024
- });
1025
- };
1026
- assertOfferCheckContinuation('offer-success-first-check', offerCheckWorkspace(0, undefined), offerCheckWorkspace(1, 'offer-cleanup', [
1027
- 'offer-cleanup',
1028
- 'offer-peer'
1029
- ], verifiedCapability('offer-cleanup')), checkReceipt('offer-success-first-check-action', 'applied', true));
1030
- assertOfferCheckContinuation('offer-success-peer-to-checked', offerCheckWorkspace(0, 'offer-peer'), offerCheckWorkspace(1, 'offer-cleanup', [
1031
- 'offer-cleanup',
1032
- 'offer-peer'
1033
- ], verifiedCapability('offer-cleanup')), checkReceipt('offer-success-peer-to-checked-action', 'applied', true));
1034
- assertOfferCheckContinuation('offer-success-cleared-forbidden', offerCheckWorkspace(0, 'offer-peer'), offerCheckWorkspace(1, undefined, [
1035
- 'offer-peer'
1036
- ], verifiedCapability('offer-cleanup')), checkReceipt('offer-success-cleared-forbidden-action', 'applied', true), /workspace_mismatch/);
1037
- const cleanupRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-offer-cleanup-'));
1038
- const cleanupRuntime = new BookingCopilotTaskRuntimeV2(ensureLedger(cleanupRoot), {
1039
- contextRefFactory: ()=>'ctx-v2'
1040
- });
1041
- const cleanupWorkspace0 = {
1042
- ...workspace(0),
1043
- visibleHotels: [
1044
- {
1045
- hotelRef: 'hotel-cleanup',
1046
- name: 'Hotel Cleanup',
1047
- factRefs: []
1048
- }
1049
- ],
1050
- loadedOffers: [
1051
- loadedOffer('offer-cleanup', 'hotel-cleanup'),
1052
- loadedOffer('offer-peer', 'hotel-cleanup')
1053
- ],
1054
- selectedOfferRef: 'offer-cleanup',
1055
- shortlistedOfferRefs: [
1056
- 'offer-cleanup',
1057
- 'offer-peer'
1058
- ],
1059
- verifiedOffer: verifiedCapability('offer-cleanup', 'offer-cleanup:v1', '2026-09-01T10:30:00.000Z')
1060
- };
1061
- const cleanupTask = cleanupRuntime.startTask({
1062
- ...turn('task-offer-cleanup'),
1063
- workspace: cleanupWorkspace0
1064
- });
1065
- const cleanupAction = {
1066
- ...action('offer-cleanup-action'),
1067
- kind: 'offer.check',
1068
- input: {
1069
- offerRef: 'offer-cleanup',
1070
- offerVersionRef: 'offer-cleanup:v1'
1071
- }
1072
- };
1073
- cleanupRuntime.issueOperation(cleanupTask.taskId, cleanupAction);
1074
- const cleanupReceipt = {
1075
- schemaVersion: 'booking.surface.v2',
1076
- kind: 'action.receipt',
1077
- actionId: 'offer-cleanup-action',
1078
- contextRef: 'ctx-v2',
1079
- status: 'unavailable',
1080
- revision: 1,
1081
- observation: {
1082
- kind: 'offer.availability',
1083
- offerRef: 'offer-cleanup',
1084
- checkedOfferVersionRef: 'offer-cleanup:v1',
1085
- available: false,
1086
- changedFactRefs: [],
1087
- gapCodes: []
1088
- },
1089
- resultContract: {
1090
- outcome: 'empty',
1091
- hardCriteriaMet: false,
1092
- factRefs: [],
1093
- gapCodes: [],
1094
- blockers: [],
1095
- relaxationsApplied: []
1096
- }
1097
- };
1098
- const cleanupWorkspace1 = {
1099
- ...cleanupWorkspace0,
1100
- revision: 1,
1101
- selectedOfferRef: undefined,
1102
- shortlistedOfferRefs: [
1103
- 'offer-peer'
1104
- ],
1105
- verifiedOffer: undefined
1106
- };
1107
- cleanupRuntime.continueWithReceipt({
1108
- schemaVersion: 'booking.surface.v2',
1109
- kind: 'action.receipt.continuation',
1110
- taskId: cleanupTask.taskId,
1111
- workspace: cleanupWorkspace1,
1112
- receipt: cleanupReceipt
1113
- });
1114
- assert.equal(cleanupRuntime.resumeTask(cleanupTask.taskId)?.lastReceipt?.status, 'unavailable', 'offer.check can clear only the checked selection/shortlist and stale verification while preserving loaded offer evidence');
1115
- assertOfferCheckContinuation('offer-negative-kept-checked-forbidden', cleanupWorkspace0, {
1116
- ...cleanupWorkspace1,
1117
- selectedOfferRef: 'offer-cleanup',
1118
- shortlistedOfferRefs: [
1119
- 'offer-cleanup',
1120
- 'offer-peer'
1121
- ]
1122
- }, cleanupReceipt, /workspace_mismatch/);
1123
- const cleanupChangedWorkspace1 = {
1124
- ...cleanupWorkspace0,
1125
- revision: 1,
1126
- loadedOffers: [
1127
- {
1128
- ...loadedOffer('offer-cleanup', 'hotel-cleanup', 'offer-cleanup:v2'),
1129
- factRefs: [
1130
- 'price'
1131
- ]
1132
- },
1133
- loadedOffer('offer-peer', 'hotel-cleanup')
1134
- ],
1135
- verifiedOffer: undefined
1136
- };
1137
- const changedRuntimeRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-offer-changed-runtime-'));
1138
- const changedRuntime = new BookingCopilotTaskRuntimeV2(ensureLedger(changedRuntimeRoot), {
1139
- contextRefFactory: ()=>'ctx-v2'
1140
- });
1141
- const changedTask = changedRuntime.startTask({
1142
- ...turn('task-offer-changed-runtime'),
1143
- workspace: cleanupWorkspace0
1144
- });
1145
- changedRuntime.issueOperation(changedTask.taskId, {
1146
- ...action('offer-changed-seed-query'),
1147
- kind: 'offers.query',
1148
- input: {
1149
- hotelRefs: [
1150
- 'hotel-cleanup'
1151
- ],
1152
- criteria: {}
1153
- }
1154
- });
1155
- const changedSeedWorkspace = {
1156
- ...cleanupWorkspace0,
1157
- revision: 1
1158
- };
1159
- changedRuntime.continueWithReceipt({
1160
- schemaVersion: 'booking.surface.v2',
1161
- kind: 'action.receipt.continuation',
1162
- taskId: changedTask.taskId,
1163
- workspace: changedSeedWorkspace,
1164
- receipt: {
1165
- schemaVersion: 'booking.surface.v2',
1166
- kind: 'action.receipt',
1167
- actionId: 'offer-changed-seed-query',
1168
- contextRef: 'ctx-v2',
1169
- status: 'applied',
1170
- revision: 1,
1171
- observation: {
1172
- kind: 'offers.state',
1173
- hotelRefs: [
1174
- 'hotel-cleanup'
1175
- ],
1176
- offerRefs: [
1177
- 'offer-cleanup',
1178
- 'offer-peer'
1179
- ],
1180
- loadedHotelCount: 1
1181
- },
1182
- resultContract: {
1183
- outcome: 'complete',
1184
- hardCriteriaMet: true,
1185
- factRefs: [],
1186
- gapCodes: [],
1187
- blockers: [],
1188
- relaxationsApplied: []
1189
- }
1190
- }
1191
- });
1192
- changedRuntime.issueOperation(changedTask.taskId, {
1193
- ...action('offer-changed-runtime-check', 1),
1194
- kind: 'offer.check',
1195
- input: {
1196
- offerRef: 'offer-cleanup',
1197
- offerVersionRef: 'offer-cleanup:v1'
1198
- }
1199
- });
1200
- changedRuntime.continueWithReceipt({
1201
- schemaVersion: 'booking.surface.v2',
1202
- kind: 'action.receipt.continuation',
1203
- taskId: changedTask.taskId,
1204
- workspace: {
1205
- ...cleanupChangedWorkspace1,
1206
- revision: 2
1207
- },
1208
- receipt: {
1209
- ...checkReceipt('offer-changed-runtime-check', 'changed'),
1210
- revision: 2
1211
- }
1212
- });
1213
- assert.equal(changedRuntime.resumeTask(changedTask.taskId)?.lastReceipt?.status, 'changed', 'runtime persists changed availability against a replacement offer version');
1214
- changedRuntime['ledger'].close();
1215
- rmSync(changedRuntimeRoot, {
1216
- recursive: true,
1217
- force: true
1218
- });
1219
- const directChangedRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-direct-offer-changed-'));
1220
- const directChangedRuntime = new BookingCopilotTaskRuntimeV2(ensureLedger(directChangedRoot), {
1221
- contextRefFactory: ()=>'ctx-v2'
1222
- });
1223
- const directChangedTask = directChangedRuntime.startTask({
1224
- ...turn('task-direct-offer-changed'),
1225
- workspace: cleanupWorkspace0
1226
- });
1227
- directChangedRuntime.issueOperation(directChangedTask.taskId, {
1228
- ...action('direct-offer-check-v1'),
1229
- kind: 'offer.check',
1230
- input: {
1231
- offerRef: 'offer-cleanup',
1232
- offerVersionRef: 'offer-cleanup:v1'
1233
- }
1234
- });
1235
- const directChangedWorkspace = {
1236
- ...cleanupChangedWorkspace1,
1237
- revision: 1
1238
- };
1239
- directChangedRuntime.continueWithReceipt({
1240
- schemaVersion: 'booking.surface.v2',
1241
- kind: 'action.receipt.continuation',
1242
- taskId: directChangedTask.taskId,
1243
- workspace: directChangedWorkspace,
1244
- receipt: checkReceipt('direct-offer-check-v1', 'changed')
1245
- });
1246
- assert.equal(directChangedRuntime.resumeTask(directChangedTask.taskId)?.lastReceipt?.status, 'changed', 'direct first CheckAvail changed receipt is durable');
1247
- const directChangedRestart = new BookingCopilotTaskRuntimeV2(ensureLedger(directChangedRoot), {
1248
- contextRefFactory: ()=>'ctx-v2'
1249
- });
1250
- const resumedDirect = directChangedRestart.resumeTask(directChangedTask.taskId);
1251
- assert.equal(resumedDirect?.lastReceipt?.observation.kind, 'offer.availability');
1252
- assert.equal(resumedDirect?.lastReceipt?.observation.kind === 'offer.availability' ? resumedDirect.lastReceipt.observation.currentOfferVersionRef : undefined, 'offer-cleanup:v2', 'restart retains replacement offer version');
1253
- const recheckV2 = {
1254
- ...action('direct-offer-check-v2', 1),
1255
- kind: 'offer.check',
1256
- input: {
1257
- offerRef: 'offer-cleanup',
1258
- offerVersionRef: 'offer-cleanup:v2'
1259
- }
1260
- };
1261
- directChangedRestart.issueOperation(directChangedTask.taskId, recheckV2);
1262
- directChangedRuntime['ledger'].close();
1263
- directChangedRestart['ledger'].close();
1264
- rmSync(directChangedRoot, {
1265
- recursive: true,
1266
- force: true
1267
- });
1268
- assertOfferCheckContinuation('offer-no-match-cleanup', cleanupWorkspace0, cleanupWorkspace1, {
1269
- ...cleanupReceipt,
1270
- status: 'no_match'
1271
- });
1272
- assertOfferCheckContinuation('offer-failed-drift-forbidden', cleanupWorkspace0, cleanupWorkspace1, {
1273
- ...cleanupReceipt,
1274
- status: 'failed',
1275
- resultContract: {
1276
- ...cleanupReceipt.resultContract,
1277
- outcome: 'partial'
1278
- }
1279
- }, /workspace_mismatch/);
1280
- assertOfferCheckContinuation('offer-stale-drift-forbidden', cleanupWorkspace0, cleanupWorkspace1, {
1281
- ...cleanupReceipt,
1282
- status: 'stale',
1283
- resultContract: {
1284
- ...cleanupReceipt.resultContract,
1285
- outcome: 'partial'
1286
- }
1287
- }, /workspace_mismatch/);
1288
- assertOfferCheckContinuation('offer-unsupported-drift-forbidden', cleanupWorkspace0, cleanupWorkspace1, {
1289
- ...cleanupReceipt,
1290
- status: 'unsupported',
1291
- resultContract: {
1292
- ...cleanupReceipt.resultContract,
1293
- outcome: 'partial'
1294
- }
1295
- }, /workspace_mismatch/);
1296
- const replacedRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-offer-cleanup-replaced-'));
1297
- const replacedRuntime = new BookingCopilotTaskRuntimeV2(ensureLedger(replacedRoot), {
1298
- contextRefFactory: ()=>'ctx-v2'
1299
- });
1300
- const replacedTask = replacedRuntime.startTask({
1301
- ...turn('task-offer-cleanup-replaced'),
1302
- workspace: cleanupWorkspace0
1303
- });
1304
- replacedRuntime.issueOperation(replacedTask.taskId, {
1305
- ...cleanupAction,
1306
- actionId: 'offer-cleanup-replaced'
1307
- });
1308
- assert.throws(()=>replacedRuntime.continueWithReceipt({
1309
- schemaVersion: 'booking.surface.v2',
1310
- kind: 'action.receipt.continuation',
1311
- taskId: replacedTask.taskId,
1312
- workspace: {
1313
- ...cleanupWorkspace1,
1314
- selectedOfferRef: 'offer-peer'
1315
- },
1316
- receipt: {
1317
- ...cleanupReceipt,
1318
- actionId: 'offer-cleanup-replaced'
1319
- }
1320
- }), /workspace_mismatch/, 'offer.check cannot replace selection with a different offer');
1321
- const loadedDriftRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-offer-cleanup-loaded-drift-'));
1322
- const loadedDriftRuntime = new BookingCopilotTaskRuntimeV2(ensureLedger(loadedDriftRoot), {
1323
- contextRefFactory: ()=>'ctx-v2'
1324
- });
1325
- const loadedDriftTask = loadedDriftRuntime.startTask({
1326
- ...turn('task-offer-cleanup-loaded-drift'),
1327
- workspace: cleanupWorkspace0
1328
- });
1329
- loadedDriftRuntime.issueOperation(loadedDriftTask.taskId, {
1330
- ...cleanupAction,
1331
- actionId: 'offer-cleanup-loaded-drift'
1332
- });
1333
- assert.throws(()=>loadedDriftRuntime.continueWithReceipt({
1334
- schemaVersion: 'booking.surface.v2',
1335
- kind: 'action.receipt.continuation',
1336
- taskId: loadedDriftTask.taskId,
1337
- workspace: {
1338
- ...cleanupWorkspace1,
1339
- loadedOffers: [
1340
- loadedOffer('offer-peer', 'hotel-cleanup')
1341
- ]
1342
- },
1343
- receipt: {
1344
- ...cleanupReceipt,
1345
- actionId: 'offer-cleanup-loaded-drift'
1346
- }
1347
- }), /workspace_mismatch/, 'offer.check cannot mutate loadedOffers evidence');
1348
- cleanupRuntime['ledger'].close();
1349
- replacedRuntime['ledger'].close();
1350
- loadedDriftRuntime['ledger'].close();
1351
- rmSync(cleanupRoot, {
1352
- recursive: true,
1353
- force: true
1354
- });
1355
- rmSync(replacedRoot, {
1356
- recursive: true,
1357
- force: true
1358
- });
1359
- rmSync(loadedDriftRoot, {
1360
- recursive: true,
1361
- force: true
1362
- });
1363
- const approvalQuestion = runtime.approvalQuestion(task.taskId);
1364
- assert.equal(approvalQuestion.kind, 'question');
1365
- if (approvalQuestion.kind === 'question') {
1366
- assert.equal(approvalQuestion.question.blocker.blockerId, 'blocker-v2');
1367
- assert.equal(approvalQuestion.question.approvalOptions[0]?.approval.sourceActionId, 'action-v2');
1368
- }
1369
- const awaitingRestart = new BookingCopilotTaskRuntimeV2(ensureLedger(stateRoot), {
1370
- now: ()=>'2026-09-01T10:00:00.000Z'
1371
- });
1372
- assert.equal(awaitingRestart.resumeTask(task.taskId)?.awaitingApproval?.blocker.blockerId, 'blocker-v2');
1373
- const approval = approvalQuestion.kind === 'question' ? approvalQuestion.question.approvalOptions[0].approval : (()=>{
1374
- throw new Error('missing approval option');
1375
- })();
1376
- assert.throws(()=>runtime.startTask({
1377
- ...turn(task.taskId, 1),
1378
- request: {
1379
- text: 'relax destination',
1380
- approval
1381
- }
1382
- }), /approval_not_presented/);
1383
- const awaitingBeforeBatch = runtime.resumeTask(task.taskId)?.awaitingApproval;
1384
- assert.ok(awaitingBeforeBatch);
1385
- const approvalPresentationKey = runtime.approvalPresentationRequestKey(task.taskId);
1386
- runtime.ensureApprovalOffered(task.taskId, task.contextRef, awaitingBeforeBatch, approvalPresentationKey);
1387
- assert.equal(runtime.resumeTask(task.taskId)?.awaitingApproval?.optionsEmitted, false);
1388
- assert.throws(()=>runtime.startTask({
1389
- ...turn(task.taskId, 1),
1390
- request: {
1391
- text: 'relax destination',
1392
- approval
1393
- }
1394
- }), /approval_not_presented/);
1395
- assert.throws(()=>runtime.commitDecisionBatch(task.taskId, 'turn:unrelated-turn', [
1396
- approvalQuestion
1397
- ]), /approval_presentation_key_mismatch/);
1398
- assert.throws(()=>runtime.commitDecisionBatch(task.taskId, `approval:other-task:turn-1:action-v2:${'c'.repeat(64)}`, [
1399
- approvalQuestion
1400
- ]), /approval_presentation_key_mismatch/);
1401
- const alteredSourceTurnQuestion = approvalQuestion.kind === 'question' ? {
1402
- ...approvalQuestion,
1403
- question: {
1404
- ...approvalQuestion.question,
1405
- approvalOptions: approvalQuestion.question.approvalOptions.map(({ approval: option })=>({
1406
- approval: {
1407
- ...option,
1408
- sourceTurnId: 'turn-forged'
1409
- }
1410
- }))
1411
- }
1412
- } : approvalQuestion;
1413
- assert.throws(()=>runtime.commitDecisionBatch(task.taskId, approvalPresentationKey, [
1414
- alteredSourceTurnQuestion
1415
- ]), /approval_presentation_key_mismatch/);
1416
- const alteredReceiptQuestion = approvalQuestion.kind === 'question' ? {
1417
- ...approvalQuestion,
1418
- question: {
1419
- ...approvalQuestion.question,
1420
- approvalOptions: approvalQuestion.question.approvalOptions.map(({ approval: option })=>({
1421
- approval: {
1422
- ...option,
1423
- sourceReceiptDigest: 'd'.repeat(64)
1424
- }
1425
- }))
1426
- }
1427
- } : approvalQuestion;
1428
- assert.throws(()=>runtime.commitDecisionBatch(task.taskId, approvalPresentationKey, [
1429
- alteredReceiptQuestion
1430
- ]), /approval_presentation_key_mismatch/);
1431
- const alteredOptionQuestion = approvalQuestion.kind === 'question' ? {
1432
- ...approvalQuestion,
1433
- question: {
1434
- ...approvalQuestion.question,
1435
- approvalOptions: approvalQuestion.question.approvalOptions.map(({ approval: option })=>({
1436
- approval: {
1437
- ...option,
1438
- optionDigest: 'e'.repeat(64)
1439
- }
1440
- }))
1441
- }
1442
- } : approvalQuestion;
1443
- assert.throws(()=>runtime.commitDecisionBatch(task.taskId, approvalPresentationKey, [
1444
- alteredOptionQuestion
1445
- ]), /approval_presentation_key_mismatch/);
1446
- runtime.commitDecisionBatch(task.taskId, approvalPresentationKey, [
1447
- approvalQuestion
1448
- ]);
1449
- assert.equal(runtime.resumeTask(task.taskId)?.awaitingApproval?.optionsEmitted, true, 'question batch is the presentation authority');
1450
- assert.throws(()=>runtime.startTask({
1451
- ...turn(task.taskId, 1),
1452
- request: {
1453
- text: 'relax destination',
1454
- approval: {
1455
- ...approval,
1456
- deliveryNonce: 'forged-delivery-nonce'
1457
- }
1458
- }
1459
- }), /approval_mismatch/);
1460
- runtime.startTask({
1461
- ...turn(task.taskId, 1),
1462
- request: {
1463
- text: 'relax destination',
1464
- approval
1465
- }
1466
- });
1467
- const approvalEventCount = runtime['ledger'].countEvents();
1468
- runtime.startTask({
1469
- ...turn(task.taskId, 1),
1470
- request: {
1471
- text: 'relax destination',
1472
- approval
1473
- }
1474
- });
1475
- assert.equal(runtime['ledger'].countEvents(), approvalEventCount, 'identical durable approval retry is idempotent');
1476
- assert.throws(()=>runtime.startTask({
1477
- ...turn(task.taskId, 1),
1478
- turnId: 'tampered-approval-turn',
1479
- request: {
1480
- text: 'relax destination',
1481
- approval: {
1482
- ...approval,
1483
- to: 'drop'
1484
- }
1485
- }
1486
- }), /approval_mismatch/);
1487
- const expiredRuntime = new BookingCopilotTaskRuntimeV2(ensureLedger(stateRoot), {
1488
- now: ()=>'2026-09-01T10:03:00.000Z'
1489
- });
1490
- assert.throws(()=>expiredRuntime.issueOperation(task.taskId, action('action-v2-expired', 1)), /approval_expired/);
1491
- const approvedOperation = runtime.issueOperation(task.taskId, action('action-v2-relaxed', 1));
1492
- assert.equal(approvedOperation.action.relaxationApprovalRef?.targetActionId, 'action-v2-relaxed');
1493
- assert.equal(approvedOperation.action.relaxationApprovalRef?.targetActionKind, 'search.run');
1494
- assert.throws(()=>runtime.issueOperation(task.taskId, action('action-v2-relaxed-again', 1)), /receipt_required/);
1495
- const reopened = new BookingCopilotTaskRuntimeV2(ensureLedger(stateRoot), {
1496
- now: ()=>'2026-09-01T10:00:00.000Z'
1497
- });
1498
- assert.equal(reopened.resumeTask(task.taskId)?.lastReceipt?.actionId, 'action-v2');
1499
- assert.equal(reopened.resumeTask(task.taskId)?.pendingAction?.actionId, 'action-v2-relaxed');
1500
- assert.throws(()=>reopened.issueOperation(task.taskId, {
1501
- ...action('forged-ref', 1),
1502
- relaxationApprovalRef: {
1503
- ...approvedOperation.action.relaxationApprovalRef,
1504
- targetActionId: 'forged-ref'
1505
- }
1506
- }), /receipt_required/);
1507
- const forgedTask = runtime.startTask(turn('task-forged', 1));
1508
- assert.throws(()=>runtime.issueOperation(forgedTask.taskId, action('forged-ref', 1, {
1509
- relaxationApprovalRef: {
1510
- ...approvedOperation.action.relaxationApprovalRef,
1511
- targetActionId: 'forged-ref'
1512
- }
1513
- })), /approval_ref_planner_owned_forbidden/);
1514
- const forgedSourceRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-forged-source-'));
1515
- const forgedSourceRuntime = new BookingCopilotTaskRuntimeV2(ensureLedger(forgedSourceRoot), {
1516
- contextRefFactory: ()=>'ctx-v2'
1517
- });
1518
- const forgedSourceTask = forgedSourceRuntime.startTask(turn('task-forged-source'));
1519
- forgedSourceRuntime.issueOperation(forgedSourceTask.taskId, action('action-real'));
1520
- const forgedSourceReceipt = forgedSourceRuntime.withReceiptDigest({
1521
- ...receipt,
1522
- actionId: 'action-real',
1523
- resultContract: {
1524
- ...receipt.resultContract,
1525
- blockers: [
1526
- {
1527
- ...receipt.resultContract.blockers[0],
1528
- sourceActionId: 'action-forged'
1529
- }
1530
- ]
1531
- }
1532
- });
1533
- assert.throws(()=>forgedSourceRuntime.continueWithReceipt({
1534
- schemaVersion: 'booking.surface.v2',
1535
- kind: 'action.receipt.continuation',
1536
- taskId: forgedSourceTask.taskId,
1537
- workspace: workspace(1),
1538
- receipt: forgedSourceReceipt
1539
- }), /receipt_source_action_mismatch/);
1540
- assert.equal(forgedSourceRuntime.resumeTask(forgedSourceTask.taskId)?.pendingAction?.actionId, 'action-real', 'forged blocker is rejected before receipt append');
1541
- const tamperRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-tamper-'));
1542
- const tamperLedger = ensureLedger(tamperRoot);
1543
- const tamperRuntime = new BookingCopilotTaskRuntimeV2(tamperLedger);
1544
- tamperRuntime.startTask(turn('task-tamper'));
1545
- tamperLedger.insertEvent({
1546
- actor: 'tamper',
1547
- kind: 'booking.copilot.v2.user.turn.observed',
1548
- subjectId: 'task-tamper',
1549
- runId: 'task-tamper',
1550
- idemKey: 'tamper-turn',
1551
- payload: {
1552
- schema: 'booking.copilot.ledger.v2',
1553
- taskId: 'task-tamper',
1554
- contextRef: 'ctx-v2',
1555
- requestDigest: 'tampered',
1556
- approval: {
1557
- forged: true
1558
- }
1559
- }
1560
- });
1561
- assert.throws(()=>tamperRuntime.resumeTask('task-tamper'), /ledger_corrupt:task-tamper:turn_approval/);
1562
- const serverRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-server-'));
1563
- let serverId = 0;
1564
- const serverRuntime = new BookingCopilotTaskRuntimeV2(ensureLedger(serverRoot), {
1565
- idFactory: (prefix)=>`${prefix}-server-${++serverId}`,
1566
- contextRefFactory: ()=>'ctx-server'
1567
- });
1568
- let factoryCalls = 0;
1569
- let plannerCalls = 0;
1570
- const server = await startBookingCopilotServer({
1571
- apiKey: 'v2-server-key',
1572
- runtime: new BookingCopilotTaskRuntime(ensureLedger(serverRoot)),
1573
- plannerFactory: (initial)=>({
1574
- next: async ()=>[]
1575
- }),
1576
- v2: {
1577
- runtime: serverRuntime,
1578
- principal: {
1579
- subject: 'bff-principal-a',
1580
- scope: 'booking:read'
1581
- },
1582
- ingressBinding: {
1583
- bind: ()=>({
1584
- taskId: 'task-server',
1585
- turnId: 'ingress-turn-1',
1586
- contextRef: 'ctx-server',
1587
- surface: 'tenant',
1588
- allowedActions: [
1589
- ...BOOKING_READ_ACTION_KINDS_V2
1590
- ]
1591
- })
1592
- },
1593
- plannerFactory: (initial)=>{
1594
- factoryCalls++;
1595
- return {
1596
- next: async ({ task: current })=>{
1597
- plannerCalls++;
1598
- const decision = {
1599
- kind: 'operation',
1600
- action: {
1601
- ...action(`server-action-${plannerCalls}`, current.revision),
1602
- contextRef: current.contextRef
1603
- }
1604
- };
1605
- return [
1606
- decision
1607
- ];
1608
- }
1609
- };
1610
- }
1611
- }
1612
- });
1613
- const endpoint = `http://127.0.0.1:${server.port}/a2a/booking-copilot/turn`;
1614
- const headers = {
1615
- authorization: 'Bearer v2-server-key',
1616
- 'content-type': 'application/json',
1617
- 'x-booking-surface-version': BOOKING_SURFACE_SCHEMA_VERSION_V2,
1618
- 'x-booking-surface-schema-sha256': BOOKING_SURFACE_SCHEMA_V2_SHA256
1619
- };
1620
- const ingress = {
1621
- schemaVersion: 'booking.surface.v2',
1622
- kind: 'user.turn.ingress',
1623
- requestKey: 'ingress-request-1',
1624
- taskHandle: 'opaque-task-handle-1',
1625
- surfaceHint: 'tenant',
1626
- workspace: {
1627
- schemaVersion: 'booking.surface.v2',
1628
- revision: 0,
1629
- locale: 'en-US',
1630
- currency: 'AED',
1631
- searchDraft: {},
1632
- results: {
1633
- status: 'idle'
1634
- },
1635
- visibleHotels: [],
1636
- loadedOffers: [],
1637
- shortlistedOfferRefs: []
1638
- },
1639
- request: {
1640
- text: 'find hotels in Dubai'
1641
- }
1642
- };
1643
- const missingTurnLedgerEvents = serverRuntime['ledger'].countEvents();
1644
- const missingIngress = await fetch(endpoint, {
1645
- method: 'POST',
1646
- headers,
1647
- body: JSON.stringify({
1648
- ...ingress,
1649
- requestKey: undefined
1650
- })
1651
- });
1652
- assert.equal(missingIngress.status, 400, 'missing ingress requestKey is rejected at HTTP schema boundary');
1653
- const missingUser = await fetch(endpoint, {
1654
- method: 'POST',
1655
- headers,
1656
- body: JSON.stringify({
1657
- ...turn('missing-user'),
1658
- turnId: undefined
1659
- })
1660
- });
1661
- assert.equal(missingUser.status, 400, 'missing user turnId is rejected at HTTP schema boundary');
1662
- const injectedIngressIdentity = await fetch(endpoint, {
1663
- method: 'POST',
1664
- headers,
1665
- body: JSON.stringify({
1666
- ...ingress,
1667
- taskId: 'browser-task',
1668
- turnId: 'browser-turn',
1669
- contextRef: null
1670
- })
1671
- });
1672
- assert.equal(injectedIngressIdentity.status, 400, 'browser-supplied identity is rejected at HTTP schema boundary');
1673
- const missingUserTask = await fetch(endpoint, {
1674
- method: 'POST',
1675
- headers,
1676
- body: JSON.stringify({
1677
- ...turn('missing-user-task'),
1678
- taskId: undefined
1679
- })
1680
- });
1681
- assert.equal(missingUserTask.status, 400, 'missing user taskId is rejected at HTTP schema boundary');
1682
- assert.equal(serverRuntime['ledger'].countEvents(), missingTurnLedgerEvents, 'missing taskId/turnId requests have no ledger side effects');
1683
- assert.equal(plannerCalls, 0, 'missing taskId/turnId requests do not call planner');
1684
- const first = await fetch(endpoint, {
1685
- method: 'POST',
1686
- headers,
1687
- body: JSON.stringify(ingress)
1688
- });
1689
- assert.equal(first.status, 200);
1690
- const firstBody = await first.text();
1691
- assert.match(firstBody, /event: operation/);
1692
- assert.equal(factoryCalls, 1);
1693
- assert.equal(plannerCalls, 1);
1694
- assert.match(firstBody, /ctx-server/);
1695
- const ingressReplayWhileWaiting = await fetch(endpoint, {
1696
- method: 'POST',
1697
- headers,
1698
- body: JSON.stringify(ingress)
1699
- });
1700
- assert.equal(ingressReplayWhileWaiting.status, 200, 'stable ingress turn identity replays while waiting for receipt');
1701
- assert.equal(await ingressReplayWhileWaiting.text(), firstBody);
1702
- assert.equal(plannerCalls, 1);
1703
- const pending = serverRuntime.resumeTask('task-server')?.pendingAction;
1704
- assert.ok(pending);
1705
- const continuationWorkspace = {
1706
- ...workspace(1),
1707
- contextRef: 'ctx-server'
1708
- };
1709
- const postActionWorkspace = {
1710
- ...continuationWorkspace,
1711
- visibleHotels: [
1712
- {
1713
- hotelRef: 'hotel-a',
1714
- name: 'Hotel A',
1715
- factRefs: [
1716
- 'fact-hotel-a'
1717
- ]
1718
- }
1719
- ]
1720
- };
1721
- const receiptTurn = {
1722
- schemaVersion: 'booking.surface.v2',
1723
- kind: 'action.receipt.continuation',
1724
- taskId: 'task-server',
1725
- workspace: postActionWorkspace,
1726
- receipt: {
1727
- schemaVersion: 'booking.surface.v2',
1728
- kind: 'action.receipt',
1729
- actionId: pending.actionId,
1730
- contextRef: 'ctx-server',
1731
- status: 'applied',
1732
- revision: 1,
1733
- observation: {
1734
- kind: 'search.state',
1735
- resultCount: 1
1736
- },
1737
- resultContract: {
1738
- outcome: 'complete',
1739
- hardCriteriaMet: true,
1740
- factRefs: [],
1741
- gapCodes: [],
1742
- blockers: [],
1743
- relaxationsApplied: []
1744
- }
1745
- }
1746
- };
1747
- const [second, concurrentSecond] = await Promise.all([
1748
- fetch(endpoint, {
1749
- method: 'POST',
1750
- headers,
1751
- body: JSON.stringify(receiptTurn)
1752
- }),
1753
- fetch(endpoint, {
1754
- method: 'POST',
1755
- headers,
1756
- body: JSON.stringify(receiptTurn)
1757
- })
1758
- ]);
1759
- assert.equal(second.status, 200);
1760
- assert.equal(concurrentSecond.status, 200);
1761
- const secondBody = await second.text();
1762
- const concurrentSecondBody = await concurrentSecond.text();
1763
- assert.equal(concurrentSecondBody, secondBody, 'concurrent identical receipt requests receive the same durable typed batch');
1764
- assert.equal(plannerCalls, 2);
1765
- const replay = await fetch(endpoint, {
1766
- method: 'POST',
1767
- headers,
1768
- body: JSON.stringify(receiptTurn)
1769
- });
1770
- assert.equal(replay.status, 200);
1771
- const replayBody = await replay.text();
1772
- assert.equal(plannerCalls, 2, 'identical durable receipt replay does not call planner again');
1773
- assert.equal(replayBody, secondBody, 'receipt replay emits the exact durable SSE batch');
1774
- const alteredReceiptWorkspace = await fetch(endpoint, {
1775
- method: 'POST',
1776
- headers,
1777
- body: JSON.stringify({
1778
- ...receiptTurn,
1779
- workspace: {
1780
- ...continuationWorkspace,
1781
- currency: 'USD'
1782
- }
1783
- })
1784
- });
1785
- assert.equal(alteredReceiptWorkspace.status, 409, 'receipt replay binds the full workspace semantics');
1786
- const alteredReceiptDraft = await fetch(endpoint, {
1787
- method: 'POST',
1788
- headers,
1789
- body: JSON.stringify({
1790
- ...receiptTurn,
1791
- workspace: {
1792
- ...postActionWorkspace,
1793
- searchDraft: {
1794
- destination: {
1795
- query: 'Abu Dhabi'
1796
- }
1797
- }
1798
- }
1799
- })
1800
- });
1801
- assert.equal(alteredReceiptDraft.status, 409, 'receipt rejects unauthorized non-action workspace mutation');
1802
- const pendingWithBlocker = serverRuntime.resumeTask('task-server')?.pendingAction;
1803
- assert.ok(pendingWithBlocker);
1804
- const blockerReceipt = serverRuntime.withReceiptDigest({
1805
- ...receipt,
1806
- actionId: pendingWithBlocker.actionId,
1807
- contextRef: 'ctx-server',
1808
- revision: 2,
1809
- resultContract: {
1810
- ...receipt.resultContract,
1811
- blockers: [
1812
- {
1813
- ...receipt.resultContract.blockers[0],
1814
- sourceActionId: pendingWithBlocker.actionId
1815
- }
1816
- ]
1817
- }
1818
- });
1819
- const blockerTurn = {
1820
- ...receiptTurn,
1821
- workspace: {
1822
- ...continuationWorkspace,
1823
- revision: 2
1824
- },
1825
- receipt: blockerReceipt
1826
- };
1827
- const blockerResponse = await fetch(endpoint, {
1828
- method: 'POST',
1829
- headers,
1830
- body: JSON.stringify(blockerTurn)
1831
- });
1832
- assert.equal(blockerResponse.status, 200);
1833
- const blockerBody = await blockerResponse.text();
1834
- assert.match(blockerBody, /relaxation_approval_required/);
1835
- assert.equal(plannerCalls, 2, 'runtime-owned approval question does not call DSH planner');
1836
- const questionApproval = serverRuntime.approvalQuestion('task-server');
1837
- assert.equal(questionApproval.kind, 'question');
1838
- const deliveredQuestionMatch = /event: question\ndata: ([^\n]+)/.exec(blockerBody);
1839
- assert.ok(deliveredQuestionMatch, 'question SSE must be delivered before approval');
1840
- const deliveredQuestion = JSON.parse(deliveredQuestionMatch[1]);
1841
- const approved = questionApproval.kind === 'question' ? questionApproval.question.approvalOptions[0].approval : undefined;
1842
- assert.ok(approved);
1843
- assert.equal(deliveredQuestion.question.approvalOptions[0].approval.deliveryNonce, approved.deliveryNonce, 'SSE carries the durable unpredictable presentation nonce');
1844
- const approvedTurn = {
1845
- schemaVersion: 'booking.surface.v2',
1846
- kind: 'user.turn',
1847
- taskId: 'task-server',
1848
- turnId: 'approval-turn-1',
1849
- workspace: {
1850
- ...continuationWorkspace,
1851
- revision: 2
1852
- },
1853
- request: {
1854
- text: 'prefer a nearby match',
1855
- approval: approved
1856
- }
1857
- };
1858
- const approvedResponse = await fetch(endpoint, {
1859
- method: 'POST',
1860
- headers,
1861
- body: JSON.stringify(approvedTurn)
1862
- });
1863
- assert.equal(approvedResponse.status, 200);
1864
- const approvedBody = await approvedResponse.text();
1865
- assert.equal(plannerCalls, 3);
1866
- const crashRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-crash-replay-'));
1867
- const crashLedger = ensureLedger(crashRoot);
1868
- const crashV2 = new BookingCopilotTaskRuntimeV2(crashLedger);
1869
- const crashTurn = {
1870
- ...turn('task-crash'),
1871
- turnId: 'stable-turn-1'
1872
- };
1873
- crashV2.startTask(crashTurn);
1874
- const crashCount = crashLedger.countEvents();
1875
- crashV2.startTask(crashTurn);
1876
- assert.equal(crashLedger.countEvents(), crashCount, 'stable turn identity retries after startTask without ledger delta');
1877
- const duplicateTextTurn = {
1878
- ...turn('task-crash'),
1879
- turnId: 'stable-turn-2'
1880
- };
1881
- crashV2.startTask(duplicateTextTurn);
1882
- assert.equal(crashV2.resumeTask('task-crash')?.userTurnCount, 2, 'same text with a new turn identity is a new turn');
1883
- const atomicRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-binding-atomic-'));
1884
- const atomicLedger = ensureLedger(atomicRoot);
1885
- const atomicRuntime = new BookingCopilotTaskRuntimeV2(atomicLedger);
1886
- const atomicTurn = turn('task-atomic-binding');
1887
- const atomicBinding = {
1888
- requestKey: 'atomic-request-1',
1889
- principal: {
1890
- subject: 'bff-atomic',
1891
- scope: 'booking:read'
1892
- },
1893
- taskHandle: 'atomic-task-handle'
1894
- };
1895
- atomicLedger.db.exec("CREATE TRIGGER abort_booking_request_binding BEFORE INSERT ON events WHEN NEW.kind = 'booking.copilot.v2.request.binding' BEGIN SELECT RAISE(ABORT, 'crash_after_start_before_binding'); END");
1896
- assert.throws(()=>atomicRuntime.startTask(atomicTurn, atomicBinding), /crash_after_start_before_binding|SQLITE_CONSTRAINT/);
1897
- assert.equal(atomicLedger.countEvents(), 0, 'binding failure rolls back STARTED and TURN reservation');
1898
- atomicLedger.db.exec('DROP TRIGGER abort_booking_request_binding');
1899
- atomicRuntime.startTask(atomicTurn, atomicBinding);
1900
- const atomicCount = atomicLedger.countEvents();
1901
- atomicRuntime.startTask(atomicTurn, atomicBinding);
1902
- assert.equal(atomicLedger.countEvents(), atomicCount, 'same request binding retry does not create a second task/planner turn');
1903
- assert.equal(atomicLedger.db.prepare("SELECT COUNT(*) AS n FROM events WHERE kind = 'booking.copilot.v2.request.binding'").get().n, 1);
1904
- assert.throws(()=>atomicRuntime.startTask(atomicTurn, {
1905
- ...atomicBinding,
1906
- principal: {
1907
- subject: 'other-principal',
1908
- scope: 'booking:read'
1909
- }
1910
- }), /principal_conflict|request_conflict/, 'taskHandle cannot be rebound across principals');
1911
- assert.throws(()=>atomicRuntime.startTask(turn('task-atomic-binding-other'), {
1912
- ...atomicBinding,
1913
- requestKey: 'atomic-request-2'
1914
- }), /task_handle_conflict/, 'taskHandle cannot bind a second task in the same principal scope');
1915
- atomicLedger.close();
1916
- rmSync(atomicRoot, {
1917
- recursive: true,
1918
- force: true
1919
- });
1920
- const v1AfterV2 = new BookingCopilotTaskRuntime(crashLedger);
1921
- const v1Turn = {
1922
- schemaVersion: 'booking.surface.v1',
1923
- kind: 'user.turn',
1924
- taskId: 'task-cross-version',
1925
- workspace: {
1926
- schemaVersion: 'booking.surface.v1',
1927
- contextRef: 'ctx-v1',
1928
- surface: 'tenant',
1929
- revision: 0,
1930
- locale: 'en-US',
1931
- currency: 'AED',
1932
- searchDraft: {},
1933
- results: {
1934
- status: 'idle'
1935
- },
1936
- visibleHotels: [],
1937
- loadedOffers: [],
1938
- shortlistedOfferRefs: [],
1939
- capabilities: {
1940
- surface: 'tenant',
1941
- allowedActions: [
1942
- 'search.run'
1943
- ]
1944
- }
1945
- },
1946
- request: {
1947
- text: 'v1 cross-version test'
1948
- }
1949
- };
1950
- const crossV2 = new BookingCopilotTaskRuntimeV2(crashLedger);
1951
- crossV2.startTask({
1952
- ...turn('task-cross-version'),
1953
- workspace: {
1954
- ...workspace(),
1955
- contextRef: 'ctx-v2'
1956
- }
1957
- });
1958
- assert.throws(()=>v1AfterV2.startTask(v1Turn), /task_conflict:protocol_version/);
1959
- const reverseRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-reverse-version-'));
1960
- const reverseLedger = ensureLedger(reverseRoot);
1961
- const reverseV1 = new BookingCopilotTaskRuntime(reverseLedger);
1962
- reverseV1.startTask(v1Turn);
1963
- const reverseV2 = new BookingCopilotTaskRuntimeV2(reverseLedger);
1964
- assert.throws(()=>reverseV2.startTask({
1965
- ...turn('task-cross-version'),
1966
- workspace: {
1967
- ...workspace(),
1968
- contextRef: 'ctx-v1'
1969
- }
1970
- }), /task_conflict:protocol_version/);
1971
- const terminalRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-terminal-replay-'));
1972
- const terminalLedger = ensureLedger(terminalRoot);
1973
- const terminalRuntime = new BookingCopilotTaskRuntimeV2(terminalLedger);
1974
- const terminalTurn = {
1975
- ...turn('task-terminal'),
1976
- turnId: 'terminal-turn-1'
1977
- };
1978
- terminalRuntime.startTask(terminalTurn);
1979
- terminalRuntime.applyDecisionBatch('task-terminal', 'turn:task-terminal:terminal-turn-1', [
1980
- {
1981
- kind: 'terminal',
1982
- terminal: {
1983
- status: 'completed',
1984
- summary: 'read-only result',
1985
- factRefs: []
1986
- }
1987
- }
1988
- ], true);
1989
- assert.equal(terminalRuntime.resumeTask('task-terminal')?.phase, 'terminal');
1990
- const terminalCount = terminalLedger.countEvents();
1991
- assert.throws(()=>terminalRuntime.startTask(terminalTurn), /task_terminal/);
1992
- assert.equal(terminalLedger.countEvents(), terminalCount, 'terminal exact replay does not append a turn');
1993
- assert.throws(()=>terminalRuntime.startTask({
1994
- ...terminalTurn,
1995
- turnId: 'terminal-new-turn'
1996
- }), /task_terminal/);
1997
- const finalityRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-batch-finality-'));
1998
- const finalityLedger = ensureLedger(finalityRoot);
1999
- const finalityRuntime = new BookingCopilotTaskRuntimeV2(finalityLedger);
2000
- finalityRuntime.startTask(turn('task-batch-finality'));
2001
- const finalityRows = finalityLedger.countEvents();
2002
- const finalityBytes = finalityLedger.db.prepare('SELECT COALESCE(SUM(length(payload)), 0) AS n FROM events').get().n;
2003
- const finalDecision = {
2004
- kind: 'terminal',
2005
- terminal: {
2006
- status: 'completed',
2007
- summary: 'one final',
2008
- factRefs: []
2009
- }
2010
- };
2011
- assert.throws(()=>finalityRuntime.applyDecisionBatch('task-batch-finality', 'batch-two-finals', [
2012
- finalDecision,
2013
- finalDecision
2014
- ], true), /decision_batch_finality/);
2015
- assert.throws(()=>finalityRuntime.applyDecisionBatch('task-batch-finality', 'batch-after-final', [
2016
- finalDecision,
2017
- {
2018
- kind: 'explanation',
2019
- explanation: {
2020
- text: 'must not follow final',
2021
- factRefs: []
2022
- }
2023
- }
2024
- ], true), /decision_batch_finality/);
2025
- assert.equal(finalityLedger.countEvents(), finalityRows, 'malformed final batches emit no events');
2026
- assert.equal(finalityLedger.db.prepare('SELECT COALESCE(SUM(length(payload)), 0) AS n FROM events').get().n, finalityBytes, 'malformed final batches do not alter payload bytes');
2027
- finalityLedger.close();
2028
- rmSync(finalityRoot, {
2029
- recursive: true,
2030
- force: true
2031
- });
2032
- const longRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-internal-key-'));
2033
- const longLedger = ensureLedger(longRoot);
2034
- const longRuntime = new BookingCopilotTaskRuntimeV2(longLedger);
2035
- const longTaskId = `task-${'t'.repeat(180)}`;
2036
- const longTurnId = `turn-${'u'.repeat(180)}`;
2037
- const longActionId = `action-${'a'.repeat(180)}`;
2038
- longRuntime.startTask({
2039
- ...turn(longTaskId),
2040
- turnId: longTurnId
2041
- });
2042
- const longDecisionKey = `turn:${longTaskId}:${longTurnId}:${longActionId}`;
2043
- const longDecisionEvents = longRuntime.applyDecisionBatch(longTaskId, longDecisionKey, [
2044
- {
2045
- kind: 'operation',
2046
- action: action(longActionId)
2047
- }
2048
- ]);
2049
- assert.equal(longDecisionEvents.some((event)=>event.kind === 'operation' && event.action.actionId === longActionId), true, 'long internal decision key is accepted');
2050
- longLedger.close();
2051
- rmSync(longRoot, {
2052
- recursive: true,
2053
- force: true
2054
- });
2055
- const budgetRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-operation-budget-'));
2056
- const budgetLedger = ensureLedger(budgetRoot);
2057
- const budgetRuntime = new BookingCopilotTaskRuntimeV2(budgetLedger);
2058
- const budgetTaskId = 'task-operation-budget';
2059
- budgetRuntime.startTask(turn(budgetTaskId));
2060
- let twentiethContinuation;
2061
- for(let ordinal = 1; ordinal <= 20; ordinal++){
2062
- const currentTurn = {
2063
- ...turn(budgetTaskId),
2064
- turnId: `budget-turn-${ordinal}`
2065
- };
2066
- if (ordinal > 1) budgetRuntime.startTask(currentTurn);
2067
- const operation = budgetRuntime.issueOperation(budgetTaskId, action(`budget-action-${ordinal}`));
2068
- const continuation = {
2069
- schemaVersion: 'booking.surface.v2',
2070
- kind: 'action.receipt.continuation',
2071
- taskId: budgetTaskId,
2072
- workspace: workspace(0),
2073
- receipt: budgetRuntime.withReceiptDigest({
2074
- schemaVersion: 'booking.surface.v2',
2075
- kind: 'action.receipt',
2076
- actionId: operation.action.actionId,
2077
- contextRef: 'ctx-v2',
2078
- status: 'applied',
2079
- revision: 0,
2080
- observation: {
2081
- kind: 'search.state',
2082
- resultCount: 0
2083
- },
2084
- resultContract: {
2085
- outcome: 'complete',
2086
- hardCriteriaMet: true,
2087
- factRefs: [],
2088
- gapCodes: [],
2089
- blockers: [],
2090
- relaxationsApplied: []
2091
- }
2092
- })
2093
- };
2094
- const folded = budgetRuntime.continueWithReceipt(continuation);
2095
- assert.equal(folded.operationCount, ordinal);
2096
- if (ordinal === 19) assert.equal(folded.phase, 'planning', '19th receipt remains continuable');
2097
- if (ordinal === 20) {
2098
- assert.equal(folded.phase, 'terminal');
2099
- twentiethContinuation = continuation;
2100
- }
2101
- }
2102
- assert.equal(budgetRuntime.resumeTask(budgetTaskId)?.operationCount, 20);
2103
- assert.equal(budgetLedger.db.prepare("SELECT COUNT(*) AS n FROM events WHERE kind = 'booking.copilot.v2.event.emitted' AND json_extract(payload, '$.eventKind') = 'terminal'").get().n, 1, '20th receipt emits one durable terminal event');
2104
- const terminalBatchCount = budgetLedger.countEvents();
2105
- const terminalBatchBytes = budgetLedger.db.prepare('SELECT COALESCE(SUM(length(payload)), 0) AS n FROM events').get().n;
2106
- const budgetRestart = new BookingCopilotTaskRuntimeV2(ensureLedger(budgetRoot));
2107
- assert.equal(budgetRestart.resumeTask(budgetTaskId)?.operationCount, 20, 'operation count survives restart');
2108
- assert.throws(()=>budgetRestart.startTask({
2109
- ...turn(budgetTaskId),
2110
- turnId: 'budget-turn-21'
2111
- }), /task_terminal/);
2112
- assert.throws(()=>budgetRestart.issueOperation(budgetTaskId, action('budget-action-21')), /task_terminal/);
2113
- assert.equal(budgetRestart.continueWithReceipt(twentiethContinuation).phase, 'terminal', 'exact terminal receipt replay is read-only');
2114
- assert.throws(()=>budgetRestart.emitEvent(budgetTaskId, {
2115
- kind: 'explanation',
2116
- explanation: {
2117
- text: 'must reject',
2118
- factRefs: []
2119
- }
2120
- }), /task_terminal/);
2121
- assert.throws(()=>budgetRestart.terminalDecisionBatch(budgetTaskId, 'arbitrary-terminal-key'), /task_terminal/);
2122
- assert.throws(()=>budgetRestart.applyDecisionBatch(budgetTaskId, 'arbitrary-terminal-key-2', [
2123
- {
2124
- kind: 'terminal',
2125
- terminal: {
2126
- status: 'stopped',
2127
- summary: 'must reject',
2128
- factRefs: []
2129
- }
2130
- }
2131
- ]), /task_terminal/);
2132
- assert.throws(()=>budgetRestart.commitDecisionBatch(budgetTaskId, 'arbitrary-terminal-key-3', []), /task_terminal/);
2133
- assert.equal(budgetLedger.countEvents(), terminalBatchCount, 'post-terminal attempts do not mutate ledger');
2134
- assert.equal(budgetLedger.db.prepare('SELECT COALESCE(SUM(length(payload)), 0) AS n FROM events').get().n, terminalBatchBytes, 'post-terminal attempts do not mutate ledger bytes');
2135
- budgetRestart['ledger'].close();
2136
- budgetLedger.close();
2137
- rmSync(budgetRoot, {
2138
- recursive: true,
2139
- force: true
2140
- });
2141
- const bindingRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-request-binding-'));
2142
- const bindingRuntime = new BookingCopilotTaskRuntimeV2(ensureLedger(bindingRoot));
2143
- const bindingTurn1 = {
2144
- ...turn('task-request-binding'),
2145
- turnId: 'binding-turn-1'
2146
- };
2147
- bindingRuntime.startTask(bindingTurn1);
2148
- bindingRuntime.persistRequestBinding('request-one', bindingTurn1, {
2149
- requestKey: 'request-one',
2150
- principal: {
2151
- subject: 'binding-principal',
2152
- scope: 'booking:read'
2153
- }
2154
- });
2155
- const firstBatch = bindingRuntime.applyDecisionBatch('task-request-binding', 'request-one', [
2156
- {
2157
- kind: 'explanation',
2158
- explanation: {
2159
- text: 'first',
2160
- factRefs: []
2161
- }
2162
- }
2163
- ], true);
2164
- const afterFirstBatch = bindingRuntime['ledger'].countEvents();
2165
- bindingRuntime.startTask({
2166
- ...bindingTurn1,
2167
- turnId: 'binding-turn-2'
2168
- });
2169
- const bindingTurn2 = {
2170
- ...bindingTurn1,
2171
- turnId: 'binding-turn-2'
2172
- };
2173
- bindingRuntime.persistRequestBinding('request-two', bindingTurn2, {
2174
- requestKey: 'request-two',
2175
- principal: {
2176
- subject: 'binding-principal',
2177
- scope: 'booking:read'
2178
- }
2179
- });
2180
- assert.deepEqual(bindingRuntime.applyDecisionBatch('task-request-binding', 'request-one', [
2181
- {
2182
- kind: 'error',
2183
- error: {
2184
- code: 'forged',
2185
- message: 'forged',
2186
- retryable: false
2187
- }
2188
- }
2189
- ]), firstBatch, 'R1 replay returns original durable batch after R2');
2190
- assert.equal(bindingRuntime['ledger'].countEvents(), afterFirstBatch + 2, 'R1 replay and R2 binding do not append a replacement batch');
2191
- assert.throws(()=>bindingRuntime.assertRequestBinding('request-one', {
2192
- ...bindingTurn1,
2193
- request: {
2194
- text: 'changed body'
2195
- }
2196
- }, {
2197
- requestKey: 'request-one',
2198
- principal: {
2199
- subject: 'binding-principal',
2200
- scope: 'booking:read'
2201
- }
2202
- }), /request_conflict/);
2203
- bindingRuntime['ledger'].close();
2204
- rmSync(bindingRoot, {
2205
- recursive: true,
2206
- force: true
2207
- });
2208
- const legacyRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-legacy-ordinal-'));
2209
- const legacyLedger = ensureLedger(legacyRoot);
2210
- const legacyRuntime = new BookingCopilotTaskRuntimeV2(legacyLedger);
2211
- legacyRuntime.startTask(turn('task-legacy-ordinal'));
2212
- const legacyOperation = legacyRuntime.issueOperation('task-legacy-ordinal', action('legacy-action'));
2213
- legacyLedger.db.prepare("UPDATE events SET payload = json_remove(payload, '$.operationCount') WHERE kind = 'booking.copilot.v2.action.issued'").run();
2214
- const legacyReceipt = legacyRuntime.withReceiptDigest({
2215
- schemaVersion: 'booking.surface.v2',
2216
- kind: 'action.receipt',
2217
- actionId: legacyOperation.action.actionId,
2218
- contextRef: 'ctx-v2',
2219
- status: 'applied',
2220
- revision: 0,
2221
- observation: {
2222
- kind: 'search.state',
2223
- resultCount: 0
2224
- },
2225
- resultContract: {
2226
- outcome: 'complete',
2227
- hardCriteriaMet: true,
2228
- factRefs: [],
2229
- gapCodes: [],
2230
- blockers: [],
2231
- relaxationsApplied: []
2232
- }
2233
- });
2234
- legacyRuntime.continueWithReceipt({
2235
- schemaVersion: 'booking.surface.v2',
2236
- kind: 'action.receipt.continuation',
2237
- taskId: 'task-legacy-ordinal',
2238
- workspace: workspace(0),
2239
- receipt: legacyReceipt
2240
- });
2241
- legacyLedger.db.prepare("UPDATE events SET payload = json_remove(payload, '$.operationCount') WHERE kind = 'booking.copilot.v2.receipt.observed'").run();
2242
- const legacyRestart = new BookingCopilotTaskRuntimeV2(ensureLedger(legacyRoot));
2243
- assert.equal(legacyRestart.resumeTask('task-legacy-ordinal')?.operationCount, 1, 'legacy action and receipt rows recover operation ordinal');
2244
- legacyRestart['ledger'].close();
2245
- legacyLedger.close();
2246
- rmSync(legacyRoot, {
2247
- recursive: true,
2248
- force: true
2249
- });
2250
- const ordinalTamperRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-ordinal-tamper-'));
2251
- const ordinalTamperLedger = ensureLedger(ordinalTamperRoot);
2252
- const ordinalTamperRuntime = new BookingCopilotTaskRuntimeV2(ordinalTamperLedger);
2253
- ordinalTamperRuntime.startTask(turn('task-ordinal-tamper'));
2254
- ordinalTamperRuntime.issueOperation('task-ordinal-tamper', action('ordinal-action'));
2255
- ordinalTamperLedger.db.prepare("UPDATE events SET payload = json_set(payload, '$.operationCount', 7) WHERE kind = 'booking.copilot.v2.action.issued'").run();
2256
- const ordinalRestart = new BookingCopilotTaskRuntimeV2(ensureLedger(ordinalTamperRoot));
2257
- assert.throws(()=>ordinalRestart.resumeTask('task-ordinal-tamper'), /ledger_corrupt:task-ordinal-tamper:action/, 'inconsistent explicit operation ordinal is rejected');
2258
- ordinalRestart['ledger'].close();
2259
- ordinalTamperLedger.close();
2260
- rmSync(ordinalTamperRoot, {
2261
- recursive: true,
2262
- force: true
2263
- });
2264
- const rewriteLegacyOfferRows = (ledger, taskId, text, scalarVerifiedOfferRef)=>{
2265
- const rows = ledger.db.prepare("SELECT seq, kind, payload FROM events WHERE run_id = ? ORDER BY seq").all(taskId);
2266
- const actionKinds = new Map();
2267
- const actionWorkspaceDigests = new Map();
2268
- const workspaceDigestMigration = new Map();
2269
- let currentWorkspace;
2270
- for (const row of rows){
2271
- if (row.kind !== 'booking.copilot.v2.action.issued') continue;
2272
- const payload = JSON.parse(row.payload);
2273
- const action = payload.action;
2274
- if (action && typeof action.actionId === 'string' && typeof action.kind === 'string') actionKinds.set(action.actionId, action.kind);
2275
- }
2276
- for (const row of rows){
2277
- const payload = JSON.parse(row.payload);
2278
- const receiptActionId = row.kind === 'booking.copilot.v2.receipt.observed' && payload.receipt && typeof payload.receipt.actionId === 'string' ? payload.receipt.actionId : undefined;
2279
- if (payload.workspace && typeof payload.workspace === 'object') {
2280
- const versionedWorkspace = structuredClone(payload.workspace);
2281
- const oldWorkspace = legacyWorkspace(payload.workspace, scalarVerifiedOfferRef);
2282
- payload.workspace = oldWorkspace;
2283
- const oldDigest = oldWorkspaceDigest(oldWorkspace);
2284
- workspaceDigestMigration.set(oldWorkspaceDigest(versionedWorkspace), oldDigest);
2285
- payload.workspaceDigest = oldDigest;
2286
- payload.workspaceSemanticDigest = oldWorkspaceSemanticDigest(oldWorkspace);
2287
- currentWorkspace = oldWorkspace;
2288
- }
2289
- if (payload.availability && typeof payload.availability === 'object') {
2290
- const actionId = payload.action && typeof payload.action === 'object' && typeof payload.action.actionId === 'string' ? payload.action.actionId : undefined;
2291
- const availabilityValue = structuredClone(payload.availability);
2292
- const sourceWorkspaceDigest = (()=>{
2293
- const hotels = availabilityValue.hotels;
2294
- if (!hotels || typeof hotels !== 'object' || Array.isArray(hotels)) return undefined;
2295
- for (const hotel of Object.values(hotels)){
2296
- if (!hotel || typeof hotel !== 'object' || Array.isArray(hotel)) continue;
2297
- const generation = hotel.currentGeneration;
2298
- if (!generation || typeof generation !== 'object' || Array.isArray(generation)) continue;
2299
- const source = generation.source;
2300
- if (source && typeof source === 'object' && !Array.isArray(source) && source.kind === 'workspace_snapshot' && typeof source.workspaceDigest === 'string') return source.workspaceDigest;
2301
- }
2302
- return undefined;
2303
- })();
2304
- const sourceDigest = workspaceDigestMigration.get(sourceWorkspaceDigest ?? '') ?? (receiptActionId ? actionWorkspaceDigests.get(receiptActionId) : currentWorkspace ? oldWorkspaceDigest(currentWorkspace) : undefined);
2305
- const oldAvailability = legacyAvailability(payload.availability, sourceDigest);
2306
- payload.availability = oldAvailability;
2307
- payload.availabilityDigest = bookingV2Digest(oldAvailability);
2308
- if (actionId) actionWorkspaceDigests.set(actionId, currentWorkspace ? oldWorkspaceDigest(currentWorkspace) : '');
2309
- }
2310
- if (row.kind === 'booking.copilot.v2.user.turn.observed' && typeof payload.turnId === 'string' && payload.workspace && typeof payload.workspace === 'object') {
2311
- payload.requestDigest = legacyTurnDigest(taskId, payload.turnId, payload.workspace, text);
2312
- }
2313
- if (row.kind === 'booking.copilot.v2.action.issued' && payload.action && typeof payload.action === 'object') {
2314
- const oldAction = payload.action;
2315
- const input = oldAction.input;
2316
- if (input && typeof input === 'object' && !Array.isArray(input) && [
2317
- 'offer.select',
2318
- 'offer.check',
2319
- 'checkout.prepare'
2320
- ].includes(String(oldAction.kind))) {
2321
- delete input.offerVersionRef;
2322
- }
2323
- if (input && typeof input === 'object' && !Array.isArray(input)) oldAction.inputDigest = bookingV2Digest(input);
2324
- const { actionDigest: _oldActionDigest, ...actionBase } = oldAction;
2325
- oldAction.actionDigest = bookingV2Digest(actionBase);
2326
- }
2327
- if (row.kind === 'booking.copilot.v2.receipt.observed' && payload.receipt && typeof payload.receipt === 'object') {
2328
- const actionId = typeof payload.receipt.actionId === 'string' ? payload.receipt.actionId : '';
2329
- const oldReceipt = stripLegacyReceipt(payload.receipt);
2330
- if (actionKinds.get(actionId) === 'checkout.prepare' && oldReceipt.observation && typeof oldReceipt.observation === 'object') {
2331
- ;
2332
- oldReceipt.observation.verifiedOfferRef = scalarVerifiedOfferRef;
2333
- }
2334
- payload.receipt = oldReceipt;
2335
- payload.receiptDigest = bookingV2Digest(oldReceipt);
2336
- }
2337
- if (row.kind === 'booking.copilot.v2.decision.batch' && Array.isArray(payload.events)) {
2338
- payload.events = payload.events.map((event)=>{
2339
- if (!event || typeof event !== 'object' || Array.isArray(event)) return event;
2340
- const oldEvent = event;
2341
- if (oldEvent.kind !== 'operation' || !oldEvent.action || typeof oldEvent.action !== 'object' || Array.isArray(oldEvent.action)) return oldEvent;
2342
- const oldAction = oldEvent.action;
2343
- const input = oldAction.input;
2344
- if (input && typeof input === 'object' && !Array.isArray(input) && [
2345
- 'offer.select',
2346
- 'offer.check',
2347
- 'checkout.prepare'
2348
- ].includes(String(oldAction.kind))) delete input.offerVersionRef;
2349
- return oldEvent;
2350
- });
2351
- }
2352
- ledger.db.prepare('UPDATE events SET payload = ? WHERE seq = ?').run(JSON.stringify(payload), row.seq);
2353
- }
2354
- };
2355
- const legacyOfferRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-legacy-offer-version-'));
2356
- const legacyOfferLedger = ensureLedger(legacyOfferRoot);
2357
- const legacyOfferRuntime = new BookingCopilotTaskRuntimeV2(legacyOfferLedger, {
2358
- contextRefFactory: ()=>'ctx-v2'
2359
- });
2360
- const legacyWorkspace0 = {
2361
- ...workspace(0),
2362
- visibleHotels: [
2363
- {
2364
- hotelRef: 'legacy-hotel',
2365
- name: 'Legacy Hotel',
2366
- factRefs: []
2367
- }
2368
- ],
2369
- loadedOffers: [
2370
- loadedOffer('legacy-offer-a', 'legacy-hotel'),
2371
- loadedOffer('legacy-offer-b', 'legacy-hotel')
2372
- ],
2373
- selectedOfferRef: 'legacy-offer-a',
2374
- shortlistedOfferRefs: [
2375
- 'legacy-offer-a',
2376
- 'legacy-offer-b'
2377
- ]
2378
- };
2379
- const legacyWorkspace1 = {
2380
- ...legacyWorkspace0,
2381
- revision: 1,
2382
- loadedOffers: [
2383
- {
2384
- ...loadedOffer('legacy-offer-a', 'legacy-hotel', 'legacy-offer-a:v2'),
2385
- factRefs: [
2386
- 'price'
2387
- ]
2388
- },
2389
- loadedOffer('legacy-offer-b', 'legacy-hotel')
2390
- ]
2391
- };
2392
- const legacyOfferTask = legacyOfferRuntime.startTask({
2393
- ...turn('task-legacy-offer-version'),
2394
- workspace: legacyWorkspace0
2395
- });
2396
- legacyOfferRuntime.issueOperation(legacyOfferTask.taskId, {
2397
- ...action('legacy-offer-check-v1'),
2398
- kind: 'offer.check',
2399
- input: {
2400
- offerRef: 'legacy-offer-a',
2401
- offerVersionRef: 'legacy-offer-a:v1'
2402
- }
2403
- });
2404
- legacyOfferRuntime.continueWithReceipt({
2405
- schemaVersion: 'booking.surface.v2',
2406
- kind: 'action.receipt.continuation',
2407
- taskId: legacyOfferTask.taskId,
2408
- workspace: legacyWorkspace1,
2409
- receipt: {
2410
- schemaVersion: 'booking.surface.v2',
2411
- kind: 'action.receipt',
2412
- actionId: 'legacy-offer-check-v1',
2413
- contextRef: 'ctx-v2',
2414
- status: 'changed',
2415
- revision: 1,
2416
- observation: {
2417
- kind: 'offer.availability',
2418
- offerRef: 'legacy-offer-a',
2419
- checkedOfferVersionRef: 'legacy-offer-a:v1',
2420
- currentOfferVersionRef: 'legacy-offer-a:v2',
2421
- available: true,
2422
- changedFactRefs: [
2423
- 'price'
2424
- ],
2425
- gapCodes: []
2426
- },
2427
- resultContract: {
2428
- outcome: 'partial',
2429
- hardCriteriaMet: false,
2430
- factRefs: [],
2431
- gapCodes: [],
2432
- blockers: [],
2433
- relaxationsApplied: []
2434
- }
2435
- }
2436
- });
2437
- legacyOfferRuntime.startTask({
2438
- ...turn('task-legacy-offer-version', 1),
2439
- workspace: legacyWorkspace1,
2440
- turnId: 'task-legacy-offer-version-turn-1'
2441
- });
2442
- legacyOfferRuntime.issueOperation(legacyOfferTask.taskId, {
2443
- ...action('legacy-offer-check-v2', 1),
2444
- kind: 'offer.check',
2445
- input: {
2446
- offerRef: 'legacy-offer-a',
2447
- offerVersionRef: 'legacy-offer-a:v2'
2448
- }
2449
- });
2450
- rewriteLegacyOfferRows(legacyOfferLedger, legacyOfferTask.taskId, 'find hotels in Dubai');
2451
- const legacyOfferRestart = new BookingCopilotTaskRuntimeV2(ensureLedger(legacyOfferRoot), {
2452
- contextRefFactory: ()=>'ctx-v2'
2453
- });
2454
- const legacyOfferRecovered = legacyOfferRestart.resumeTask(legacyOfferTask.taskId);
2455
- assert.ok(legacyOfferRecovered && legacyOfferRecovered.pendingAction?.input.offerVersionRef, 'pre-versioned row family migrates to a version-bound pending action');
2456
- assert.deepEqual(legacyOfferRecovered?.availability.attempts.map((attempt)=>attempt.offerVersionRef), [
2457
- 'legacy-offer-version:' + bookingV2Digest({
2458
- contextRef: 'ctx-v2',
2459
- offerRef: 'legacy-offer-a',
2460
- hotelRef: 'legacy-hotel',
2461
- evidenceLevel: 'rate_loaded',
2462
- factRefs: []
2463
- }).slice(0, 40),
2464
- 'legacy-offer-version:' + bookingV2Digest({
2465
- contextRef: 'ctx-v2',
2466
- offerRef: 'legacy-offer-a',
2467
- hotelRef: 'legacy-hotel',
2468
- evidenceLevel: 'rate_loaded',
2469
- factRefs: [
2470
- 'price'
2471
- ]
2472
- }).slice(0, 40)
2473
- ], 'legacy A attempts retain distinct old v1 and changed v2 identities');
2474
- legacyOfferRestart['ledger'].close();
2475
- legacyOfferLedger.close();
2476
- rmSync(legacyOfferRoot, {
2477
- recursive: true,
2478
- force: true
2479
- });
2480
- const legacyCheckoutRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-legacy-checkout-tail-'));
2481
- const legacyCheckoutLedger = ensureLedger(legacyCheckoutRoot);
2482
- const legacyCheckoutRuntime = new BookingCopilotTaskRuntimeV2(legacyCheckoutLedger, {
2483
- contextRefFactory: ()=>'ctx-v2',
2484
- now: ()=>'2026-09-01T10:00:00.000Z'
2485
- });
2486
- const legacyCheckoutCapability = verifiedCapability('legacy-checkout', 'legacy-checkout:v1');
2487
- const legacyCheckoutWorkspace = {
2488
- ...workspace(1),
2489
- visibleHotels: [
2490
- {
2491
- hotelRef: 'legacy-checkout-hotel',
2492
- name: 'Legacy Checkout Hotel',
2493
- factRefs: []
2494
- }
2495
- ],
2496
- loadedOffers: [
2497
- loadedOffer('legacy-checkout', 'legacy-checkout-hotel')
2498
- ],
2499
- selectedOfferRef: 'legacy-checkout',
2500
- verifiedOffer: legacyCheckoutCapability
2501
- };
2502
- const legacyCheckoutTask = legacyCheckoutRuntime.startTask({
2503
- ...turn('task-legacy-checkout-tail'),
2504
- workspace: legacyCheckoutWorkspace
2505
- });
2506
- const legacyCheckoutAction = {
2507
- ...action('legacy-checkout-action', 1),
2508
- kind: 'checkout.prepare',
2509
- input: {
2510
- offerRef: 'legacy-checkout',
2511
- offerVersionRef: 'legacy-checkout:v1',
2512
- verifiedOfferRef: legacyCheckoutCapability.verifiedOfferRef
2513
- }
2514
- };
2515
- const legacyCheckoutBatch = legacyCheckoutRuntime.applyDecisionBatch(legacyCheckoutTask.taskId, 'legacy-checkout-batch', [
2516
- {
2517
- kind: 'operation',
2518
- action: legacyCheckoutAction
2519
- },
2520
- {
2521
- kind: 'explanation',
2522
- explanation: {
2523
- text: 'checkout handoff is awaiting receipt',
2524
- factRefs: []
2525
- }
2526
- }
2527
- ], true);
2528
- assert.equal(legacyCheckoutBatch.some((event)=>event.kind === 'operation' && event.action.actionId === legacyCheckoutAction.actionId), true, 'normal decision batch includes the checkout operation envelope');
2529
- const appendLegacyEvent = (eventId, sequence, eventKind)=>{
2530
- const safeContent = eventKind === 'terminal' ? {
2531
- status: 'stopped',
2532
- summary: 'legacy-terminal',
2533
- factRefs: []
2534
- } : {
2535
- code: 'unhandled',
2536
- message: 'legacy error',
2537
- retryable: false
2538
- };
2539
- legacyCheckoutLedger.insertEvent({
2540
- actor: 'system:booking-copilot-v2',
2541
- kind: 'booking.copilot.v2.event.emitted',
2542
- subjectId: legacyCheckoutTask.taskId,
2543
- runId: legacyCheckoutTask.taskId,
2544
- ts: '2026-09-01T10:00:01.000Z',
2545
- idemKey: `legacy-tail:${eventId}`,
2546
- payload: {
2547
- schema: 'booking.copilot.ledger.v2',
2548
- taskId: legacyCheckoutTask.taskId,
2549
- contextRef: 'ctx-v2',
2550
- eventId,
2551
- sequence,
2552
- emittedAt: '2026-09-01T10:00:01.000Z',
2553
- eventKind,
2554
- contentDigest: bookingV2Digest(safeContent)
2555
- }
2556
- });
2557
- };
2558
- appendLegacyEvent('legacy-terminal-event', 6, 'terminal');
2559
- appendLegacyEvent('legacy-error-event', 7, 'error');
2560
- rewriteLegacyOfferRows(legacyCheckoutLedger, legacyCheckoutTask.taskId, 'find hotels in Dubai', legacyCheckoutCapability.verifiedOfferRef);
2561
- const legacyCheckoutNextWorkspace = {
2562
- ...legacyCheckoutWorkspace,
2563
- revision: 2,
2564
- verifiedOffer: undefined
2565
- };
2566
- legacyCheckoutRuntime.appendTurn(legacyCheckoutTask.taskId, 'ctx-v2', bookingV2Digest({
2567
- schemaVersion: 'booking.surface.v2',
2568
- kind: 'user.turn',
2569
- taskId: legacyCheckoutTask.taskId,
2570
- turnId: 'legacy-checkout-next-turn',
2571
- workspace: legacyCheckoutNextWorkspace,
2572
- request: {
2573
- text: 'check the replacement room'
2574
- }
2575
- }), oldWorkspaceDigest(legacyCheckoutNextWorkspace), oldWorkspaceSemanticDigest(legacyCheckoutNextWorkspace), 2, 'legacy-checkout-next-turn', legacyCheckoutNextWorkspace);
2576
- const legacyCheckoutRestart = new BookingCopilotTaskRuntimeV2(ensureLedger(legacyCheckoutRoot), {
2577
- contextRefFactory: ()=>'ctx-v2',
2578
- now: ()=>'2026-09-01T10:00:00.000Z'
2579
- });
2580
- const legacyCheckoutRecovery = legacyCheckoutRestart.resumeTask(legacyCheckoutTask.taskId);
2581
- assert.equal(legacyCheckoutRecovery?.workspaceSnapshot?.verifiedOffer, undefined, 'legacy scalar verifiedOfferRef never becomes a live capability');
2582
- assert.equal(legacyCheckoutRecovery?.phase, 'planning', 'unsafe legacy checkout tail suppresses old terminal/error events and replans');
2583
- assert.equal(legacyCheckoutRecovery?.operationCount, 1, 'legacy checkout action ordinal remains consumed after tail skip');
2584
- assert.ok((legacyCheckoutRecovery?.lastSequence ?? 0) >= 7, 'legacy terminal/error sequence remains consumed for append-only continuation');
2585
- legacyCheckoutRestart.issueOperation(legacyCheckoutTask.taskId, {
2586
- ...action('legacy-checkout-recheck', 2),
2587
- kind: 'offer.check',
2588
- input: {
2589
- offerRef: 'legacy-checkout',
2590
- offerVersionRef: 'legacy-checkout:v1'
2591
- }
2592
- });
2593
- const legacyCheckoutAfterAppend = legacyCheckoutRestart.resumeTask(legacyCheckoutTask.taskId);
2594
- assert.equal(legacyCheckoutAfterAppend?.phase, 'waiting_receipt', 'new typed offer.check remains reachable after unsafe legacy tail');
2595
- assert.equal(legacyCheckoutAfterAppend?.operationCount, 2);
2596
- assert.equal(legacyCheckoutAfterAppend?.revision, 2);
2597
- const legacyCheckoutNewEvent = legacyCheckoutRestart.emitEvent(legacyCheckoutTask.taskId, {
2598
- kind: 'explanation',
2599
- explanation: {
2600
- text: 'new anchor event',
2601
- factRefs: []
2602
- }
2603
- });
2604
- assert.equal(legacyCheckoutNewEvent.sequence, (legacyCheckoutAfterAppend?.lastSequence ?? 0) + 1, 'new post-reanchor event receives the next unique sequence');
2605
- legacyCheckoutRestart['ledger'].close();
2606
- legacyCheckoutLedger.close();
2607
- rmSync(legacyCheckoutRoot, {
2608
- recursive: true,
2609
- force: true
2610
- });
2611
- const legacyBatchVariantFixture = (variant)=>{
2612
- const root = mkdtempSync(join(tmpdir(), `gotry-booking-v2-legacy-batch-${variant}-`));
2613
- const ledger = ensureLedger(root);
2614
- const writer = new BookingCopilotTaskRuntimeV2(ledger, {
2615
- contextRefFactory: ()=>'ctx-v2',
2616
- now: ()=>'2026-09-01T10:00:00.000Z'
2617
- });
2618
- const taskId = `task-legacy-batch-${variant}`;
2619
- const capability = verifiedCapability(`batch-${variant}-offer`);
2620
- const batchWorkspace = {
2621
- ...workspace(0),
2622
- visibleHotels: [
2623
- {
2624
- hotelRef: `batch-${variant}-hotel`,
2625
- name: 'Batch Hotel',
2626
- factRefs: []
2627
- }
2628
- ],
2629
- loadedOffers: [
2630
- loadedOffer(`batch-${variant}-offer`, `batch-${variant}-hotel`)
2631
- ],
2632
- selectedOfferRef: `batch-${variant}-offer`,
2633
- verifiedOffer: capability
2634
- };
2635
- writer.startTask({
2636
- ...turn(taskId),
2637
- workspace: batchWorkspace
2638
- });
2639
- const batchAction = {
2640
- ...action(`batch-${variant}-checkout`),
2641
- kind: 'checkout.prepare',
2642
- input: {
2643
- offerRef: `batch-${variant}-offer`,
2644
- offerVersionRef: `batch-${variant}-offer:v1`,
2645
- verifiedOfferRef: capability.verifiedOfferRef
2646
- }
2647
- };
2648
- writer.applyDecisionBatch(taskId, `batch-envelope-${variant}`, [
2649
- {
2650
- kind: 'operation',
2651
- action: batchAction
2652
- },
2653
- {
2654
- kind: 'explanation',
2655
- explanation: {
2656
- text: 'batch envelope',
2657
- factRefs: []
2658
- }
2659
- }
2660
- ], true);
2661
- rewriteLegacyOfferRows(ledger, taskId, 'find hotels in Dubai', capability.verifiedOfferRef);
2662
- const row = ledger.db.prepare("SELECT seq, payload FROM events WHERE run_id = ? AND kind = 'booking.copilot.v2.decision.batch'").get(taskId);
2663
- const payload = JSON.parse(row.payload);
2664
- if (variant === 'tampered') {
2665
- const explanation = payload.events.find((event)=>event.kind === 'explanation');
2666
- if (explanation && explanation.explanation && typeof explanation.explanation === 'object') explanation.explanation.text = 'edited envelope';
2667
- } else if (variant === 'missing') {
2668
- const operationIndex = payload.events.findIndex((event)=>event.kind === 'operation');
2669
- assert.ok(operationIndex >= 0, 'missing batch fixture has a retained operation identity to remove');
2670
- payload.events.splice(operationIndex, 1);
2671
- } else if (variant === 'unknown') {
2672
- const first = payload.events[0];
2673
- if (first) first.eventId = 'unknown-envelope-event';
2674
- } else {
2675
- const first = payload.events[0];
2676
- if (first) payload.events.push(structuredClone(first));
2677
- }
2678
- ledger.db.prepare('UPDATE events SET payload = ? WHERE seq = ?').run(JSON.stringify(payload), row.seq);
2679
- ledger.close();
2680
- return {
2681
- root,
2682
- taskId
2683
- };
2684
- };
2685
- for (const variant of [
2686
- 'tampered',
2687
- 'missing',
2688
- 'unknown',
2689
- 'duplicate'
2690
- ]){
2691
- const fixtureState = legacyBatchVariantFixture(variant);
2692
- const replayLedger = ensureLedger(fixtureState.root);
2693
- const replay = new BookingCopilotTaskRuntimeV2(replayLedger, {
2694
- contextRefFactory: ()=>'ctx-v2',
2695
- now: ()=>'2026-09-01T10:00:00.000Z'
2696
- });
2697
- assert.throws(()=>replay.resumeTask(fixtureState.taskId), new RegExp(`ledger_corrupt:${fixtureState.taskId}:decision_batch`), `legacy duplicate batch ${variant} mismatch fails closed`);
2698
- replayLedger.close();
2699
- rmSync(fixtureState.root, {
2700
- recursive: true,
2701
- force: true
2702
- });
2703
- }
2704
- const legacyTwoBatchFixture = (crossBatchGraft)=>{
2705
- const root = mkdtempSync(join(tmpdir(), `gotry-booking-v2-legacy-two-batch-${crossBatchGraft ? 'graft' : 'clean'}-`));
2706
- const ledger = ensureLedger(root);
2707
- const writer = new BookingCopilotTaskRuntimeV2(ledger, {
2708
- contextRefFactory: ()=>'ctx-v2',
2709
- now: ()=>'2026-09-01T10:00:00.000Z'
2710
- });
2711
- const taskId = `task-legacy-two-batch-${crossBatchGraft ? 'graft' : 'clean'}`;
2712
- writer.startTask(turn(taskId));
2713
- const standaloneStatus = writer.emitEvent(taskId, {
2714
- kind: 'status',
2715
- status: 'submitted'
2716
- });
2717
- const standaloneAction = writer.issueOperation(taskId, action(`${taskId}-standalone-action`));
2718
- const standaloneReceipt = writer.withReceiptDigest({
2719
- schemaVersion: 'booking.surface.v2',
2720
- kind: 'action.receipt',
2721
- actionId: standaloneAction.action.actionId,
2722
- contextRef: 'ctx-v2',
2723
- status: 'applied',
2724
- revision: 1,
2725
- observation: {
2726
- kind: 'search.state',
2727
- resultCount: 0
2728
- },
2729
- resultContract: {
2730
- outcome: 'complete',
2731
- hardCriteriaMet: true,
2732
- factRefs: [],
2733
- gapCodes: [],
2734
- blockers: [],
2735
- relaxationsApplied: []
2736
- }
2737
- });
2738
- writer.continueWithReceipt({
2739
- schemaVersion: 'booking.surface.v2',
2740
- kind: 'action.receipt.continuation',
2741
- taskId,
2742
- workspace: workspace(1),
2743
- receipt: standaloneReceipt
2744
- });
2745
- const capability = verifiedCapability(`${taskId}-offer`);
2746
- const batchWorkspace = {
2747
- ...workspace(1),
2748
- visibleHotels: [
2749
- {
2750
- hotelRef: `${taskId}-hotel`,
2751
- name: 'Two Batch Hotel',
2752
- factRefs: []
2753
- }
2754
- ],
2755
- loadedOffers: [
2756
- loadedOffer(`${taskId}-offer`, `${taskId}-hotel`)
2757
- ],
2758
- selectedOfferRef: `${taskId}-offer`,
2759
- verifiedOffer: capability
2760
- };
2761
- writer.startTask({
2762
- ...turn(taskId, 1),
2763
- turnId: `${taskId}-anchor-turn`,
2764
- workspace: batchWorkspace
2765
- });
2766
- const normalBatch = writer.applyDecisionBatch(taskId, `${taskId}-normal-batch`, [
2767
- {
2768
- kind: 'explanation',
2769
- explanation: {
2770
- text: 'normal completed batch',
2771
- factRefs: []
2772
- }
2773
- }
2774
- ], true);
2775
- assert.ok(normalBatch.some((event)=>event.kind === 'explanation'), 'normal batch persists its own explanation');
2776
- const unsafeAction = {
2777
- ...action(`${taskId}-unsafe-checkout`, 1),
2778
- kind: 'checkout.prepare',
2779
- input: {
2780
- offerRef: `${taskId}-offer`,
2781
- offerVersionRef: `${taskId}-offer:v1`,
2782
- verifiedOfferRef: capability.verifiedOfferRef
2783
- }
2784
- };
2785
- writer.applyDecisionBatch(taskId, `${taskId}-unsafe-batch`, [
2786
- {
2787
- kind: 'operation',
2788
- action: unsafeAction
2789
- },
2790
- {
2791
- kind: 'explanation',
2792
- explanation: {
2793
- text: 'unsafe legacy batch',
2794
- factRefs: []
2795
- }
2796
- }
2797
- ], true);
2798
- rewriteLegacyOfferRows(ledger, taskId, 'find hotels in Dubai', capability.verifiedOfferRef);
2799
- if (crossBatchGraft) {
2800
- const rows = ledger.db.prepare("SELECT seq, payload FROM events WHERE run_id = ? AND kind = 'booking.copilot.v2.decision.batch' ORDER BY seq").all(taskId);
2801
- assert.equal(rows.length, 2, 'two-batch fixture retains both decision envelopes');
2802
- const firstPayload = JSON.parse(rows[0].payload);
2803
- const secondPayload = JSON.parse(rows[1].payload);
2804
- const graft = firstPayload.events.find((event)=>event.kind === 'explanation');
2805
- assert.ok(graft, 'first batch has an event identity available for graft detection');
2806
- const graftIndex = secondPayload.events.findIndex((event)=>event.kind === 'explanation');
2807
- assert.ok(graftIndex >= 0, 'second batch has a retained event slot for graft detection');
2808
- secondPayload.events[graftIndex] = structuredClone(graft);
2809
- ledger.db.prepare('UPDATE events SET payload = ? WHERE seq = ?').run(JSON.stringify(secondPayload), rows[1].seq);
2810
- }
2811
- assert.equal(standaloneStatus.sequence < standaloneAction.sequence, true, 'standalone EVENT precedes standalone ACTION before batched work');
2812
- ledger.close();
2813
- return {
2814
- root,
2815
- taskId
2816
- };
2817
- };
2818
- const cleanTwoBatch = legacyTwoBatchFixture(false);
2819
- const cleanTwoBatchReplayLedger = ensureLedger(cleanTwoBatch.root);
2820
- const cleanTwoBatchReplay = new BookingCopilotTaskRuntimeV2(cleanTwoBatchReplayLedger, {
2821
- contextRefFactory: ()=>'ctx-v2',
2822
- now: ()=>'2026-09-01T10:00:00.000Z'
2823
- });
2824
- const cleanTwoBatchState = cleanTwoBatchReplay.resumeTask(cleanTwoBatch.taskId);
2825
- assert.equal(cleanTwoBatchState?.phase, 'planning', 'normal batch replays before unsafe legacy batch enters replan');
2826
- assert.equal(cleanTwoBatchState?.operationCount, 2, 'standalone action and unsafe batch action ordinals remain consumed');
2827
- cleanTwoBatchReplayLedger.close();
2828
- rmSync(cleanTwoBatch.root, {
2829
- recursive: true,
2830
- force: true
2831
- });
2832
- const graftedTwoBatch = legacyTwoBatchFixture(true);
2833
- const graftedTwoBatchReplayLedger = ensureLedger(graftedTwoBatch.root);
2834
- const graftedTwoBatchReplay = new BookingCopilotTaskRuntimeV2(graftedTwoBatchReplayLedger, {
2835
- contextRefFactory: ()=>'ctx-v2',
2836
- now: ()=>'2026-09-01T10:00:00.000Z'
2837
- });
2838
- assert.throws(()=>graftedTwoBatchReplay.resumeTask(graftedTwoBatch.taskId), new RegExp(`ledger_corrupt:${graftedTwoBatch.taskId}:decision_batch`), 'cross-batch event graft fails closed within the second envelope');
2839
- graftedTwoBatchReplayLedger.close();
2840
- rmSync(graftedTwoBatch.root, {
2841
- recursive: true,
2842
- force: true
2843
- });
2844
- for (const kind of [
2845
- 'offer.select',
2846
- 'offer.check'
2847
- ]){
2848
- const root = mkdtempSync(join(tmpdir(), `gotry-booking-v2-legacy-safe-batch-${kind.replace('.', '-')}-`));
2849
- const ledger = ensureLedger(root);
2850
- const writer = new BookingCopilotTaskRuntimeV2(ledger, {
2851
- contextRefFactory: ()=>'ctx-v2',
2852
- now: ()=>'2026-09-01T10:00:00.000Z'
2853
- });
2854
- const taskId = `task-legacy-safe-batch-${kind.replace('.', '-')}`;
2855
- const offerRef = `${taskId}-offer`;
2856
- const hotelRef = `${taskId}-hotel`;
2857
- const safeWorkspace = {
2858
- ...workspace(0),
2859
- visibleHotels: [
2860
- {
2861
- hotelRef,
2862
- name: 'Safe Batch Hotel',
2863
- factRefs: []
2864
- }
2865
- ],
2866
- loadedOffers: [
2867
- loadedOffer(offerRef, hotelRef)
2868
- ]
2869
- };
2870
- writer.startTask({
2871
- ...turn(taskId),
2872
- workspace: safeWorkspace
2873
- });
2874
- const safeAction = {
2875
- ...action(`${taskId}-action`),
2876
- kind,
2877
- input: {
2878
- offerRef,
2879
- offerVersionRef: `${offerRef}:v1`
2880
- }
2881
- };
2882
- const requestKey = `${taskId}-request`;
2883
- writer.applyDecisionBatch(taskId, requestKey, [
2884
- {
2885
- kind: 'operation',
2886
- action: safeAction
2887
- }
2888
- ], true);
2889
- rewriteLegacyOfferRows(ledger, taskId, 'find hotels in Dubai');
2890
- ledger.close();
2891
- const restartLedger = ensureLedger(root);
2892
- const restart = new BookingCopilotTaskRuntimeV2(restartLedger, {
2893
- contextRefFactory: ()=>'ctx-v2',
2894
- now: ()=>'2026-09-01T10:00:00.000Z'
2895
- });
2896
- const recovered = restart.resumeTask(taskId);
2897
- const recoveredOperation = recovered?.pendingAction;
2898
- const expectedVersion = `legacy-offer-version:${bookingV2Digest({
2899
- contextRef: 'ctx-v2',
2900
- offerRef,
2901
- hotelRef,
2902
- evidenceLevel: 'rate_loaded',
2903
- factRefs: []
2904
- }).slice(0, 40)}`;
2905
- const offerVersionFromAction = (candidate)=>{
2906
- const input = candidate?.input;
2907
- return input && typeof input === 'object' && !Array.isArray(input) && 'offerVersionRef' in input && typeof input.offerVersionRef === 'string' ? input.offerVersionRef : undefined;
2908
- };
2909
- assert.equal(recoveredOperation?.kind, kind, `legacy ${kind} batch restart retains operation kind`);
2910
- assert.equal(offerVersionFromAction(recoveredOperation), expectedVersion, `legacy ${kind} batch restart binds deterministic synthetic version`);
2911
- const replayBatch = restart.readDecisionBatch(taskId, requestKey);
2912
- assert.ok(replayBatch, `legacy ${kind} batch is readable after restart`);
2913
- const replayOperation = replayBatch?.find((event)=>event.kind === 'operation');
2914
- assert.ok(replayOperation && validateBookingSurfaceV2(replayOperation).ok, `legacy ${kind} batch operation is schema-valid after replay normalization`);
2915
- assert.equal(replayOperation?.kind === 'operation' ? offerVersionFromAction(replayOperation.action) : undefined, expectedVersion, `legacy ${kind} batch replay operation carries synthetic version`);
2916
- restartLedger.close();
2917
- rmSync(root, {
2918
- recursive: true,
2919
- force: true
2920
- });
2921
- }
2922
- const legacyBlockerRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-legacy-blocker-replan-'));
2923
- const legacyBlockerLedger = ensureLedger(legacyBlockerRoot);
2924
- const legacyBlockerRuntime = new BookingCopilotTaskRuntimeV2(legacyBlockerLedger, {
2925
- contextRefFactory: ()=>'ctx-v2'
2926
- });
2927
- const blockerWorkspace0 = {
2928
- ...workspace(0),
2929
- visibleHotels: [
2930
- {
2931
- hotelRef: 'blocker-hotel',
2932
- name: 'Blocker Hotel',
2933
- factRefs: []
2934
- }
2935
- ],
2936
- loadedOffers: [
2937
- loadedOffer('blocker-offer', 'blocker-hotel')
2938
- ]
2939
- };
2940
- const blockerTask = legacyBlockerRuntime.startTask({
2941
- ...turn('task-legacy-blocker-lineage'),
2942
- workspace: blockerWorkspace0
2943
- });
2944
- legacyBlockerRuntime.issueOperation(blockerTask.taskId, {
2945
- ...action('legacy-blocker-check'),
2946
- kind: 'offer.check',
2947
- input: {
2948
- offerRef: 'blocker-offer',
2949
- offerVersionRef: 'blocker-offer:v1'
2950
- }
2951
- });
2952
- const legacyLineageReceipt = legacyBlockerRuntime.withReceiptDigest({
2953
- schemaVersion: 'booking.surface.v2',
2954
- kind: 'action.receipt',
2955
- actionId: 'legacy-blocker-check',
2956
- contextRef: 'ctx-v2',
2957
- status: 'needs_input',
2958
- revision: 1,
2959
- observation: {
2960
- kind: 'offer.availability',
2961
- offerRef: 'blocker-offer',
2962
- checkedOfferVersionRef: 'blocker-offer:v1',
2963
- available: false,
2964
- changedFactRefs: [],
2965
- gapCodes: []
2966
- },
2967
- resultContract: {
2968
- outcome: 'partial',
2969
- hardCriteriaMet: false,
2970
- factRefs: [
2971
- 'fact-free-cancellation'
2972
- ],
2973
- gapCodes: [
2974
- 'criterion_must_not_met'
2975
- ],
2976
- blockers: [
2977
- {
2978
- blockerId: 'legacy-blocker',
2979
- sourceActionId: 'legacy-blocker-check',
2980
- sourceReceiptDigest: '',
2981
- scope: 'availability',
2982
- code: 'criterion_must_not_met',
2983
- criterionPath: 'offers.freeCancellation',
2984
- strength: 'must',
2985
- valueDigest: 'c'.repeat(64),
2986
- evidence: {
2987
- factRefs: [
2988
- 'fact-free-cancellation'
2989
- ],
2990
- gapCodes: [
2991
- 'criterion_must_not_met'
2992
- ]
2993
- }
2994
- }
2995
- ],
2996
- relaxationsApplied: []
2997
- }
2998
- });
2999
- legacyBlockerRuntime.continueWithReceipt({
3000
- schemaVersion: 'booking.surface.v2',
3001
- kind: 'action.receipt.continuation',
3002
- taskId: blockerTask.taskId,
3003
- workspace: {
3004
- ...blockerWorkspace0,
3005
- revision: 1
3006
- },
3007
- receipt: legacyLineageReceipt
3008
- });
3009
- rewriteLegacyOfferRows(legacyBlockerLedger, blockerTask.taskId, 'find hotels in Dubai');
3010
- const legacyBlockerRestart = new BookingCopilotTaskRuntimeV2(ensureLedger(legacyBlockerRoot), {
3011
- contextRefFactory: ()=>'ctx-v2'
3012
- });
3013
- const blockerRecovery = legacyBlockerRestart.resumeTask(blockerTask.taskId);
3014
- assert.equal(blockerRecovery?.phase, 'planning', 'normalized legacy blocker receipt fail-closes to replan');
3015
- assert.equal(blockerRecovery?.replayUpgradeRequired, true, 'legacy blocker lineage records an explicit replay upgrade gap');
3016
- assert.equal(blockerRecovery?.pendingAction, undefined);
3017
- assert.throws(()=>legacyBlockerRestart.issueOperation(blockerTask.taskId, action('blocked-replan-action', 1)), /replay_upgrade|reanchor|replan|turn_required/, 'replay upgrade gap blocks direct operation before a new anchor turn');
3018
- assert.throws(()=>legacyBlockerRestart.applyDecisionBatch(blockerTask.taskId, 'blocked-replan-batch', [
3019
- {
3020
- kind: 'operation',
3021
- action: action('blocked-replan-decision', 1)
3022
- }
3023
- ]), /replay_upgrade|reanchor|replan|turn_required/, 'replay upgrade gap blocks decision batches before a new anchor turn');
3024
- assert.throws(()=>legacyBlockerRestart.emitEvent(blockerTask.taskId, {
3025
- kind: 'explanation',
3026
- explanation: {
3027
- text: 'must wait for a new anchor turn',
3028
- factRefs: []
3029
- }
3030
- }), /replay_upgrade|reanchor|replan|turn_required/, 'replay upgrade gap blocks standalone event mutation');
3031
- const blockedBindingTurn = {
3032
- ...turn(blockerTask.taskId, 1),
3033
- turnId: 'blocked-replan-binding-turn'
3034
- };
3035
- assert.throws(()=>legacyBlockerRestart.persistRequestBinding('blocked-replan-request', blockedBindingTurn, {
3036
- requestKey: 'blocked-replan-request',
3037
- principal: {
3038
- subject: 'blocked-replan-principal',
3039
- scope: 'booking:read'
3040
- }
3041
- }), /replay_upgrade|reanchor|replan|turn_required/, 'replay upgrade gap blocks request binding mutation');
3042
- assert.throws(()=>legacyBlockerRestart.terminalDecisionBatch(blockerTask.taskId, 'blocked-replan-terminal-batch'), /replay_upgrade|reanchor|replan|turn_required|task_not_terminal/, 'replay upgrade gap blocks terminal decision mutation');
3043
- const reanchorWorkspace = blockerRecovery.workspaceSnapshot;
3044
- const reanchor = legacyBlockerRestart.startTask({
3045
- ...turn(blockerTask.taskId, reanchorWorkspace.revision),
3046
- turnId: 'legacy-blocker-reanchor-turn',
3047
- workspace: reanchorWorkspace,
3048
- request: {
3049
- text: 're-anchor the upgraded workspace'
3050
- }
3051
- });
3052
- assert.equal(reanchor.replayUpgradeRequired, undefined, 'validated new TURN clears replay upgrade gap');
3053
- legacyBlockerRestart['ledger'].close();
3054
- legacyBlockerLedger.close();
3055
- rmSync(legacyBlockerRoot, {
3056
- recursive: true,
3057
- force: true
3058
- });
3059
- const skippedActionRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-legacy-skipped-action-tamper-'));
3060
- const skippedActionLedger = ensureLedger(skippedActionRoot);
3061
- const skippedActionWriter = new BookingCopilotTaskRuntimeV2(skippedActionLedger, {
3062
- contextRefFactory: ()=>'ctx-v2',
3063
- now: ()=>'2026-09-01T10:00:00.000Z'
3064
- });
3065
- const skippedWorkspace = {
3066
- ...workspace(0),
3067
- visibleHotels: [
3068
- {
3069
- hotelRef: 'skipped-hotel',
3070
- name: 'Skipped Hotel',
3071
- factRefs: []
3072
- }
3073
- ],
3074
- loadedOffers: [
3075
- loadedOffer('skipped-offer', 'skipped-hotel')
3076
- ],
3077
- selectedOfferRef: 'skipped-offer',
3078
- verifiedOffer: verifiedCapability('skipped-offer')
3079
- };
3080
- const skippedTask = skippedActionWriter.startTask({
3081
- ...turn('task-legacy-skipped-action'),
3082
- workspace: skippedWorkspace
3083
- });
3084
- skippedActionWriter.issueOperation(skippedTask.taskId, {
3085
- ...action('unsafe-checkout-tail'),
3086
- kind: 'checkout.prepare',
3087
- input: {
3088
- offerRef: 'skipped-offer',
3089
- offerVersionRef: 'skipped-offer:v1',
3090
- verifiedOfferRef: 'verified-skipped-offer'
3091
- }
3092
- });
3093
- rewriteLegacyOfferRows(skippedActionLedger, skippedTask.taskId, 'find hotels in Dubai', 'verified-skipped-offer');
3094
- skippedActionLedger.insertEvent({
3095
- actor: 'system:booking-copilot-v2',
3096
- kind: 'booking.copilot.v2.event.emitted',
3097
- subjectId: skippedTask.taskId,
3098
- runId: skippedTask.taskId,
3099
- ts: '2026-09-01T10:00:01.000Z',
3100
- idemKey: 'skipped-action-tail-event',
3101
- payload: {
3102
- schema: 'booking.copilot.ledger.v2',
3103
- taskId: skippedTask.taskId,
3104
- contextRef: 'ctx-v2',
3105
- eventId: 'skipped-action-tail-event',
3106
- sequence: 2,
3107
- emittedAt: '2026-09-01T10:00:01.000Z',
3108
- eventKind: 'error',
3109
- contentDigest: bookingV2Digest({
3110
- code: 'unhandled',
3111
- message: 'legacy error',
3112
- retryable: false
3113
- })
3114
- }
3115
- });
3116
- const lateActionBase = {
3117
- actionId: 'legacy-skipped-action',
3118
- kind: 'search.run',
3119
- contextRef: 'ctx-v2',
3120
- expectedRevision: 0,
3121
- factRefs: [],
3122
- reasonDigest: bookingV2Digest('legacy search'),
3123
- inputDigest: bookingV2Digest({}),
3124
- input: {},
3125
- eventId: 'legacy-skipped-operation',
3126
- sequence: 3,
3127
- emittedAt: '2026-09-01T10:00:02.000Z',
3128
- sourceTurnId: 'task-legacy-skipped-action-turn-0'
3129
- };
3130
- const lateAction = {
3131
- ...lateActionBase,
3132
- actionDigest: bookingV2Digest(lateActionBase)
3133
- };
3134
- const startAvailability = JSON.parse(skippedActionLedger.db.prepare("SELECT payload FROM events WHERE run_id = ? AND kind = 'booking.copilot.v2.task.started'").get(skippedTask.taskId).payload).availability;
3135
- skippedActionLedger.insertEvent({
3136
- actor: 'system:booking-copilot-v2',
3137
- kind: 'booking.copilot.v2.action.issued',
3138
- subjectId: skippedTask.taskId,
3139
- runId: skippedTask.taskId,
3140
- ts: '2026-09-01T10:00:02.000Z',
3141
- idemKey: 'legacy-skipped-action-row',
3142
- payload: {
3143
- schema: 'booking.copilot.ledger.v2',
3144
- taskId: skippedTask.taskId,
3145
- contextRef: 'ctx-v2',
3146
- action: lateAction,
3147
- operationCount: 99,
3148
- availability: legacyAvailability(startAvailability),
3149
- availabilityDigest: bookingV2Digest(legacyAvailability(startAvailability))
3150
- }
3151
- });
3152
- const skippedActionRestart = new BookingCopilotTaskRuntimeV2(ensureLedger(skippedActionRoot), {
3153
- contextRefFactory: ()=>'ctx-v2',
3154
- now: ()=>'2026-09-01T10:00:00.000Z'
3155
- });
3156
- assert.throws(()=>skippedActionRestart.resumeTask(skippedTask.taskId), /ledger_corrupt:task-legacy-skipped-action:action/, 'tampered skipped ACTION ordinal fails closed during legacy tail replay');
3157
- skippedActionRestart['ledger'].close();
3158
- skippedActionLedger.close();
3159
- rmSync(skippedActionRoot, {
3160
- recursive: true,
3161
- force: true
3162
- });
3163
- const approvedReplay = await fetch(endpoint, {
3164
- method: 'POST',
3165
- headers,
3166
- body: JSON.stringify(approvedTurn)
3167
- });
3168
- assert.equal(approvedReplay.status, 200);
3169
- assert.equal(await approvedReplay.text(), approvedBody, 'approved user-turn retry replays the durable batch while waiting for receipt');
3170
- assert.equal(plannerCalls, 3, 'approved user-turn replay does not call planner or append');
3171
- const alteredTextReplay = await fetch(endpoint, {
3172
- method: 'POST',
3173
- headers,
3174
- body: JSON.stringify({
3175
- ...approvedTurn,
3176
- request: {
3177
- ...approvedTurn.request,
3178
- text: 'different request'
3179
- }
3180
- })
3181
- });
3182
- assert.equal(alteredTextReplay.status, 409, 'altered request text cannot replay a waiting batch');
3183
- const alteredContextReplay = await fetch(endpoint, {
3184
- method: 'POST',
3185
- headers,
3186
- body: JSON.stringify({
3187
- ...approvedTurn,
3188
- workspace: {
3189
- ...approvedTurn.workspace,
3190
- contextRef: 'ctx-forged'
3191
- }
3192
- })
3193
- });
3194
- assert.equal(alteredContextReplay.status, 409, 'cross-context request cannot replay a waiting batch');
3195
- const alteredApprovalReplay = await fetch(endpoint, {
3196
- method: 'POST',
3197
- headers,
3198
- body: JSON.stringify({
3199
- ...approvedTurn,
3200
- request: {
3201
- ...approvedTurn.request,
3202
- approval: {
3203
- ...approved,
3204
- to: 'drop'
3205
- }
3206
- }
3207
- })
3208
- });
3209
- assert.equal(alteredApprovalReplay.status, 409, 'altered approval tuple cannot replay a waiting batch');
3210
- const alteredCapabilitiesReplay = await fetch(endpoint, {
3211
- method: 'POST',
3212
- headers,
3213
- body: JSON.stringify({
3214
- ...approvedTurn,
3215
- workspace: {
3216
- ...approvedTurn.workspace,
3217
- capabilities: {
3218
- ...approvedTurn.workspace.capabilities,
3219
- allowedActions: [
3220
- approvedTurn.workspace.capabilities.allowedActions[0],
3221
- approvedTurn.workspace.capabilities.allowedActions[0]
3222
- ]
3223
- }
3224
- }
3225
- })
3226
- });
3227
- assert.ok([
3228
- 400,
3229
- 409
3230
- ].includes(alteredCapabilitiesReplay.status), 'altered capabilities cannot replay a waiting batch');
3231
- const ingressReplay = await fetch(endpoint, {
3232
- method: 'POST',
3233
- headers,
3234
- body: JSON.stringify({
3235
- ...ingress,
3236
- workspace: {
3237
- ...ingress.workspace,
3238
- revision: 2
3239
- },
3240
- request: {
3241
- text: 'prefer a nearby match'
3242
- }
3243
- })
3244
- });
3245
- assert.equal(ingressReplay.status, 409, 'unbound ingress cannot replay an existing task');
3246
- assert.equal(plannerCalls, 3);
3247
- const v1Conflict = await fetch(endpoint, {
3248
- method: 'POST',
3249
- headers: {
3250
- authorization: 'Bearer v2-server-key',
3251
- 'content-type': 'application/json',
3252
- 'x-booking-surface-version': BOOKING_SURFACE_SCHEMA_VERSION,
3253
- 'x-booking-surface-schema-sha256': BOOKING_SURFACE_SCHEMA_SHA256
3254
- },
3255
- body: JSON.stringify({
3256
- schemaVersion: 'booking.surface.v1',
3257
- kind: 'user.turn',
3258
- taskId: 'task-server',
3259
- workspace: {
3260
- schemaVersion: 'booking.surface.v1',
3261
- contextRef: 'ctx-server',
3262
- surface: 'tenant',
3263
- revision: 1,
3264
- locale: 'en-US',
3265
- currency: 'AED',
3266
- searchDraft: {},
3267
- results: {
3268
- status: 'ready'
3269
- },
3270
- visibleHotels: [],
3271
- loadedOffers: [],
3272
- shortlistedOfferRefs: [],
3273
- capabilities: {
3274
- surface: 'tenant',
3275
- allowedActions: [
3276
- 'search.run'
3277
- ]
3278
- }
3279
- },
3280
- request: {
3281
- text: 'v1 must not share a v2 task id'
3282
- }
3283
- })
3284
- });
3285
- assert.equal(v1Conflict.status, 409, 'one task id cannot cross protocol versions on the unified server');
3286
- await server.close();
3287
- const defaultBindingRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-default-binding-'));
3288
- const defaultBindingLedger = ensureLedger(defaultBindingRoot);
3289
- const defaultBindingRuntime = new BookingCopilotTaskRuntimeV2(defaultBindingLedger);
3290
- let defaultBindingPlannerCalls = 0;
3291
- await assert.rejects(startBookingCopilotServer({
3292
- apiKey: 'partial-binding-key',
3293
- v2: {
3294
- runtime: defaultBindingRuntime,
3295
- plannerFactory: ()=>({
3296
- next: async ()=>[]
3297
- }),
3298
- principal: {
3299
- subject: 'partial-only',
3300
- scope: 'booking:read'
3301
- }
3302
- }
3303
- }), /booking_copilot_v2_ingress_binding_pair_required/);
3304
- await assert.rejects(startBookingCopilotServer({
3305
- apiKey: 'partial-principal-key',
3306
- v2: {
3307
- runtime: defaultBindingRuntime,
3308
- plannerFactory: ()=>({
3309
- next: async ()=>[]
3310
- }),
3311
- ingressBinding: {
3312
- bind: ()=>({
3313
- taskId: 'partial',
3314
- turnId: 'partial',
3315
- contextRef: 'partial',
3316
- surface: 'tenant',
3317
- allowedActions: [
3318
- ...BOOKING_READ_ACTION_KINDS_V2
3319
- ]
3320
- })
3321
- }
3322
- }
3323
- }), /booking_copilot_v2_ingress_binding_pair_required/);
3324
- const defaultBindingServer = await startBookingCopilotServer({
3325
- apiKey: 'default-binding-key',
3326
- runtime: new BookingCopilotTaskRuntime(defaultBindingLedger),
3327
- plannerFactory: ()=>({
3328
- next: async ()=>[]
3329
- }),
3330
- v2: {
3331
- runtime: defaultBindingRuntime,
3332
- plannerFactory: ()=>({
3333
- next: async ()=>{
3334
- defaultBindingPlannerCalls++;
3335
- return [
3336
- {
3337
- kind: 'terminal',
3338
- terminal: {
3339
- status: 'stopped',
3340
- summary: 'default binding',
3341
- factRefs: []
3342
- }
3343
- }
3344
- ];
3345
- }
3346
- })
3347
- }
3348
- });
3349
- const defaultBindingEndpoint = `http://127.0.0.1:${defaultBindingServer.port}/a2a/booking-copilot/turn`;
3350
- const defaultBindingHeaders = {
3351
- ...headers,
3352
- authorization: 'Bearer default-binding-key'
3353
- };
3354
- const defaultBindingBeforeIngress = defaultBindingLedger.countEvents();
3355
- const rejectedDefaultIngress = await fetch(defaultBindingEndpoint, {
3356
- method: 'POST',
3357
- headers: defaultBindingHeaders,
3358
- body: JSON.stringify(ingress)
3359
- });
3360
- assert.equal(rejectedDefaultIngress.status, 503, 'bound-turn-only mode rejects browser ingress before the ledger');
3361
- assert.deepEqual(await rejectedDefaultIngress.json(), {
3362
- error: {
3363
- code: 'trusted_ingress_binding_required',
3364
- mode: 'bff-bound-turn-only',
3365
- acceptedTurnKinds: [
3366
- 'user.turn',
3367
- 'action.receipt.continuation'
3368
- ]
3369
- }
3370
- });
3371
- assert.equal(defaultBindingLedger.countEvents(), defaultBindingBeforeIngress, 'rejected browser ingress has no ledger side effects');
3372
- const boundTurnForSurface = (taskId, surface, allowedActions)=>({
3373
- ...turn(taskId),
3374
- workspace: {
3375
- ...workspace(),
3376
- surface,
3377
- capabilities: {
3378
- surface,
3379
- allowedActions
3380
- }
3381
- }
3382
- });
3383
- const invalidBoundTurns = [
3384
- boundTurnForSurface('task-bound-storefront-escalation', 'storefront', [
3385
- 'order.observe'
3386
- ]),
3387
- boundTurnForSurface('task-bound-payment-escalation', 'payment_link', [
3388
- 'checkout.prepare'
3389
- ]),
3390
- {
3391
- ...boundTurnForSurface('task-bound-capability-mismatch', 'storefront', [
3392
- 'search.run'
3393
- ]),
3394
- workspace: {
3395
- ...workspace(),
3396
- surface: 'storefront',
3397
- capabilities: {
3398
- surface: 'tenant',
3399
- allowedActions: [
3400
- 'search.run'
3401
- ]
3402
- }
3403
- }
3404
- },
3405
- boundTurnForSurface('task-bound-duplicate-capability', 'tenant', [
3406
- 'search.run',
3407
- 'search.run'
3408
- ]),
3409
- boundTurnForSurface('task-bound-unknown-capability', 'tenant', [
3410
- 'book'
3411
- ])
3412
- ];
3413
- for (const invalidBoundTurn of invalidBoundTurns){
3414
- const before = defaultBindingLedger.countEvents();
3415
- const response = await fetch(defaultBindingEndpoint, {
3416
- method: 'POST',
3417
- headers: defaultBindingHeaders,
3418
- body: JSON.stringify(invalidBoundTurn)
3419
- });
3420
- assert.ok([
3421
- 400,
3422
- 403
3423
- ].includes(response.status), `${invalidBoundTurn.taskId} is rejected at the schema/authority boundary`);
3424
- assert.equal(defaultBindingLedger.countEvents(), before, `${invalidBoundTurn.taskId} has no ledger side effects`);
3425
- }
3426
- const invalidBoundContinuation = {
3427
- schemaVersion: 'booking.surface.v2',
3428
- kind: 'action.receipt.continuation',
3429
- taskId: 'task-bound-invalid-continuation',
3430
- workspace: {
3431
- ...workspace(1),
3432
- surface: 'storefront',
3433
- capabilities: {
3434
- surface: 'storefront',
3435
- allowedActions: [
3436
- 'order.observe'
3437
- ]
3438
- }
3439
- },
3440
- receipt: {
3441
- schemaVersion: 'booking.surface.v2',
3442
- kind: 'action.receipt',
3443
- actionId: 'missing-action',
3444
- contextRef: 'ctx-v2',
3445
- status: 'failed',
3446
- revision: 1,
3447
- observation: {
3448
- kind: 'gap',
3449
- code: 'unhandled',
3450
- factRefs: []
3451
- },
3452
- resultContract: {
3453
- outcome: 'empty',
3454
- hardCriteriaMet: false,
3455
- factRefs: [],
3456
- gapCodes: [
3457
- 'unhandled'
3458
- ],
3459
- blockers: [],
3460
- relaxationsApplied: []
3461
- }
3462
- }
3463
- };
3464
- const invalidContinuationBefore = defaultBindingLedger.countEvents();
3465
- const invalidContinuationResponse = await fetch(defaultBindingEndpoint, {
3466
- method: 'POST',
3467
- headers: defaultBindingHeaders,
3468
- body: JSON.stringify(invalidBoundContinuation)
3469
- });
3470
- assert.equal(invalidContinuationResponse.status, 403, 'receipt continuation cannot change to a surface-disallowed capability');
3471
- assert.equal(defaultBindingLedger.countEvents(), invalidContinuationBefore, 'invalid bound continuation has no ledger side effects');
3472
- assert.equal(defaultBindingPlannerCalls, 0, 'invalid bound turns do not call the planner');
3473
- for (const [surface, allowedActions] of [
3474
- [
3475
- 'tenant',
3476
- [
3477
- 'search.run'
3478
- ]
3479
- ],
3480
- [
3481
- 'customer_portal',
3482
- [
3483
- 'offers.query'
3484
- ]
3485
- ],
3486
- [
3487
- 'storefront',
3488
- [
3489
- 'search.run'
3490
- ]
3491
- ],
3492
- [
3493
- 'payment_link',
3494
- [
3495
- 'hotel.select'
3496
- ]
3497
- ]
3498
- ]){
3499
- const response = await fetch(defaultBindingEndpoint, {
3500
- method: 'POST',
3501
- headers: defaultBindingHeaders,
3502
- body: JSON.stringify(boundTurnForSurface(`task-bound-valid-${surface}`, surface, allowedActions))
3503
- });
3504
- assert.equal(response.status, 200, `valid ${surface} bound turn is accepted`);
3505
- assert.match(await response.text(), /event: terminal/);
3506
- }
3507
- assert.equal(defaultBindingPlannerCalls, 4, 'only valid surface-bound turns call the planner');
3508
- await defaultBindingServer.close();
3509
- assert.equal(defaultBindingLedger.countEvents() > defaultBindingBeforeIngress, true);
3510
- defaultBindingLedger.close();
3511
- rmSync(defaultBindingRoot, {
3512
- recursive: true,
3513
- force: true
3514
- });
3515
- const terminalServerRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-http-terminal-replay-'));
3516
- const terminalServerLedger = ensureLedger(terminalServerRoot);
3517
- const terminalServerRuntime = new BookingCopilotTaskRuntimeV2(terminalServerLedger);
3518
- const terminalServer = await startBookingCopilotServer({
3519
- apiKey: 'terminal-server-key',
3520
- v2: {
3521
- runtime: terminalServerRuntime,
3522
- principal: {
3523
- subject: 'bff-terminal',
3524
- scope: 'booking:read'
3525
- },
3526
- ingressBinding: {
3527
- bind: ()=>({
3528
- taskId: 'task-http-terminal',
3529
- turnId: 'http-terminal-turn',
3530
- contextRef: 'ctx-server',
3531
- surface: 'tenant',
3532
- allowedActions: [
3533
- ...BOOKING_READ_ACTION_KINDS_V2
3534
- ]
3535
- })
3536
- },
3537
- plannerFactory: ()=>({
3538
- next: async ()=>[
3539
- {
3540
- kind: 'terminal',
3541
- terminal: {
3542
- status: 'stopped',
3543
- summary: 'terminal replay',
3544
- factRefs: []
3545
- }
3546
- }
3547
- ]
3548
- })
3549
- }
3550
- });
3551
- const terminalIngressEndpoint = `http://127.0.0.1:${terminalServer.port}/a2a/booking-copilot/turn`;
3552
- const terminalIngress = {
3553
- ...ingress,
3554
- requestKey: 'terminal-request-1'
3555
- };
3556
- const terminalIngressHeaders = {
3557
- ...headers,
3558
- authorization: 'Bearer terminal-server-key'
3559
- };
3560
- const terminalFirst = await fetch(terminalIngressEndpoint, {
3561
- method: 'POST',
3562
- headers: terminalIngressHeaders,
3563
- body: JSON.stringify(terminalIngress)
3564
- });
3565
- assert.equal(terminalFirst.status, 200);
3566
- const terminalFirstBody = await terminalFirst.text();
3567
- const terminalIngressCount = terminalServerLedger.countEvents();
3568
- const terminalReplay = await fetch(terminalIngressEndpoint, {
3569
- method: 'POST',
3570
- headers: terminalIngressHeaders,
3571
- body: JSON.stringify(terminalIngress)
3572
- });
3573
- assert.equal(terminalReplay.status, 200);
3574
- assert.equal(await terminalReplay.text(), terminalFirstBody, 'terminal ingress exact request batch replays after terminal');
3575
- assert.equal(terminalServerLedger.countEvents(), terminalIngressCount);
3576
- const terminalNewRequest = await fetch(terminalIngressEndpoint, {
3577
- method: 'POST',
3578
- headers: terminalIngressHeaders,
3579
- body: JSON.stringify({
3580
- ...terminalIngress,
3581
- requestKey: 'terminal-request-2'
3582
- })
3583
- });
3584
- assert.equal(terminalNewRequest.status, 409, 'new terminal ingress request key is rejected');
3585
- assert.equal(terminalServerLedger.countEvents(), terminalIngressCount);
3586
- await terminalServer.close();
3587
- terminalServerLedger.close();
3588
- rmSync(terminalServerRoot, {
3589
- recursive: true,
3590
- force: true
3591
- });
3592
- for (const [surface, taskId, allowedActions, hint] of [
3593
- [
3594
- 'storefront',
3595
- 'task-storefront-escalation',
3596
- [
3597
- ...BOOKING_READ_ACTION_KINDS_V2
3598
- ],
3599
- 'storefront'
3600
- ],
3601
- [
3602
- 'payment_link',
3603
- 'task-payment-escalation',
3604
- [
3605
- ...BOOKING_READ_ACTION_KINDS_V2
3606
- ],
3607
- 'payment_link'
3608
- ],
3609
- [
3610
- 'tenant',
3611
- 'task-surface-mismatch',
3612
- [
3613
- ...BOOKING_READ_ACTION_KINDS_V2
3614
- ],
3615
- 'storefront'
3616
- ]
3617
- ]){
3618
- const matrixRoot = mkdtempSync(join(tmpdir(), `gotry-booking-v2-${taskId}-`));
3619
- const matrixLedger = ensureLedger(matrixRoot);
3620
- const matrixRuntime = new BookingCopilotTaskRuntimeV2(matrixLedger);
3621
- const matrixServer = await startBookingCopilotServer({
3622
- apiKey: `${taskId}-key`,
3623
- v2: {
3624
- runtime: matrixRuntime,
3625
- principal: {
3626
- subject: 'bff-matrix',
3627
- scope: 'booking:read'
3628
- },
3629
- ingressBinding: {
3630
- bind: ()=>({
3631
- taskId,
3632
- turnId: `${taskId}-turn`,
3633
- contextRef: 'ctx-server',
3634
- surface,
3635
- allowedActions: [
3636
- ...allowedActions
3637
- ]
3638
- })
3639
- },
3640
- plannerFactory: ()=>({
3641
- next: async ()=>[
3642
- {
3643
- kind: 'terminal',
3644
- terminal: {
3645
- status: 'stopped',
3646
- summary: 'must not plan',
3647
- factRefs: []
3648
- }
3649
- }
3650
- ]
3651
- })
3652
- }
3653
- });
3654
- const matrixResponse = await fetch(`http://127.0.0.1:${matrixServer.port}/a2a/booking-copilot/turn`, {
3655
- method: 'POST',
3656
- headers: {
3657
- ...headers,
3658
- authorization: `Bearer ${taskId}-key`
3659
- },
3660
- body: JSON.stringify({
3661
- ...ingress,
3662
- requestKey: `${taskId}-request`,
3663
- surfaceHint: hint
3664
- })
3665
- });
3666
- assert.equal(matrixResponse.status, 502, `${surface} binding escalation/mismatch is rejected before planning`);
3667
- assert.equal(matrixLedger.countEvents(), 0);
3668
- await matrixServer.close();
3669
- matrixLedger.close();
3670
- rmSync(matrixRoot, {
3671
- recursive: true,
3672
- force: true
3673
- });
3674
- }
3675
- for (const [label, plannerFactory, expectedCode] of [
3676
- [
3677
- 'runtime-unsupported-action',
3678
- ()=>({
3679
- next: async ()=>[
3680
- {
3681
- kind: 'operation',
3682
- action: {
3683
- ...action('disallowed-http-action'),
3684
- kind: 'offers.query',
3685
- input: {
3686
- hotelRefs: [
3687
- 'hotel-a'
3688
- ],
3689
- criteria: {}
3690
- }
3691
- }
3692
- }
3693
- ]
3694
- }),
3695
- 'UNSUPPORTED_ACTION'
3696
- ],
3697
- [
3698
- 'planner-surface-unsupported',
3699
- ()=>({
3700
- next: async ()=>{
3701
- throw new Error('planner_surface_action_unsupported: raw internal detail');
3702
- }
3703
- }),
3704
- 'PLANNER_SURFACE_ACTION_UNSUPPORTED'
3705
- ]
3706
- ]){
3707
- const errorRoot = mkdtempSync(join(tmpdir(), `gotry-booking-v2-${label}-`));
3708
- const errorLedger = ensureLedger(errorRoot);
3709
- const errorRuntime = new BookingCopilotTaskRuntimeV2(errorLedger);
3710
- const errorTaskId = `task-${label}`;
3711
- let errorPlannerCalls = 0;
3712
- const errorServer = await startBookingCopilotServer({
3713
- apiKey: `${label}-key`,
3714
- v2: {
3715
- runtime: errorRuntime,
3716
- principal: {
3717
- subject: `bff-${label}`,
3718
- scope: 'booking:read'
3719
- },
3720
- ingressBinding: {
3721
- bind: ()=>({
3722
- taskId: errorTaskId,
3723
- turnId: `${errorTaskId}-turn`,
3724
- contextRef: 'ctx-v2',
3725
- surface: 'tenant',
3726
- allowedActions: [
3727
- 'search.run'
3728
- ]
3729
- })
3730
- },
3731
- plannerFactory: (_initial)=>{
3732
- const session = plannerFactory();
3733
- return {
3734
- next: async (_input)=>{
3735
- errorPlannerCalls++;
3736
- return session.next();
3737
- }
3738
- };
3739
- }
3740
- }
3741
- });
3742
- const errorTurn = {
3743
- ...turn(errorTaskId),
3744
- workspace: {
3745
- ...workspace(),
3746
- capabilities: {
3747
- surface: 'tenant',
3748
- allowedActions: [
3749
- 'search.run'
3750
- ]
3751
- }
3752
- }
3753
- };
3754
- const errorResponse = await fetch(`http://127.0.0.1:${errorServer.port}/a2a/booking-copilot/turn`, {
3755
- method: 'POST',
3756
- headers: {
3757
- ...headers,
3758
- authorization: `Bearer ${label}-key`
3759
- },
3760
- body: JSON.stringify(errorTurn)
3761
- });
3762
- assert.equal(errorResponse.status, 200, `${label} keeps the committed SSE status while returning a typed error`);
3763
- const errorBody = await errorResponse.text();
3764
- assert.match(errorBody, /event: error/, `${label} never returns an empty SSE body`);
3765
- assert.match(errorBody, new RegExp(`"code":"${expectedCode}"`), `${label} normalizes to the closed uppercase code`);
3766
- assert.doesNotMatch(errorBody, /raw internal detail/, `${label} does not expose internal error text`);
3767
- assert.doesNotMatch(errorBody, /event: operation/, `${label} emits no disallowed operation`);
3768
- const actionRows = errorLedger.db.prepare("SELECT count(*) AS count FROM events WHERE kind = 'booking.copilot.v2.action.issued'").get();
3769
- assert.equal(actionRows.count, 0, `${label} has zero disallowed operation side effect`);
3770
- const afterErrorRows = errorLedger.countEvents();
3771
- assert.equal(errorPlannerCalls, 1, `${label} calls planner once before durable typed error`);
3772
- const errorReplay = await fetch(`http://127.0.0.1:${errorServer.port}/a2a/booking-copilot/turn`, {
3773
- method: 'POST',
3774
- headers: {
3775
- ...headers,
3776
- authorization: `Bearer ${label}-key`
3777
- },
3778
- body: JSON.stringify(errorTurn)
3779
- });
3780
- assert.equal(errorReplay.status, 200, `${label} terminal typed error request replays as SSE, not HTTP conflict`);
3781
- assert.equal(await errorReplay.text(), errorBody, `${label} typed error replay is byte-identical`);
3782
- assert.equal(errorLedger.countEvents(), afterErrorRows, `${label} typed error replay appends zero ledger rows`);
3783
- assert.equal(errorPlannerCalls, 1, `${label} typed error replay does not call planner again`);
3784
- const errorConflict = await fetch(`http://127.0.0.1:${errorServer.port}/a2a/booking-copilot/turn`, {
3785
- method: 'POST',
3786
- headers: {
3787
- ...headers,
3788
- authorization: `Bearer ${label}-key`
3789
- },
3790
- body: JSON.stringify({
3791
- ...errorTurn,
3792
- request: {
3793
- text: `${errorTurn.request.text} changed`
3794
- }
3795
- })
3796
- });
3797
- assert.equal(errorConflict.status, 409, `${label} same turn identity with changed body remains a conflict`);
3798
- await errorServer.close();
3799
- errorLedger.close();
3800
- rmSync(errorRoot, {
3801
- recursive: true,
3802
- force: true
3803
- });
3804
- }
3805
- const mixedRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-mixed-startup-'));
3806
- const mixedLedger = ensureLedger(mixedRoot);
3807
- const mixedRuntimeV2 = new BookingCopilotTaskRuntimeV2(mixedLedger);
3808
- const mixedServer = await startBookingCopilotServer({
3809
- apiKey: 'mixed-v2-key',
3810
- runtime: new BookingCopilotTaskRuntime(mixedLedger),
3811
- plannerFactory: ()=>({
3812
- next: async ()=>[]
3813
- }),
3814
- v2: {
3815
- runtime: mixedRuntimeV2,
3816
- plannerFactory: ()=>({
3817
- next: async ()=>[]
3818
- })
3819
- }
3820
- });
3821
- await mixedServer.close();
3822
- mixedLedger.close();
3823
- rmSync(mixedRoot, {
3824
- recursive: true,
3825
- force: true
3826
- });
3827
- const v2OnlyRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-only-'));
3828
- const v2OnlyLedger = ensureLedger(v2OnlyRoot);
3829
- const v2OnlyRuntime = new BookingCopilotTaskRuntimeV2(v2OnlyLedger);
3830
- const boundOnlyV2Server = await startBookingCopilotServer({
3831
- apiKey: 'v2-only-key',
3832
- v2: {
3833
- runtime: v2OnlyRuntime,
3834
- plannerFactory: ()=>({
3835
- next: async ()=>[]
3836
- })
3837
- }
3838
- });
3839
- await boundOnlyV2Server.close();
3840
- const v2OnlyServer = await startBookingCopilotServer({
3841
- apiKey: 'v2-only-key',
3842
- v2: {
3843
- runtime: v2OnlyRuntime,
3844
- principal: {
3845
- subject: 'bff-v2-only',
3846
- scope: 'booking:read'
3847
- },
3848
- ingressBinding: {
3849
- bind: ()=>({
3850
- taskId: 'task-v2-only',
3851
- turnId: 'turn-v2-only',
3852
- contextRef: 'ctx-v2-only',
3853
- surface: 'tenant',
3854
- allowedActions: [
3855
- ...BOOKING_READ_ACTION_KINDS_V2
3856
- ]
3857
- })
3858
- },
3859
- plannerFactory: ()=>({
3860
- next: async ()=>[]
3861
- })
3862
- }
3863
- });
3864
- const v2Health = await fetch(`http://127.0.0.1:${v2OnlyServer.port}/healthz`, {
3865
- headers: {
3866
- authorization: 'Bearer v2-only-key'
3867
- }
3868
- });
3869
- assert.equal(v2Health.status, 200);
3870
- assert.deepEqual(await v2Health.json(), {
3871
- schemaVersion: 'booking.surface.v2',
3872
- schemaSha256: BOOKING_SURFACE_SCHEMA_V2_SHA256,
3873
- supportedSchemaVersions: [
3874
- 'booking.surface.v2'
3875
- ],
3876
- status: 'ready',
3877
- ingressMode: 'bff-ingress-binding',
3878
- acceptedTurnKinds: [
3879
- 'user.turn',
3880
- 'action.receipt.continuation',
3881
- 'user.turn.ingress'
3882
- ]
3883
- }, 'v2-only health reports the complete trusted ingress mode');
3884
- await v2OnlyServer.close();
3885
- v2OnlyLedger.close();
3886
- rmSync(v2OnlyRoot, {
3887
- recursive: true,
3888
- force: true
3889
- });
3890
- runtime['ledger'].close();
3891
- awaitingRestart['ledger'].close();
3892
- reopened['ledger'].close();
3893
- serverRuntime['ledger'].close();
3894
- crashLedger.close();
3895
- reverseLedger.close();
3896
- terminalLedger.close();
3897
- forgedSourceRuntime['ledger'].close();
3898
- tamperLedger.close();
3899
- offerRuntime['ledger'].close();
3900
- rmSync(stateRoot, {
3901
- recursive: true,
3902
- force: true
3903
- });
3904
- rmSync(serverRoot, {
3905
- recursive: true,
3906
- force: true
3907
- });
3908
- rmSync(forgedSourceRoot, {
3909
- recursive: true,
3910
- force: true
3911
- });
3912
- rmSync(tamperRoot, {
3913
- recursive: true,
3914
- force: true
3915
- });
3916
- rmSync(crashRoot, {
3917
- recursive: true,
3918
- force: true
3919
- });
3920
- rmSync(reverseRoot, {
3921
- recursive: true,
3922
- force: true
3923
- });
3924
- rmSync(terminalRoot, {
3925
- recursive: true,
3926
- force: true
3927
- });
3928
- rmSync(offerRoot, {
3929
- recursive: true,
3930
- force: true
3931
- });
3932
- console.log('BOOKING COPILOT V2 RUNTIME PROOF: task scope, receipt binding, approval authority/one-time, recovery, ingress/SSE session OK');
3933
-
3934
-
3935
- //# sourceURL=ts/scripts/booking-copilot-v2-runtime-proof-tests.ts