@danceiny/gotry 0.0.1-rc.9 → 0.2.0-rc.28

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 (533) hide show
  1. package/README.md +172 -219
  2. package/README.zh-CN.md +222 -0
  3. package/bin/gotry-backend.js +53 -0
  4. package/bin/gotry-booking-copilot.js +53 -0
  5. package/bin/gotry-bootstrap.js +1788 -0
  6. package/bin/gotry-inner.js +733 -83
  7. package/bin/gotry-process-liveness.d.ts +27 -0
  8. package/bin/gotry-process-liveness.js +97 -0
  9. package/bin/gotry-runtime-resolution.d.ts +27 -0
  10. package/bin/gotry-runtime-resolution.js +49 -0
  11. package/bin/gotry.js +1 -1
  12. package/client/client.js +203 -0
  13. package/cordis.gotry-patch.yml +80 -56
  14. package/data/airline-airports.json +39 -0
  15. package/data/stations-12306-verify.json +524 -0
  16. package/dist/capabilities/agent-reach-deep.js +45 -122
  17. package/dist/capabilities/agent-reach.js +101 -146
  18. package/dist/capabilities/anything.js +161 -94
  19. package/dist/capabilities/artifacts.js +370 -0
  20. package/dist/capabilities/channel-health.js +131 -0
  21. package/dist/capabilities/channel-registry.js +256 -0
  22. package/dist/capabilities/doctor.js +553 -0
  23. package/dist/capabilities/effect.js +394 -0
  24. package/dist/capabilities/external-event.js +149 -0
  25. package/dist/capabilities/fact-log.js +55 -0
  26. package/dist/capabilities/flyai-config.js +337 -0
  27. package/dist/capabilities/flyai.js +947 -110
  28. package/dist/capabilities/fx-contract.js +293 -0
  29. package/dist/capabilities/geo-atlas.js +246 -0
  30. package/dist/capabilities/ground-transfer.js +676 -0
  31. package/dist/capabilities/hbcli.js +260 -123
  32. package/dist/capabilities/hotelbyte-transaction.js +194 -0
  33. package/dist/capabilities/incident-log.js +131 -61
  34. package/dist/capabilities/itinerary-artifact.js +190 -0
  35. package/dist/capabilities/itinerary-deck-artifact.js +194 -0
  36. package/dist/capabilities/itinerary-deck-export.js +339 -0
  37. package/dist/capabilities/lavish-local.js +924 -0
  38. package/dist/capabilities/model-override.js +38 -0
  39. package/dist/capabilities/opensky.js +54 -43
  40. package/dist/capabilities/provider-detail-sanitize.js +64 -0
  41. package/dist/capabilities/resilience.js +191 -0
  42. package/dist/capabilities/route-provider-conformance.js +341 -0
  43. package/dist/capabilities/session/action-cache.js +50 -44
  44. package/dist/capabilities/session/adapters/ctrip-flight.js +120 -62
  45. package/dist/capabilities/session/adapters/ctrip-hotel.js +222 -0
  46. package/dist/capabilities/session/adapters/dida-portal.js +274 -0
  47. package/dist/capabilities/session/adapters/meituan-local.js +35 -54
  48. package/dist/capabilities/session/adapters/rail-12306.js +342 -0
  49. package/dist/capabilities/session/benchmark.js +189 -0
  50. package/dist/capabilities/session/bridge-ledger.js +224 -0
  51. package/dist/capabilities/session/extension-bridge.js +636 -0
  52. package/dist/capabilities/session/extension-channel.js +109 -0
  53. package/dist/capabilities/session/extension-distribution.js +230 -0
  54. package/dist/capabilities/session/extract.js +31 -21
  55. package/dist/capabilities/session/golden-score.js +114 -0
  56. package/dist/capabilities/session/health-watch.js +215 -0
  57. package/dist/capabilities/session/read-guard.js +75 -24
  58. package/dist/capabilities/session/static-flight-golden.js +150 -0
  59. package/dist/capabilities/session/transport.js +97 -83
  60. package/dist/capabilities/session/wizard.js +117 -0
  61. package/dist/capabilities/session-consent.js +178 -0
  62. package/dist/capabilities/session-login.js +170 -0
  63. package/dist/capabilities/session-search.js +635 -44
  64. package/dist/capabilities/spawn-bounded.js +203 -0
  65. package/dist/capabilities/sponsor-plugin.js +341 -0
  66. package/dist/capabilities/visa-policy.js +140 -0
  67. package/dist/capabilities/weather.js +243 -138
  68. package/dist/data/session-golden-20.json +25 -0
  69. package/dist/data/sf-golden-manifest.json +103 -0
  70. package/dist/data/sf-static-routes.json +91 -0
  71. package/dist/scripts/action-cache-tests.js +24 -59
  72. package/dist/scripts/agent-planning-turn-deadline-e2e.js +242 -0
  73. package/dist/scripts/agent-planning-turn-deadline-tests.js +185 -0
  74. package/dist/scripts/agent-reach-deep-tests.js +30 -40
  75. package/dist/scripts/agent-reach-error-tests.js +354 -0
  76. package/dist/scripts/agent-reach-tests.js +26 -18
  77. package/dist/scripts/agent-reach-wrapper-tests.js +50 -50
  78. package/dist/scripts/anything-path-measure-tests.js +133 -0
  79. package/dist/scripts/anything-path-measure.js +150 -0
  80. package/dist/scripts/anything-tests.js +250 -60
  81. package/dist/scripts/artifact-client-contract-tests.js +220 -0
  82. package/dist/scripts/artifacts-capability-tests.js +727 -0
  83. package/dist/scripts/async-collect.js +50 -11
  84. package/dist/scripts/benchmark-environment-bridge-e2e.js +741 -0
  85. package/dist/scripts/benchmark-environment-bridge-tests.js +1452 -0
  86. package/dist/scripts/booking-copilot-availability-ledger-binding-tests.js +126 -0
  87. package/dist/scripts/booking-copilot-availability-policy-tests.js +265 -0
  88. package/dist/scripts/booking-copilot-bin-proof-tests.js +66 -0
  89. package/dist/scripts/booking-copilot-cleanup-proof-helper.js +76 -0
  90. package/dist/scripts/booking-copilot-crossrepo-fixture-server.js +144 -0
  91. package/dist/scripts/booking-copilot-dsh-core-proof-tests.js +424 -0
  92. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +2839 -0
  93. package/dist/scripts/booking-copilot-dsh-plugin-proof-tests.js +309 -0
  94. package/dist/scripts/booking-copilot-dsh-readiness-proof.js +238 -0
  95. package/dist/scripts/booking-copilot-dsh-warmer-proof.js +126 -0
  96. package/dist/scripts/booking-copilot-event-sequence-concurrency-proof-tests.js +156 -0
  97. package/dist/scripts/booking-copilot-gap-code-contract-proof-tests.js +70 -0
  98. package/dist/scripts/booking-copilot-intent-proof-tests.js +422 -0
  99. package/dist/scripts/booking-copilot-operation-ledger-concurrency-proof-tests.js +184 -0
  100. package/dist/scripts/booking-copilot-planner-live-probe.js +95 -0
  101. package/dist/scripts/booking-copilot-proof-runtime.js +42 -0
  102. package/dist/scripts/booking-copilot-receipt-ledger-concurrency-proof-tests.js +210 -0
  103. package/dist/scripts/booking-copilot-runtime-proof-tests.js +2349 -0
  104. package/dist/scripts/booking-copilot-server-proof-tests.js +784 -0
  105. package/dist/scripts/booking-copilot-startup-proof-tests.js +94 -0
  106. package/dist/scripts/booking-executor-tests.js +80 -0
  107. package/dist/scripts/booking-recovery-chain-tests.js +182 -0
  108. package/dist/scripts/booking-saga-tests.js +125 -0
  109. package/dist/scripts/booking-surface-contract-proof-tests.js +212 -0
  110. package/dist/scripts/bootstrap-tests.js +1549 -0
  111. package/dist/scripts/bridge-events-tests.js +239 -0
  112. package/dist/scripts/bridge-ledger-tests.js +428 -0
  113. package/dist/scripts/build-changelog.js +238 -0
  114. package/dist/scripts/build-metrics-report.js +331 -0
  115. package/dist/scripts/changelog-tests.js +118 -0
  116. package/dist/scripts/channel-probe-tests.js +103 -0
  117. package/dist/scripts/channel-probe.js +169 -0
  118. package/dist/scripts/channel-registry-tests.js +208 -0
  119. package/dist/scripts/city-scenario-tier-tests.js +234 -0
  120. package/dist/scripts/companion-tests.js +25 -70
  121. package/dist/scripts/dida-cookie-probe.js +87 -0
  122. package/dist/scripts/dida-runner-stop-tests.js +311 -0
  123. package/dist/scripts/dida-sw-probe.js +114 -0
  124. package/dist/scripts/diff-test.js +17 -12
  125. package/dist/scripts/doctor-tests.js +600 -0
  126. package/dist/scripts/drill-multiuser-ledger-tests.js +1076 -0
  127. package/dist/scripts/drill-sdk-descendant-cleanup-tests.js +606 -0
  128. package/dist/scripts/drill-w2a-sensor-tests.js +788 -0
  129. package/dist/scripts/dsh-artifact-e2e.js +279 -0
  130. package/dist/scripts/dsh-artifact-web-e2e.js +675 -0
  131. package/dist/scripts/dsh-http-proxy-sse-proof.js +90 -0
  132. package/dist/scripts/dsh-llm-tests.js +437 -0
  133. package/dist/scripts/dsh-runtime-closure-tests.js +182 -0
  134. package/dist/scripts/dsh-runtime-closure.js +162 -0
  135. package/dist/scripts/dsh-session-v3-migration-proof.js +109 -0
  136. package/dist/scripts/dsh-subprocess-local-proof.js +150 -0
  137. package/dist/scripts/dsh-target-closure-proof.js +178 -0
  138. package/dist/scripts/effect-tests.js +539 -0
  139. package/dist/scripts/engine-run.js +1 -3
  140. package/dist/scripts/engine-tests.js +16 -10
  141. package/dist/scripts/evaluation-cadence-tests.js +174 -0
  142. package/dist/scripts/evaluation-contract-tests.js +268 -0
  143. package/dist/scripts/extension-distribution-cli.js +39 -0
  144. package/dist/scripts/extension-distribution-tests.js +443 -0
  145. package/dist/scripts/extension-tests.js +988 -0
  146. package/dist/scripts/external-event-tests.js +350 -0
  147. package/dist/scripts/fact-gate-tests.js +869 -0
  148. package/dist/scripts/fixtures/extension-bridge-unref-child.mjs +21 -0
  149. package/dist/scripts/flight-hotel-anchor-363-registered-e2e.js +169 -0
  150. package/dist/scripts/flight-malformed-tests.js +159 -0
  151. package/dist/scripts/flyai-setup-tests.js +228 -0
  152. package/dist/scripts/flyai-setup-tool-tests.js +54 -0
  153. package/dist/scripts/flyai-tests.js +692 -0
  154. package/dist/scripts/flyai-tool-registration-contract-tests.js +91 -0
  155. package/dist/scripts/fx-contract-tests.js +201 -0
  156. package/dist/scripts/g5-guard-tests.js +150 -0
  157. package/dist/scripts/g5-guard.js +193 -0
  158. package/dist/scripts/gen-lunar-anchors.js +106 -0
  159. package/dist/scripts/geo-atlas-tests.js +283 -0
  160. package/dist/scripts/gotry-backend-kernel-tests.js +206 -0
  161. package/dist/scripts/gotry-try-demo-tests.js +79 -0
  162. package/dist/scripts/gotry-try-demo.js +49 -0
  163. package/dist/scripts/ground-transfer-tests.js +956 -0
  164. package/dist/scripts/hbcli-e2e-tests.js +159 -0
  165. package/dist/scripts/hbcli-live-optin-tests.js +96 -0
  166. package/dist/scripts/hbcli-release-contract-tests.js +122 -0
  167. package/dist/scripts/hbcli-tests.js +121 -69
  168. package/dist/scripts/health-watch-cli.js +73 -0
  169. package/dist/scripts/hotel-date-gate-tests.js +496 -0
  170. package/dist/scripts/hotelbyte-reconcile-tests.js +141 -0
  171. package/dist/scripts/hotelbyte-spawn-e2e-tests.js +373 -0
  172. package/dist/scripts/i18n-tests.js +41 -0
  173. package/dist/scripts/incident-tests.js +281 -87
  174. package/dist/scripts/issue-194-job-id-guard-tests.js +106 -0
  175. package/dist/scripts/issue-233-cancel-refund-commission-tests.js +392 -0
  176. package/dist/scripts/issue-271-liveness-tests.js +569 -0
  177. package/dist/scripts/issue-289-model-retry-real-tests.js +296 -0
  178. package/dist/scripts/issue-289-model-retry-tests.js +441 -0
  179. package/dist/scripts/issue-289-web-retry-e2e.js +630 -0
  180. package/dist/scripts/issue-338-home-city-e2e.js +577 -0
  181. package/dist/scripts/issue343-real-entry-e2e.js +160 -0
  182. package/dist/scripts/issue436-persisted-routing-e2e.js +247 -0
  183. package/dist/scripts/itinerary-artifact-tests.js +368 -0
  184. package/dist/scripts/itinerary-deck-artifact-tests.js +416 -0
  185. package/dist/scripts/itinerary-deck-export-tests.js +552 -0
  186. package/dist/scripts/itinerary-deck-tests.js +435 -0
  187. package/dist/scripts/itinerary-html-tests.js +426 -0
  188. package/dist/scripts/journey-tests.js +22 -27
  189. package/dist/scripts/kernel-manifest-evidence.js +109 -0
  190. package/dist/scripts/kernel-manifest-gate.js +126 -0
  191. package/dist/scripts/kernel-manifest-tests.js +212 -0
  192. package/dist/scripts/kernel-manifest-trace-entry.js +7 -0
  193. package/dist/scripts/kernel-manifest-trace-hook.mjs +28 -0
  194. package/dist/scripts/kernel-manifest.js +369 -0
  195. package/dist/scripts/lavish-local-tests.js +1226 -0
  196. package/dist/scripts/lavish-product-tools-tests.js +537 -0
  197. package/dist/scripts/ledger-repair-apply-tests.js +316 -0
  198. package/dist/scripts/ledger-repair-plan-tests.js +375 -0
  199. package/dist/scripts/ledger-tests.js +382 -246
  200. package/dist/scripts/ledger-workflow-crash.js +10 -20
  201. package/dist/scripts/m3-cohort-tests.js +714 -0
  202. package/dist/scripts/m3-cohort.js +310 -0
  203. package/dist/scripts/managed-dsh-cleanup-diagnostic-proof.js +184 -0
  204. package/dist/scripts/managed-dsh-close-failure-proof.js +172 -0
  205. package/dist/scripts/managed-dsh-run-port-proof.js +153 -0
  206. package/dist/scripts/managed-dsh-terminal-proof.js +252 -0
  207. package/dist/scripts/map-tools-vendor-package-proof.js +365 -0
  208. package/dist/scripts/memory-capture-tests.js +242 -61
  209. package/dist/scripts/memory-decay-tests.js +18 -52
  210. package/dist/scripts/memory-lifecycle-tests.js +657 -0
  211. package/dist/scripts/memory-lifecycle.js +196 -0
  212. package/dist/scripts/memory-metrics.js +24 -12
  213. package/dist/scripts/memory-value-report.js +631 -0
  214. package/dist/scripts/memory-value-tests.js +328 -0
  215. package/dist/scripts/metrics-report-tests.js +151 -0
  216. package/dist/scripts/model-override-e2e.js +137 -0
  217. package/dist/scripts/nightly-evidence-tests.js +203 -0
  218. package/dist/scripts/nightly-evidence.js +303 -0
  219. package/dist/scripts/nudge-digest.js +39 -28
  220. package/dist/scripts/observability-tests.js +381 -0
  221. package/dist/scripts/onboarding-tests.js +161 -0
  222. package/dist/scripts/opensky-check.js +18 -13
  223. package/dist/scripts/opensky-tests.js +28 -28
  224. package/dist/scripts/outcome-projection-tests.js +427 -0
  225. package/dist/scripts/persona-sim-tests.js +603 -0
  226. package/dist/scripts/persona-sim.js +1081 -0
  227. package/dist/scripts/persona-surface-guard-tests.js +49 -0
  228. package/dist/scripts/pnpm-dsh-closure-proof.js +17 -0
  229. package/dist/scripts/policy-anchor-359-registered-e2e.js +214 -0
  230. package/dist/scripts/price-drift-tests.js +340 -0
  231. package/dist/scripts/price-drift-watch.js +447 -0
  232. package/dist/scripts/probe-poi-tests.js +53 -45
  233. package/dist/scripts/product-metrics.js +483 -0
  234. package/dist/scripts/publish-preverify.js +110 -0
  235. package/dist/scripts/realtime-pricing-tests.js +76 -0
  236. package/dist/scripts/recall-tests.js +513 -0
  237. package/dist/scripts/replay-async.js +22 -18
  238. package/dist/scripts/replay-real.js +18 -18
  239. package/dist/scripts/replay.js +54 -82
  240. package/dist/scripts/route-provider-conformance-tests.js +391 -0
  241. package/dist/scripts/session-attach-diagnose.js +20 -20
  242. package/dist/scripts/session-attach-poc.js +34 -37
  243. package/dist/scripts/session-benchmark.js +180 -0
  244. package/dist/scripts/session-dida-live-e2e.js +199 -0
  245. package/dist/scripts/session-extract-tests.js +14 -35
  246. package/dist/scripts/session-link-tests.js +478 -0
  247. package/dist/scripts/session-login.js +27 -46
  248. package/dist/scripts/session-tests.js +796 -96
  249. package/dist/scripts/session-zone-crash.js +49 -0
  250. package/dist/scripts/session-zone-ledger-tests.js +432 -0
  251. package/dist/scripts/session-zone-observation-tests.js +492 -0
  252. package/dist/scripts/session-zone-observe.js +135 -0
  253. package/dist/scripts/session-zone-wiring-tests.js +566 -0
  254. package/dist/scripts/session-zones-tests.js +374 -0
  255. package/dist/scripts/sf-live-benchmark.js +367 -0
  256. package/dist/scripts/sf-live-challenge-stop-tests.js +453 -0
  257. package/dist/scripts/sf-live-cli-tests.js +92 -0
  258. package/dist/scripts/sf-live-options.js +46 -0
  259. package/dist/scripts/sf-manifest-tests.js +30 -0
  260. package/dist/scripts/sf-manifest.js +33 -0
  261. package/dist/scripts/sf-soft-score-tests.js +67 -0
  262. package/dist/scripts/sf-summary-tests.js +324 -0
  263. package/dist/scripts/sf-summary.js +525 -0
  264. package/dist/scripts/share-tests.js +432 -0
  265. package/dist/scripts/skeleton-check.js +16 -14
  266. package/dist/scripts/skeleton-integration-test.js +28 -11
  267. package/dist/scripts/skills-contract-tests.js +51 -46
  268. package/dist/scripts/smoke-session-gate-tests.js +21 -0
  269. package/dist/scripts/smoke.js +690 -246
  270. package/dist/scripts/spawn-bounded-abort-tests.js +244 -0
  271. package/dist/scripts/sponsor-reuse-tests.js +262 -0
  272. package/dist/scripts/state-cli-tests.js +226 -101
  273. package/dist/scripts/state-cli.js +565 -198
  274. package/dist/scripts/state-repair-cli.js +66 -0
  275. package/dist/scripts/state-repair-tests.js +140 -0
  276. package/dist/scripts/static-golden-tests.js +165 -0
  277. package/dist/scripts/time-eval-tests.js +212 -192
  278. package/dist/scripts/travel-timeline-tests.js +29 -93
  279. package/dist/scripts/turn-handoff-collect-tests.js +169 -0
  280. package/dist/scripts/turn-handoff-collect.js +161 -0
  281. package/dist/scripts/turn-policy-tests.js +55 -0
  282. package/dist/scripts/typed-contract-canary.js +176 -0
  283. package/dist/scripts/tz-resolver-sanity.js +50 -0
  284. package/dist/scripts/tz-resolver-tests.js +584 -0
  285. package/dist/scripts/unified-tests.js +60 -20
  286. package/dist/scripts/visa-policy-tests.js +62 -0
  287. package/dist/scripts/weather-tests.js +276 -41
  288. package/dist/scripts/wish-channel-gate-tests.js +54 -0
  289. package/dist/scripts/write-gate-tests.js +522 -0
  290. package/dist/scripts/z3-lifecycle-fault-tests.js +188 -0
  291. package/dist/scripts/z3-lifecycle-tests.js +170 -0
  292. package/dist/scripts/z3-race-repeat-tests.js +39 -0
  293. package/dist/scripts/z3-race-tests.js +46 -0
  294. package/dist/src/artifact-gate.js +829 -0
  295. package/dist/src/backend/kernel.js +91 -0
  296. package/dist/src/backend/modules/booking-copilot.js +92 -0
  297. package/dist/src/backend/modules/booking-executor.js +124 -0
  298. package/dist/src/backend/modules/session-search.js +551 -0
  299. package/dist/src/benchmark-agent-conformance.js +578 -0
  300. package/dist/src/benchmark-environment-bridge.js +515 -0
  301. package/dist/src/benchmark-headless-child-diagnostics.js +166 -0
  302. package/dist/src/benchmark-tool-isolation.js +151 -0
  303. package/dist/src/bookable-facts.js +533 -0
  304. package/dist/src/booking-saga.js +97 -0
  305. package/dist/src/booking-surface/availability-policy.js +697 -0
  306. package/dist/src/booking-surface/booking-intent.js +43 -0
  307. package/dist/src/booking-surface/cancel-refund-commission.js +379 -0
  308. package/dist/src/booking-surface/canonical-schema.js +116 -0
  309. package/dist/src/booking-surface/contracts.js +75 -0
  310. package/dist/src/booking-surface/dsh-planner.js +1854 -0
  311. package/dist/src/booking-surface/dsh-plugin.js +378 -0
  312. package/dist/src/booking-surface/dsh-worker.js +98 -0
  313. package/dist/src/booking-surface/error-codes.js +231 -0
  314. package/dist/src/booking-surface/index.js +9 -0
  315. package/dist/src/booking-surface/managed-dsh-cleanup-diagnostic.js +97 -0
  316. package/dist/src/booking-surface/managed-dsh-run-port.js +244 -0
  317. package/dist/src/booking-surface/profile.js +24 -0
  318. package/dist/src/booking-surface/recovery-chain.js +367 -0
  319. package/dist/src/booking-surface/runtime.js +1968 -0
  320. package/dist/src/booking-surface/server.js +674 -0
  321. package/dist/src/booking-surface/startup.js +168 -0
  322. package/dist/src/booking-surface/validation.js +261 -0
  323. package/dist/src/bridge.js +20 -13
  324. package/dist/src/city-scenario-tier.js +378 -0
  325. package/dist/src/companions.js +48 -74
  326. package/dist/src/contracts.js +21 -43
  327. package/dist/src/dsh-llm.js +171 -115
  328. package/dist/src/engine.js +276 -193
  329. package/dist/src/evaluation-cadence.js +97 -0
  330. package/dist/src/evaluation-contracts.js +283 -0
  331. package/dist/src/flight-pack-adapter.js +54 -0
  332. package/dist/src/flight-pack-contract.js +2 -0
  333. package/dist/src/flyai-setup-tool.js +70 -0
  334. package/dist/src/gotry-backend.js +125 -0
  335. package/dist/src/hotel-date-gate.js +118 -0
  336. package/dist/src/i18n.js +88 -0
  337. package/dist/src/index.js +2385 -881
  338. package/dist/src/itinerary-deck.js +139 -0
  339. package/dist/src/itinerary-doc-shared.js +661 -0
  340. package/dist/src/itinerary-html.js +131 -0
  341. package/dist/src/journey.js +155 -108
  342. package/dist/src/lavish-tools.js +400 -0
  343. package/dist/src/ledger-repair-apply.js +538 -0
  344. package/dist/src/ledger-repair-plan.js +463 -0
  345. package/dist/src/loop.js +331 -161
  346. package/dist/src/m3-cohort.js +1577 -0
  347. package/dist/src/memory-capture.js +180 -32
  348. package/dist/src/memory-decay.js +30 -14
  349. package/dist/src/memory-lifecycle.js +1310 -0
  350. package/dist/src/memory-utility.js +27 -40
  351. package/dist/src/mock-llm.js +36 -61
  352. package/dist/src/model.js +81 -36
  353. package/dist/src/outcome-projection.js +746 -0
  354. package/dist/src/realtime-pricing.js +103 -0
  355. package/dist/src/recall/card.js +74 -0
  356. package/dist/src/recall/evaluator.js +140 -0
  357. package/dist/src/recall/integration.js +17 -0
  358. package/dist/src/recall/tick.js +166 -0
  359. package/dist/src/session-link/plan-it-action.js +48 -0
  360. package/dist/src/session-link/session-link.js +248 -0
  361. package/dist/src/session-zone-ledger.js +312 -0
  362. package/dist/src/session-zone-observation.js +354 -0
  363. package/dist/src/session-zone-wiring.js +658 -0
  364. package/dist/src/session-zones.js +510 -0
  365. package/dist/src/share/adapters.js +51 -0
  366. package/dist/src/share/share-consent.js +76 -0
  367. package/dist/src/share/share-deck.js +58 -0
  368. package/dist/src/share/share-token.js +146 -0
  369. package/dist/src/slot-spec.js +84 -106
  370. package/dist/src/state-ledger.js +696 -524
  371. package/dist/src/state-repair.js +299 -0
  372. package/dist/src/subagent-job-id-guard.js +58 -0
  373. package/dist/src/time-anchor.js +231 -33
  374. package/dist/src/tool-packet.js +20 -8
  375. package/dist/src/travel-slots.js +58 -35
  376. package/dist/src/travel-timeline.js +44 -63
  377. package/dist/src/turn-deadline.js +287 -0
  378. package/dist/src/turn-policy.js +118 -0
  379. package/dist/src/tz-resolver.js +143 -0
  380. package/dist/src/unified.js +600 -360
  381. package/dist/src/wish-pool.js +42 -12
  382. package/dist/src/write-gate.js +495 -0
  383. package/dist/src/z3-shared.js +284 -0
  384. package/extension/README.md +21 -0
  385. package/extension/README.zh-CN.md +21 -0
  386. package/extension/background.js +463 -0
  387. package/extension/content-bridge.js +270 -0
  388. package/extension/content-main.js +111 -0
  389. package/extension/manifest.json +60 -0
  390. package/package.json +407 -14
  391. package/schemas/booking.intent.schema.json +193 -0
  392. package/schemas/booking.surface.schema.json +2622 -0
  393. package/ts/capabilities/agent-reach-deep.ts +114 -0
  394. package/ts/capabilities/agent-reach.ts +198 -0
  395. package/ts/capabilities/anything.ts +282 -0
  396. package/ts/capabilities/artifacts.ts +428 -0
  397. package/ts/capabilities/fact-log.ts +54 -0
  398. package/ts/capabilities/flyai-config.ts +388 -0
  399. package/ts/capabilities/flyai.ts +1274 -0
  400. package/ts/capabilities/hbcli.ts +253 -71
  401. package/ts/capabilities/incident-log.ts +88 -59
  402. package/ts/capabilities/itinerary-artifact.ts +214 -0
  403. package/ts/capabilities/itinerary-deck-artifact.ts +218 -0
  404. package/ts/capabilities/itinerary-deck-export.ts +378 -0
  405. package/ts/capabilities/opensky.ts +121 -0
  406. package/ts/capabilities/session/action-cache.ts +148 -0
  407. package/ts/capabilities/session/adapters/ctrip-flight.ts +181 -0
  408. package/ts/capabilities/session/adapters/ctrip-hotel.ts +238 -0
  409. package/ts/capabilities/session/adapters/dida-portal.ts +338 -0
  410. package/ts/capabilities/session/adapters/meituan-local.ts +70 -0
  411. package/ts/capabilities/session/adapters/rail-12306.ts +392 -0
  412. package/ts/capabilities/session/benchmark.ts +276 -0
  413. package/ts/capabilities/session/bridge-ledger.ts +333 -0
  414. package/ts/capabilities/session/extension-bridge.ts +794 -0
  415. package/ts/capabilities/session/extension-channel.ts +144 -0
  416. package/ts/capabilities/session/extension-distribution.ts +266 -0
  417. package/ts/capabilities/session/extract.ts +54 -0
  418. package/ts/capabilities/session/golden-score.ts +139 -0
  419. package/ts/capabilities/session/health-watch.ts +264 -0
  420. package/ts/capabilities/session/read-guard.ts +141 -0
  421. package/ts/capabilities/session/static-flight-golden.ts +209 -0
  422. package/ts/capabilities/session/transport.ts +146 -0
  423. package/ts/capabilities/session/wizard.ts +140 -0
  424. package/ts/capabilities/session-consent.ts +211 -0
  425. package/ts/capabilities/session-login.ts +208 -0
  426. package/ts/capabilities/session-search.ts +893 -0
  427. package/ts/capabilities/weather.ts +330 -0
  428. package/ts/dsh-runtime/vendor/README.md +68 -0
  429. package/ts/dsh-runtime/vendor/dsh-map-tools/LICENSE +21 -0
  430. package/ts/dsh-runtime/vendor/dsh-map-tools/README.en.md +405 -0
  431. package/ts/dsh-runtime/vendor/dsh-map-tools/README.md +404 -0
  432. package/ts/dsh-runtime/vendor/dsh-map-tools/client/client.js +1584 -0
  433. package/ts/dsh-runtime/vendor/dsh-map-tools/cordis.patch.yml +5 -0
  434. package/ts/dsh-runtime/vendor/dsh-map-tools/icon.svg +5 -0
  435. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/amap.js +595 -0
  436. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/nominatim.js +63 -0
  437. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/osrm.js +58 -0
  438. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/photon.js +55 -0
  439. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/config-file.js +87 -0
  440. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/config-route.js +131 -0
  441. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/config.js +16 -0
  442. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/geo.js +232 -0
  443. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/index.js +120 -0
  444. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/settings-ns.js +40 -0
  445. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/staticmap-route.js +115 -0
  446. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/tools/geocode.js +127 -0
  447. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/tools/poi.js +84 -0
  448. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/tools/routes.js +267 -0
  449. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/amap.d.ts +90 -0
  450. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/nominatim.d.ts +17 -0
  451. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/osrm.d.ts +15 -0
  452. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/photon.d.ts +22 -0
  453. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/config-file.d.ts +33 -0
  454. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/config-route.d.ts +23 -0
  455. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/config.d.ts +23 -0
  456. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/geo.d.ts +61 -0
  457. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/index.d.ts +18 -0
  458. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/settings-ns.d.ts +14 -0
  459. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/staticmap-route.d.ts +31 -0
  460. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/tools/geocode.d.ts +11 -0
  461. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/tools/poi.d.ts +9 -0
  462. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/tools/routes.d.ts +75 -0
  463. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/types.d.ts +68 -0
  464. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types.js +16 -0
  465. package/ts/dsh-runtime/vendor/dsh-map-tools/package.json +97 -0
  466. package/ts/package.json +293 -3
  467. package/ts/scripts/async-collect.ts +91 -0
  468. package/ts/scripts/map-tools-vendor-package-proof.ts +404 -0
  469. package/ts/scripts/skeleton-check.ts +1 -1
  470. package/ts/scripts/skeleton-integration-test.ts +23 -1
  471. package/ts/scripts/state-cli.ts +586 -0
  472. package/ts/scripts/turn-handoff-collect.ts +178 -0
  473. package/ts/src/artifact-gate.ts +991 -0
  474. package/ts/src/benchmark-agent-conformance.ts +619 -0
  475. package/ts/src/benchmark-environment-bridge.ts +538 -0
  476. package/ts/src/benchmark-headless-child-diagnostics.ts +184 -0
  477. package/ts/src/benchmark-tool-isolation.ts +166 -0
  478. package/ts/src/bookable-facts.ts +837 -0
  479. package/ts/src/booking-saga.ts +133 -0
  480. package/ts/src/booking-surface/availability-policy.ts +522 -0
  481. package/ts/src/booking-surface/booking-intent.ts +86 -0
  482. package/ts/src/booking-surface/cancel-refund-commission.ts +488 -0
  483. package/ts/src/booking-surface/canonical-schema.js +116 -0
  484. package/ts/src/booking-surface/contracts.ts +475 -0
  485. package/ts/src/booking-surface/dsh-planner.ts +1949 -0
  486. package/ts/src/booking-surface/dsh-plugin.js +378 -0
  487. package/ts/src/booking-surface/dsh-worker.ts +110 -0
  488. package/ts/src/booking-surface/error-codes.ts +241 -0
  489. package/ts/src/booking-surface/index.ts +9 -0
  490. package/ts/src/booking-surface/managed-dsh-cleanup-diagnostic.ts +151 -0
  491. package/ts/src/booking-surface/managed-dsh-run-port.ts +273 -0
  492. package/ts/src/booking-surface/profile.ts +42 -0
  493. package/ts/src/booking-surface/recovery-chain.ts +409 -0
  494. package/ts/src/booking-surface/runtime.ts +1793 -0
  495. package/ts/src/booking-surface/server.ts +733 -0
  496. package/ts/src/booking-surface/startup.ts +201 -0
  497. package/ts/src/booking-surface/validation.ts +217 -0
  498. package/ts/src/companions.ts +113 -0
  499. package/ts/src/contracts.ts +17 -3
  500. package/ts/src/dsh-llm.ts +152 -17
  501. package/ts/src/flyai-setup-tool.ts +77 -0
  502. package/ts/src/index.ts +2123 -200
  503. package/ts/src/itinerary-deck.ts +189 -0
  504. package/ts/src/itinerary-doc-shared.ts +741 -0
  505. package/ts/src/itinerary-html.ts +171 -0
  506. package/ts/src/loop.ts +234 -26
  507. package/ts/src/memory-capture.ts +283 -0
  508. package/ts/src/memory-decay.ts +55 -0
  509. package/ts/src/memory-utility.ts +70 -0
  510. package/ts/src/mock-llm.ts +4 -8
  511. package/ts/src/model.ts +88 -2
  512. package/ts/src/recall/card.ts +106 -0
  513. package/ts/src/recall/evaluator.ts +170 -0
  514. package/ts/src/recall/integration.ts +35 -0
  515. package/ts/src/recall/tick.ts +213 -0
  516. package/ts/src/session-link/plan-it-action.ts +78 -0
  517. package/ts/src/session-link/session-link.ts +275 -0
  518. package/ts/src/share/adapters.ts +93 -0
  519. package/ts/src/share/share-consent.ts +105 -0
  520. package/ts/src/share/share-deck.ts +85 -0
  521. package/ts/src/share/share-token.ts +166 -0
  522. package/ts/src/slot-spec.ts +172 -0
  523. package/ts/src/state-ledger.ts +1166 -0
  524. package/ts/src/subagent-job-id-guard.ts +72 -0
  525. package/ts/src/time-anchor.ts +338 -0
  526. package/ts/src/tool-packet.ts +32 -0
  527. package/ts/src/travel-slots.ts +219 -0
  528. package/ts/src/travel-timeline.ts +83 -0
  529. package/ts/src/turn-deadline.ts +366 -0
  530. package/ts/src/turn-policy.ts +154 -0
  531. package/ts/src/unified.ts +444 -105
  532. package/ts/src/wish-pool.ts +79 -0
  533. package/dist/scripts/session-attach-wait.js +0 -41
@@ -0,0 +1,2839 @@
1
+ /**
2
+ * Embedded Booking planner adapter proof.
3
+ *
4
+ * The fake below stops at the DeepSeek Harness SDK event boundary: planner
5
+ * decisions come only from one typed dsh tool/call paired with one successful
6
+ * tool/result event. Assistant finalResponse/prose is diagnostic text only and
7
+ * never an authority source. A separate core proof boots the real dsh SDK
8
+ * runtime.
9
+ */
10
+ import assert from 'node:assert/strict';
11
+ import { createHash } from 'node:crypto';
12
+ import { mkdtempSync, rmSync } from 'node:fs';
13
+ import { tmpdir } from 'node:os';
14
+ import { join } from 'node:path';
15
+ import { ensureLedger } from '../src/state-ledger.js';
16
+ import { bookingDigest, BookingCopilotTaskRuntime } from '../src/booking-surface/runtime.js';
17
+ import { BOOKING_INTENT_SCHEMA_VERSION } from '../src/booking-surface/booking-intent.js';
18
+ import { DSH_EMBEDDED_BOOKING_TOOL_NAMES, buildDshEmbeddedBookingPatch, buildDshPlannerEnvironment, createDshEmbeddedBookingPlanner, formatUtcOffsetLabel, } from '../src/booking-surface/dsh-planner.js';
19
+ import { normalizeBookingErrorCode, safeBookingErrorMessage } from '../src/booking-surface/error-codes.js';
20
+ const availability = { initialized: true, recoveryStarted: false, availabilityPhase: 'need_offers', activeHotelOrdinal: 0, hotelRefs: [], hotels: {}, attempts: [], queryReservations: [] };
21
+ const task = {
22
+ schemaVersion: 'booking.surface',
23
+ taskId: 'task-dsh-1',
24
+ contextRef: 'ctx-dsh-1',
25
+ surface: 'tenant',
26
+ revision: 0,
27
+ allowedActions: ['search.patch', 'search.run'],
28
+ userTurnCount: 1,
29
+ lastTurnId: 'dsh-turn-1',
30
+ operationCount: 0,
31
+ phase: 'planning',
32
+ lastSequence: 0,
33
+ availability,
34
+ };
35
+ const workspace = {
36
+ schemaVersion: 'booking.surface',
37
+ contextRef: task.contextRef,
38
+ surface: 'tenant',
39
+ revision: 0,
40
+ locale: 'zh-CN',
41
+ currency: 'AED',
42
+ searchDraft: {},
43
+ results: { status: 'idle' },
44
+ visibleHotels: [],
45
+ loadedOffers: [],
46
+ shortlistedOfferRefs: [],
47
+ capabilities: { surface: 'tenant', allowedActions: ['search.patch', 'search.run'] },
48
+ };
49
+ task.workspaceSnapshot = workspace;
50
+ const searchRun = {
51
+ schemaVersion: 'booking.surface',
52
+ kind: 'search.run',
53
+ actionId: 'action-dsh-1',
54
+ contextRef: task.contextRef,
55
+ expectedRevision: 0,
56
+ reason: 'Run the authoritative workspace search.',
57
+ factRefs: [],
58
+ input: {},
59
+ };
60
+ const hotelSelect = { ...searchRun, kind: 'hotel.select', actionId: 'action-dsh-select-1', reason: 'Select the requested hotel.', input: { hotelRef: 'hotel-1' } };
61
+ const checkoutPrepare = { ...searchRun, kind: 'checkout.prepare', actionId: 'action-dsh-checkout-1', expectedRevision: 1, reason: 'Prepare checkout for the verified offer.', input: { offerRef: 'offer-confirmed', offerVersionRef: 'offer-confirmed:v1', verifiedOfferRef: 'verified-offer-confirmed' } };
62
+ function toolCall(name, argumentsText, callId) {
63
+ return { type: 'tool/call', data: { name, callId, arguments: argumentsText } };
64
+ }
65
+ function toolResult(callId, isError = false, errorCode, toolCallId = callId, sourceKind = 'tool') {
66
+ // 0.2.0-rc.2 线型:toolCallId/isError 在 message 顶层,content 是单个文本载荷块。
67
+ return {
68
+ type: 'tool/result',
69
+ data: {
70
+ message: {
71
+ source: { kind: sourceKind, callId },
72
+ toolCallId,
73
+ content: [{ type: 'text', text: isError ? 'Error: fixture tool failure' : 'Typed booking decision accepted (operation). End this turn.' }],
74
+ isError,
75
+ },
76
+ ...(errorCode ? { error: { name: 'ToolArgsError', code: errorCode } } : {}),
77
+ },
78
+ };
79
+ }
80
+ function successfulToolEvents(name, argumentsText, callId) {
81
+ return [toolCall(name, argumentsText, callId), toolResult(callId)];
82
+ }
83
+ function promptPayload(prompt) {
84
+ return JSON.parse(prompt.split('\n').at(-1));
85
+ }
86
+ function toolArgumentsPort(argumentsText) {
87
+ return {
88
+ async run() {
89
+ return {
90
+ finalResponse: '',
91
+ events: successfulToolEvents('booking_search_hotels', argumentsText, 'call-arguments'),
92
+ };
93
+ },
94
+ async close() { },
95
+ };
96
+ }
97
+ async function runToolArgumentsCase(argumentsText, turnId) {
98
+ const planner = await createDshEmbeddedBookingPlanner({ runPort: toolArgumentsPort(argumentsText) });
99
+ try {
100
+ return await planner.plannerFactory(task).next({
101
+ task,
102
+ turn: {
103
+ schemaVersion: 'booking.surface',
104
+ kind: 'user.turn',
105
+ taskId: task.taskId,
106
+ turnId,
107
+ workspace,
108
+ request: { text: 'Find hotels' },
109
+ },
110
+ });
111
+ }
112
+ finally {
113
+ await planner.close();
114
+ }
115
+ }
116
+ // A fresh order-observe turn may bind only to a BFF-injected observable order
117
+ // projection. The model still supplies the lookup key, but neither that key
118
+ // nor its evidence can come from prose or an untrusted ingress snapshot.
119
+ const freshOrderWorkspace = {
120
+ ...workspace,
121
+ contextRef: 'ctx-dsh-fresh-order',
122
+ capabilities: { surface: 'tenant', allowedActions: ['order.observe'] },
123
+ observableOrders: [{ orderRef: 'order-fresh-1', factRefs: ['order:state:fresh-1'] }],
124
+ };
125
+ const freshOrderTask = {
126
+ ...task,
127
+ taskId: 'task-dsh-fresh-order',
128
+ contextRef: freshOrderWorkspace.contextRef,
129
+ lastTurnId: 'dsh-turn-fresh-order',
130
+ allowedActions: ['order.observe'],
131
+ workspaceSnapshot: freshOrderWorkspace,
132
+ };
133
+ const freshOrderPlanner = await createDshEmbeddedBookingPlanner({
134
+ runPort: {
135
+ async run() {
136
+ return {
137
+ finalResponse: '',
138
+ events: successfulToolEvents('booking_observe_booking', JSON.stringify({ kind: 'order.observe', input: { orderRef: 'order-fresh-1' }, intent: { target: 'order.observed' } }), 'call-fresh-order'),
139
+ };
140
+ },
141
+ async close() { },
142
+ },
143
+ });
144
+ const freshOrderDecisions = await freshOrderPlanner.plannerFactory(freshOrderTask).next({
145
+ task: freshOrderTask,
146
+ turn: {
147
+ schemaVersion: 'booking.surface', kind: 'user.turn', taskId: freshOrderTask.taskId, turnId: freshOrderTask.lastTurnId,
148
+ workspace: freshOrderWorkspace, request: { text: '查看刚创建订单状态' },
149
+ },
150
+ });
151
+ assertRuntimeMaterializedOperation(freshOrderDecisions, 'order.observe', { orderRef: 'order-fresh-1' }, freshOrderTask, 'fresh order observe binds to the BFF-authoritative observable order projection');
152
+ assert.deepEqual(freshOrderDecisions[0]?.action?.factRefs, ['order:state:fresh-1'], 'fresh order observe carries only binding-supplied order evidence');
153
+ await freshOrderPlanner.close();
154
+ const forgedOrderPlanner = await createDshEmbeddedBookingPlanner({
155
+ runPort: {
156
+ async run() {
157
+ return {
158
+ finalResponse: '',
159
+ events: successfulToolEvents('booking_observe_booking', JSON.stringify({ kind: 'order.observe', input: { orderRef: 'order-forged' }, intent: { target: 'order.observed' } }), 'call-forged-order'),
160
+ };
161
+ },
162
+ async close() { },
163
+ },
164
+ });
165
+ await assert.rejects(forgedOrderPlanner.plannerFactory(freshOrderTask).next({
166
+ task: freshOrderTask,
167
+ turn: {
168
+ schemaVersion: 'booking.surface', kind: 'user.turn', taskId: freshOrderTask.taskId, turnId: 'dsh-turn-forged-order',
169
+ workspace: freshOrderWorkspace, request: { text: '查看订单状态' },
170
+ },
171
+ }), /planner_order_ref_unbound/, 'fresh order observe rejects an order ref absent from the trusted projection');
172
+ await forgedOrderPlanner.close();
173
+ // The trusted order projection must flow through an actual operation and its
174
+ // matching receipt before terminalization. Only a verified order outcome is a
175
+ // successful observation; pending, unknown, and failed remain stopped while
176
+ // retaining their state-specific summaries. Even verified receipts with a
177
+ // partial result or a gap cannot claim completion.
178
+ if (freshOrderDecisions[0]?.kind !== 'operation')
179
+ throw new Error('fresh order matrix did not receive an operation');
180
+ const orderMatrixIntentBase = {
181
+ taskId: 'task-dsh-order-state-matrix', contextRef: freshOrderWorkspace.contextRef,
182
+ sourceTurnId: freshOrderTask.lastTurnId, sourceRequestDigest: bookingDigest('observe existing order'),
183
+ projection: { schemaVersion: BOOKING_INTENT_SCHEMA_VERSION, target: 'order.observed' },
184
+ };
185
+ const orderMatrixIntent = { ...orderMatrixIntentBase, intentDigest: bookingDigest(orderMatrixIntentBase) };
186
+ const orderMatrixCases = [
187
+ { state: 'pending', outcome: 'complete', hardCriteriaMet: true, gapCodes: [], expectedStatus: 'stopped', expectedSummary: 'order_pending' },
188
+ { state: 'unknown', outcome: 'complete', hardCriteriaMet: true, gapCodes: [], expectedStatus: 'stopped', expectedSummary: 'order_unknown' },
189
+ { state: 'failed', outcome: 'complete', hardCriteriaMet: true, gapCodes: [], expectedStatus: 'stopped', expectedSummary: 'order_failed' },
190
+ { state: 'verified', outcome: 'complete', hardCriteriaMet: true, gapCodes: [], expectedStatus: 'completed', expectedSummary: 'order_verified' },
191
+ { state: 'verified', outcome: 'partial', hardCriteriaMet: true, gapCodes: [], expectedStatus: 'stopped', expectedSummary: 'order_verified' },
192
+ { state: 'verified', outcome: 'complete', hardCriteriaMet: true, gapCodes: ['order_outcome_not_observed'], expectedStatus: 'stopped', expectedSummary: 'booking_constraints_unmet' },
193
+ ];
194
+ for (const [index, matrixCase] of orderMatrixCases.entries()) {
195
+ const matrixTaskId = `task-dsh-order-state-${matrixCase.state}-${index}`;
196
+ const matrixRoot = mkdtempSync(join(tmpdir(), `gotry-booking-order-state-${matrixCase.state}-${index}-`));
197
+ const matrixLedger = ensureLedger(matrixRoot);
198
+ const matrixRuntime = new BookingCopilotTaskRuntime(matrixLedger, { contextRefFactory: () => freshOrderWorkspace.contextRef });
199
+ const action = { ...freshOrderDecisions[0].action, actionId: `order-matrix-${matrixCase.state}-${index}`, factRefs: ['order:state:fresh-1'] };
200
+ const receipt = {
201
+ schemaVersion: 'booking.surface', kind: 'action.receipt', actionId: action.actionId,
202
+ contextRef: freshOrderWorkspace.contextRef, status: 'applied', revision: 1,
203
+ observation: { kind: 'order.state', orderRef: 'order-fresh-1', state: matrixCase.state, ...(matrixCase.gapCodes.length ? { gapCodes: matrixCase.gapCodes } : {}) },
204
+ resultContract: { outcome: matrixCase.outcome, hardCriteriaMet: matrixCase.hardCriteriaMet, factRefs: ['order:state:fresh-1'], gapCodes: matrixCase.gapCodes, blockers: [], relaxationsApplied: [] },
205
+ };
206
+ matrixRuntime.startTask({
207
+ schemaVersion: 'booking.surface', kind: 'user.turn', taskId: matrixTaskId, turnId: `${matrixTaskId}-turn`,
208
+ workspace: freshOrderWorkspace, request: { text: '查看已有订单状态' },
209
+ });
210
+ matrixRuntime.issueOperation(matrixTaskId, action, orderMatrixIntent.projection);
211
+ const matrixState = matrixRuntime.continueWithReceipt({
212
+ schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: matrixTaskId,
213
+ workspace: { ...freshOrderWorkspace, revision: 1 }, receipt,
214
+ });
215
+ const [terminal] = await runTerminalContinuation(matrixState, receipt);
216
+ assert.equal(terminal?.kind, 'terminal', `order ${matrixCase.state} emits a typed terminal`);
217
+ if (terminal?.kind === 'terminal') {
218
+ assert.equal(terminal.terminal.status, matrixCase.expectedStatus, `order ${matrixCase.state} status is state-authoritative`);
219
+ assert.equal(terminal.terminal.summary, matrixCase.expectedSummary, `order ${matrixCase.state} summary is preserved`);
220
+ }
221
+ matrixLedger.close();
222
+ rmSync(matrixRoot, { recursive: true, force: true });
223
+ }
224
+ function assertRuntimeMaterializedOperation(decisions, kind, input, expectedTask = task, message = 'planner materializes one runtime-owned operation') {
225
+ assert.equal(decisions.length, 1, message);
226
+ assert.equal(decisions[0]?.kind, 'operation', message);
227
+ assert.equal(decisions[0]?.action?.kind, kind, message);
228
+ assert.deepEqual(decisions[0]?.action?.input, input, message);
229
+ assert.equal(decisions[0]?.action?.contextRef, expectedTask.contextRef, message);
230
+ assert.equal(decisions[0]?.action?.expectedRevision, expectedTask.revision, message);
231
+ assert.match(decisions[0]?.action?.actionId, /^planner-[a-f0-9]{24}$/, message);
232
+ }
233
+ function completedCheckpoint(action, sourceTurnId) {
234
+ const base = {
235
+ actionId: action.actionId,
236
+ kind: action.kind,
237
+ contextRef: action.contextRef,
238
+ expectedRevision: action.expectedRevision,
239
+ factRefs: [...action.factRefs],
240
+ input: structuredClone(action.input),
241
+ reasonDigest: bookingDigest(action.reason),
242
+ inputDigest: bookingDigest(action.input),
243
+ eventId: `operation-${action.actionId}`,
244
+ sequence: 1,
245
+ emittedAt: '2026-08-30T12:00:00.000Z',
246
+ sourceTurnId,
247
+ };
248
+ return { ...base, actionDigest: bookingDigest(base) };
249
+ }
250
+ async function runTerminalContinuation(task, receipt) {
251
+ const planner = await createDshEmbeddedBookingPlanner({
252
+ runPort: {
253
+ async run() {
254
+ return { finalResponse: '', events: successfulToolEvents('booking_search_hotels', JSON.stringify({ kind: 'terminal' }), `call-terminal-${task.taskId}`) };
255
+ },
256
+ async close() { },
257
+ },
258
+ });
259
+ try {
260
+ return await planner.plannerFactory(task).next({
261
+ task,
262
+ turn: {
263
+ schemaVersion: 'booking.surface',
264
+ kind: 'action.receipt.continuation',
265
+ taskId: task.taskId,
266
+ workspace: task.workspaceSnapshot,
267
+ receipt,
268
+ },
269
+ });
270
+ }
271
+ finally {
272
+ await planner.close();
273
+ }
274
+ }
275
+ // Exercise the real runtime -> planner receipt continuation path. The
276
+ // planner may terminalize only after the runtime has accepted a workspace
277
+ // whose shortlist is a subset of loadedOffers; a forged shortlist is rejected
278
+ // at the continuation boundary before it can become terminal evidence.
279
+ const shortlistProofRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-shortlist-proof-'));
280
+ const shortlistProofRuntime = new BookingCopilotTaskRuntime(ensureLedger(shortlistProofRoot), { contextRefFactory: () => 'ctx-dsh-shortlist' });
281
+ const shortlistWorkspace0 = {
282
+ ...workspace,
283
+ contextRef: 'ctx-dsh-shortlist',
284
+ visibleHotels: [{ hotelRef: 'hotel-shortlist', name: 'Shortlist Hotel', factRefs: [] }],
285
+ loadedOffers: [
286
+ { offerRef: 'offer-shortlist-a', offerVersionRef: 'offer-shortlist-a:v1', hotelRef: 'hotel-shortlist', evidenceLevel: 'rate_loaded', factRefs: [] },
287
+ { offerRef: 'offer-shortlist-b', offerVersionRef: 'offer-shortlist-b:v1', hotelRef: 'hotel-shortlist', evidenceLevel: 'rate_loaded', factRefs: [] },
288
+ ],
289
+ shortlistedOfferRefs: ['offer-shortlist-a', 'offer-shortlist-b'],
290
+ capabilities: { surface: 'tenant', allowedActions: ['offer.select'] },
291
+ };
292
+ const shortlistTurn = {
293
+ schemaVersion: 'booking.surface', kind: 'user.turn', taskId: 'task-dsh-shortlist', turnId: 'dsh-turn-shortlist',
294
+ workspace: shortlistWorkspace0, request: { text: 'select the first offer' },
295
+ };
296
+ const shortlistTask = shortlistProofRuntime.startTask(shortlistTurn);
297
+ const shortlistAction = {
298
+ ...searchRun, kind: 'offer.select', actionId: 'action-dsh-shortlist', contextRef: 'ctx-dsh-shortlist',
299
+ expectedRevision: 0, factRefs: [], input: { offerRef: 'offer-shortlist-a', offerVersionRef: 'offer-shortlist-a:v1' },
300
+ };
301
+ const shortlistIntent = { schemaVersion: BOOKING_INTENT_SCHEMA_VERSION, target: 'offer.selected', offerCriteria: { targetCount: 2 } };
302
+ shortlistProofRuntime.issueOperation(shortlistTask.taskId, shortlistAction, shortlistIntent);
303
+ const shortlistWorkspace1 = { ...shortlistWorkspace0, revision: 1, selectedOfferRef: 'offer-shortlist-a' };
304
+ const shortlistReceipt = {
305
+ schemaVersion: 'booking.surface', kind: 'action.receipt', actionId: shortlistAction.actionId, contextRef: 'ctx-dsh-shortlist', status: 'applied', revision: 1,
306
+ observation: { kind: 'offer.selection', offerRef: 'offer-shortlist-a', offerVersionRef: 'offer-shortlist-a:v1' },
307
+ resultContract: { outcome: 'complete', hardCriteriaMet: true, factRefs: [], gapCodes: [], blockers: [], relaxationsApplied: [] },
308
+ };
309
+ const acceptedShortlistTask = shortlistProofRuntime.continueWithReceipt({ schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: shortlistTask.taskId, workspace: shortlistWorkspace1, receipt: shortlistReceipt });
310
+ const shortlistPlanner = await createDshEmbeddedBookingPlanner({
311
+ runPort: {
312
+ async run() { return { finalResponse: '', events: successfulToolEvents('booking_search_hotels', JSON.stringify({ kind: 'terminal' }), 'call-shortlist-terminal') }; },
313
+ async close() { },
314
+ },
315
+ });
316
+ const shortlistTerminal = await shortlistPlanner.plannerFactory(acceptedShortlistTask).next({
317
+ task: acceptedShortlistTask,
318
+ turn: { schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: shortlistTask.taskId, workspace: shortlistWorkspace1, receipt: shortlistReceipt },
319
+ });
320
+ assert.deepEqual(shortlistTerminal, [{ kind: 'terminal', terminal: { status: 'completed', summary: 'offer_selected', factRefs: [] } }], 'accepted runtime receipt continuation reaches the planner terminal');
321
+ await shortlistPlanner.close();
322
+ const forgedShortlistRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-forged-shortlist-proof-'));
323
+ const forgedShortlistRuntime = new BookingCopilotTaskRuntime(ensureLedger(forgedShortlistRoot), { contextRefFactory: () => 'ctx-dsh-shortlist' });
324
+ const forgedShortlistTask = forgedShortlistRuntime.startTask({ ...shortlistTurn, taskId: 'task-dsh-forged-shortlist', turnId: 'dsh-turn-forged-shortlist' });
325
+ forgedShortlistRuntime.issueOperation(forgedShortlistTask.taskId, { ...shortlistAction, actionId: 'action-dsh-forged-shortlist' }, shortlistIntent);
326
+ assert.throws(() => forgedShortlistRuntime.continueWithReceipt({
327
+ schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: forgedShortlistTask.taskId,
328
+ workspace: { ...shortlistWorkspace1, shortlistedOfferRefs: ['offer-shortlist-a', 'offer-forged'] },
329
+ receipt: { ...shortlistReceipt, actionId: 'action-dsh-forged-shortlist' },
330
+ }), /invalid_receipt_continuation:.*shortlistedOfferRefs: every ref must be a loaded offer/, 'forged shortlist is rejected before a planner receipt continuation can terminalize');
331
+ shortlistProofRuntime['ledger'].close();
332
+ forgedShortlistRuntime['ledger'].close();
333
+ rmSync(shortlistProofRoot, { recursive: true, force: true });
334
+ rmSync(forgedShortlistRoot, { recursive: true, force: true });
335
+ const repeatedToolArguments = JSON.stringify({
336
+ decision: {
337
+ kind: 'operation',
338
+ intent: { target: 'search.results' },
339
+ action: {
340
+ ...searchRun,
341
+ reason: 'Preserve braces } {, escaped quotes " and a backslash \\ inside a JSON string.',
342
+ },
343
+ },
344
+ });
345
+ const stringifiedDecisionArguments = JSON.stringify({
346
+ decision: JSON.stringify({
347
+ kind: 'operation',
348
+ intent: { target: 'search.results' },
349
+ action: searchRun,
350
+ }),
351
+ });
352
+ const compactSearchPatchInput = {
353
+ patch: {
354
+ destination: { query: 'Dubai' },
355
+ starRating: { strength: 'must', value: { min: 5, max: 5 } },
356
+ },
357
+ };
358
+ const compactSearchPatchDecision = await runToolArgumentsCase(JSON.stringify({ kind: 'search.patch', input: compactSearchPatchInput, intent: { target: 'search.results' } }), 'dsh-compact-search-patch');
359
+ assert.equal(compactSearchPatchDecision.length, 1);
360
+ assert.equal(compactSearchPatchDecision[0]?.kind, 'operation');
361
+ if (compactSearchPatchDecision[0]?.kind !== 'operation')
362
+ throw new Error('compact proposal did not materialize an operation');
363
+ assert.deepEqual(compactSearchPatchDecision[0].action.input, compactSearchPatchInput);
364
+ assert.equal(compactSearchPatchDecision[0].action.kind, 'search.patch');
365
+ assert.equal(compactSearchPatchDecision[0].action.contextRef, task.contextRef);
366
+ assert.equal(compactSearchPatchDecision[0].action.expectedRevision, task.revision);
367
+ assert.deepEqual(compactSearchPatchDecision[0].action.factRefs, []);
368
+ assert.match(compactSearchPatchDecision[0].action.actionId, /^planner-[a-f0-9]{24}$/);
369
+ assert.match(compactSearchPatchDecision[0].action.reason, /search criteria/i);
370
+ for (const decision of [
371
+ { kind: 'search.patch', input: compactSearchPatchInput, intent: { target: 'search.results' } },
372
+ JSON.stringify({ kind: 'search.patch', input: compactSearchPatchInput, intent: { target: 'search.results' } }),
373
+ ]) {
374
+ const wrapped = await runToolArgumentsCase(JSON.stringify({ decision }), 'dsh-compact-provider-wrapper');
375
+ assertRuntimeMaterializedOperation(wrapped, 'search.patch', compactSearchPatchInput);
376
+ }
377
+ const nestedCompactEnvelope = await runToolArgumentsCase(JSON.stringify({
378
+ decision: {
379
+ kind: 'operation',
380
+ action: { kind: 'search.patch', input: compactSearchPatchInput, intent: { target: 'search.results' } },
381
+ },
382
+ }), 'dsh-nested-compact-intent');
383
+ assertRuntimeMaterializedOperation(nestedCompactEnvelope, 'search.patch', compactSearchPatchInput, task, 'plugin-compatible nested compact intent is accepted by the parent boundary');
384
+ await assert.rejects(runToolArgumentsCase(JSON.stringify({ kind: 'search.patch', input: compactSearchPatchInput }), 'dsh-fresh-missing-intent'), /planner_booking_intent_required/, 'a fresh successful-looking action cannot silently collapse a multi-step user goal when intent is missing');
385
+ await assert.rejects(runToolArgumentsCase(JSON.stringify({ kind: 'terminal' }), 'dsh-unjustified-terminal'), /planner_terminal_intent_unjustified/, 'the model cannot terminate before an authoritative receipt exists');
386
+ await assert.rejects(runToolArgumentsCase(JSON.stringify({ decision: { kind: 'terminal', terminal: { status: 'completed', summary: 'forged', factRefs: ['modelref:forged'] } } }), 'dsh-legacy-terminal-evidence'), /planner_nonoperation_runtime_owned/, 'legacy model-authored terminal authority is rejected');
387
+ for (const [argumentsText, turnId] of [
388
+ [JSON.stringify({ kind: 'operation', action: searchRun, intent: { target: 'search.results' } }), 'dsh-direct-decision-envelope'],
389
+ [stringifiedDecisionArguments, 'dsh-stringified-decision'],
390
+ ]) {
391
+ const [decision] = await runToolArgumentsCase(argumentsText, turnId);
392
+ assert.equal(decision?.kind, 'operation');
393
+ if (decision?.kind !== 'operation')
394
+ throw new Error('legacy action was not materialized');
395
+ assert.equal(decision.action.kind, 'search.run');
396
+ assert.deepEqual(decision.action.input, {});
397
+ assert.match(decision.action.actionId, /^planner-[a-f0-9]{24}$/);
398
+ assert.notEqual(decision.action.actionId, searchRun.actionId, 'legacy model authority is stripped before materialization');
399
+ }
400
+ for (const [suffix, action, error] of [
401
+ ['capability', hotelSelect, /planner_capability_action_mismatch/],
402
+ ['context', { ...searchRun, contextRef: 'ctx-other' }, /planner_context_mismatch/],
403
+ ['revision', { ...searchRun, expectedRevision: 1 }, /planner_revision_mismatch/],
404
+ ['reserved-ref', { ...searchRun, factRefs: ['modelref:reserved'] }, /planner_invalid_action:reserved_fact_ref/],
405
+ ]) {
406
+ await assert.rejects(runToolArgumentsCase(JSON.stringify({ decision: JSON.stringify({ kind: 'operation', action, intent: { target: 'search.results' } }) }), `dsh-stringified-decision-${suffix}`), error, `a stringified decision cannot bypass ${suffix} authority`);
407
+ }
408
+ const rejectedStringifiedDecision = await createDshEmbeddedBookingPlanner({
409
+ runPort: {
410
+ async run() {
411
+ return {
412
+ finalResponse: '',
413
+ events: [
414
+ toolCall('booking_search_hotels', stringifiedDecisionArguments, 'call-stringified-decision-rejected'),
415
+ toolResult('call-stringified-decision-rejected', true, 'INVALID_ARGS'),
416
+ ],
417
+ };
418
+ },
419
+ async close() { },
420
+ },
421
+ });
422
+ await assert.rejects(rejectedStringifiedDecision.plannerFactory(task).next({
423
+ task,
424
+ turn: {
425
+ schemaVersion: 'booking.surface',
426
+ kind: 'user.turn',
427
+ taskId: task.taskId,
428
+ turnId: 'dsh-stringified-decision-rejected',
429
+ workspace,
430
+ request: { text: 'Find hotels' },
431
+ },
432
+ }), /planner_tool_call_rejected/, 'a parsed stringified decision cannot bypass a paired tool rejection');
433
+ await rejectedStringifiedDecision.close();
434
+ await assert.rejects(runToolArgumentsCase(JSON.stringify({ decision: 'not JSON' }), 'dsh-stringified-decision-invalid-json'), /planner_invalid_tool_arguments/, 'a non-JSON decision string is rejected');
435
+ await assert.rejects(runToolArgumentsCase(JSON.stringify({ decision: JSON.stringify(JSON.stringify({ kind: 'operation', action: searchRun })) }), 'dsh-stringified-decision-recursive'), /planner_invalid_tool_arguments/, 'decision normalization unwraps exactly one provider encoding layer');
436
+ await assert.rejects(runToolArgumentsCase(JSON.stringify({ decision: JSON.stringify([{ kind: 'operation', action: searchRun }]) }), 'dsh-stringified-decision-array'), /planner_invalid_tool_arguments/, 'a stringified decision array is rejected');
437
+ await assert.rejects(runToolArgumentsCase(`${repeatedToolArguments}${repeatedToolArguments}`, 'dsh-duplicate-identical'), /planner_invalid_tool_arguments/, 'repeated JSON objects are not canonical raw arguments');
438
+ await assert.rejects(runToolArgumentsCase(`${repeatedToolArguments}\n \t ${repeatedToolArguments}`, 'dsh-duplicate-whitespace'), /planner_invalid_tool_arguments/, 'whitespace-separated repeated JSON objects are not canonical raw arguments');
439
+ await assert.rejects(runToolArgumentsCase(`${repeatedToolArguments}${JSON.stringify({
440
+ decision: {
441
+ kind: 'operation',
442
+ action: { ...searchRun, actionId: 'action-dsh-conflict' },
443
+ },
444
+ })}`, 'dsh-duplicate-conflict'), /planner_invalid_tool_arguments/, 'structurally different complete objects are rejected instead of selecting the first action');
445
+ await assert.rejects(runToolArgumentsCase(`${repeatedToolArguments}${repeatedToolArguments.slice(0, -1)}`, 'dsh-duplicate-truncated'), /planner_invalid_tool_arguments/, 'a truncated later object is rejected');
446
+ await assert.rejects(runToolArgumentsCase(`model preface ${repeatedToolArguments}${repeatedToolArguments}`, 'dsh-duplicate-prefix'), /planner_invalid_tool_arguments/, 'arbitrary prefix is rejected');
447
+ await assert.rejects(runToolArgumentsCase(`${repeatedToolArguments} trailing garbage`, 'dsh-duplicate-tail'), /planner_invalid_tool_arguments/, 'garbage suffix is rejected');
448
+ await assert.rejects(runToolArgumentsCase('{"decision":{"kind":"operation","action":{"reason":"raw\nline"}}}', 'dsh-single-invalid-control'), /planner_invalid_tool_arguments/, 'a single invalid object is not repaired by escaping raw control characters');
449
+ await assert.rejects(runToolArgumentsCase('[] []', 'dsh-non-object-sequence'), /planner_invalid_tool_arguments/, 'a non-object JSON sequence is rejected');
450
+ const profilePatch = buildDshEmbeddedBookingPatch('/tmp/gotry-booking-dsh-plugin.js');
451
+ const prompts = [];
452
+ const sessionIds = [];
453
+ const frozenNow = new Date(2026, 8, 9, 10, 30, 0, 0);
454
+ let runIndex = 0;
455
+ const runPort = {
456
+ async run(prompt, options) {
457
+ prompts.push(prompt);
458
+ sessionIds.push(options.sessionId);
459
+ runIndex += 1;
460
+ if (runIndex === 1) {
461
+ return {
462
+ finalResponse: '{"kind":"book","input":{}}',
463
+ events: successfulToolEvents('booking_search_hotels', JSON.stringify({ decision: { kind: 'operation', action: searchRun, intent: { target: 'search.results' } } }), 'call-adapter-search'),
464
+ };
465
+ }
466
+ if (runIndex === 3) {
467
+ return {
468
+ finalResponse: '',
469
+ events: successfulToolEvents('booking_refine_results', JSON.stringify({ decision: { kind: 'operation', action: hotelSelect, intent: { target: 'hotel.selected' } } }), 'call-adapter-select'),
470
+ };
471
+ }
472
+ return {
473
+ finalResponse: '{"kind":"search.run","input":{}}',
474
+ events: successfulToolEvents('booking_search_hotels', JSON.stringify({ kind: 'terminal' }), 'call-adapter-terminal'),
475
+ };
476
+ },
477
+ async close() { },
478
+ };
479
+ const adapter = await createDshEmbeddedBookingPlanner({ runPort, now: frozenNow });
480
+ const session = adapter.plannerFactory(task);
481
+ const first = await session.next({
482
+ task,
483
+ turn: {
484
+ schemaVersion: 'booking.surface',
485
+ kind: 'user.turn',
486
+ taskId: task.taskId,
487
+ turnId: 'dsh-turn-1',
488
+ workspace: {
489
+ ...workspace,
490
+ capabilities: { ...workspace.capabilities, allowedActions: [...workspace.capabilities.allowedActions] },
491
+ },
492
+ request: { text: '执行当前搜索' },
493
+ },
494
+ });
495
+ assertRuntimeMaterializedOperation(first, 'search.run', {}, task, 'typed dsh tool call becomes one runtime-owned operation');
496
+ assert.match(prompts[0], /Time anchor: today is 2026-09-09 \(周三, UTC[+-]\d{2}:\d{2}\)/, 'planner prompt uses the injected host-local date anchor');
497
+ assert.match(prompts[0], /process host-local anchor used only for relative-date parsing/, 'planner prompt identifies host-local time as a parsing anchor');
498
+ assert.match(prompts[0], /do not treat it as the traveler\/user timezone/, 'planner prompt does not masquerade host time as user timezone');
499
+ for (const field of ['destination', 'hotel', 'stay', 'occupancy', 'budget', 'starRating', 'guestRating', 'facilities']) {
500
+ assert.match(prompts[0], new RegExp(`\\b${field}\\b`), `planner prompt names SearchCriteriaPatch field ${field}`);
501
+ }
502
+ assert.ok(!/under criteria/i.test(prompts[0]), 'planner prompt does not revive the stale under-criteria routing wording');
503
+ assert.ok(!profilePatch.includes('2026-09-10') && !profilePatch.includes('2026-09-13'), 'shape example no longer carries stale concrete 2026 dates');
504
+ assert.ok(!/under criteria/i.test(profilePatch), 'planner persona does not revive the stale under-criteria routing wording');
505
+ assert.match(profilePatch, /Shape-only example/i, 'planner persona marks the example as shape-only');
506
+ assert.match(profilePatch, /do not copy literal/i, 'planner persona tells the model not to copy placeholder sample values');
507
+ const shapeExampleLine = profilePatch.split('\n').find((line) => line.trimStart().startsWith('{"kind":"search.patch"'));
508
+ assert.ok(shapeExampleLine, 'planner persona example uses the shallow model-facing proposal');
509
+ const shapeExample = JSON.parse(shapeExampleLine.trim());
510
+ assert.deepEqual(Object.keys(shapeExample), ['kind', 'input', 'intent'], 'planner persona example exposes semantic kind, input, and mandatory intent only');
511
+ assert.equal(shapeExample.kind, 'search.patch');
512
+ for (const runtimeField of ['schemaVersion', 'actionId', 'contextRef', 'expectedRevision', 'factRefs', 'reason']) {
513
+ assert.ok(!Object.prototype.hasOwnProperty.call(shapeExample, runtimeField), `planner persona omits runtime-owned ${runtimeField}`);
514
+ }
515
+ assert.match(profilePatch, /"stay":\{"checkIn":"<computed YYYY-MM-DD from the host-local time anchor>","checkOut":"<computed YYYY-MM-DD from nights\/check-in>"\}/, 'shape example keeps the stay object shape');
516
+ assert.match(profilePatch, /"starRating":\{"strength":"must","value":\{"min":3,"max":3\}\}/, 'shape example keeps the starRating criterion shape');
517
+ assert.match(profilePatch, /"occupancy":\{"rooms":\[\{"adults":2,"childAges":\[\]\}\]\}/, 'shape example includes a well-formed occupancy block');
518
+ assert.ok(!/Bali/.test(profilePatch), 'shape example carries no literal destination');
519
+ assert.ok(!/\b20\d{2}-\d{2}-\d{2}\b/.test(profilePatch), 'shape example carries no concrete YYYY-MM-DD dates');
520
+ const personaPrefixKeyMatches = profilePatch.match(/^ {4}personaPrefix:\s*>-/gm) ?? [];
521
+ assert.equal(personaPrefixKeyMatches.length, 1, `planner patch must carry exactly one personaPrefix: >- key (observed=${personaPrefixKeyMatches.length})`);
522
+ assert.ok(!/(^|\n) {4}persona:\s*>-/.test(profilePatch), 'planner patch must not carry the legacy persona: >- key');
523
+ assert.match(profilePatch, /You are GoTry's embedded booking planner/, 'planner patch retains the intended embedded booking persona text');
524
+ assert.match(profilePatch, /Always answer by calling exactly one/, 'planner patch retains the tool-call-only discipline');
525
+ assert.ok(!profilePatch.includes('six booking'), 'planner patch no longer hardcodes the grouped tool count');
526
+ assert.equal(formatUtcOffsetLabel(345), 'UTC+05:45', 'timezone formatter preserves positive minute offsets');
527
+ assert.equal(formatUtcOffsetLabel(-210), 'UTC-03:30', 'timezone formatter preserves negative minute offsets');
528
+ assert.equal(formatUtcOffsetLabel(0), 'UTC+00:00', 'timezone formatter zero-pads whole-hour offsets');
529
+ const receipt = {
530
+ schemaVersion: 'booking.surface',
531
+ kind: 'action.receipt',
532
+ actionId: first[0]?.kind === 'operation' ? first[0].action.actionId : searchRun.actionId,
533
+ contextRef: task.contextRef,
534
+ status: 'applied',
535
+ revision: 1,
536
+ observation: { kind: 'search.state', searchSessionRef: 'search-dsh-1', resultCount: 4 },
537
+ resultContract: { outcome: 'complete', hardCriteriaMet: true, factRefs: [], gapCodes: [], blockers: [], relaxationsApplied: [] },
538
+ };
539
+ const continuedWorkspace = {
540
+ ...workspace,
541
+ revision: 1,
542
+ results: { status: 'ready', resultCount: 4, searchSessionRef: 'search-dsh-1' },
543
+ };
544
+ if (first[0]?.kind !== 'operation')
545
+ throw new Error('first planner turn did not produce an operation checkpoint');
546
+ const continuedTask = {
547
+ ...task,
548
+ revision: 1,
549
+ lastCompletedAction: completedCheckpoint(first[0].action, task.lastTurnId),
550
+ lastReceipt: receipt,
551
+ workspaceSnapshot: continuedWorkspace,
552
+ };
553
+ const second = await session.next({
554
+ task: continuedTask,
555
+ turn: {
556
+ schemaVersion: 'booking.surface',
557
+ kind: 'action.receipt.continuation',
558
+ taskId: task.taskId,
559
+ workspace: continuedWorkspace,
560
+ receipt,
561
+ },
562
+ });
563
+ assert.deepEqual(second, [{
564
+ kind: 'terminal',
565
+ terminal: { status: 'completed', summary: 'search_results_ready', factRefs: [] },
566
+ }]);
567
+ assert.equal(sessionIds[0], sessionIds[1], 'one task keeps one dsh session across receipt continuation');
568
+ assert.match(prompts[1], /planner-[a-f0-9]{24}/, 'receipt continuation reaches the same task-scoped planner session');
569
+ // A search.patch receipt is not a search waypoint. The runtime compiles the
570
+ // mandatory search.run transition without asking the model for another
571
+ // probabilistic tool call.
572
+ const patchTerminalTask = {
573
+ ...task,
574
+ taskId: 'task-dsh-patch-terminal',
575
+ contextRef: 'ctx-dsh-patch-terminal',
576
+ lastTurnId: 'dsh-patch-terminal-turn',
577
+ workspaceSnapshot: { ...workspace, contextRef: 'ctx-dsh-patch-terminal' },
578
+ };
579
+ const patchTerminalWorkspace = patchTerminalTask.workspaceSnapshot;
580
+ let patchTerminalRuns = 0;
581
+ const patchTerminalPort = {
582
+ async run() {
583
+ patchTerminalRuns += 1;
584
+ return patchTerminalRuns === 1
585
+ ? { finalResponse: '', events: successfulToolEvents('booking_search_hotels', JSON.stringify({ kind: 'search.patch', input: { patch: { destination: { query: 'Dubai' } } }, intent: { target: 'search.results' } }), 'call-patch-terminal') }
586
+ : { finalResponse: '', events: successfulToolEvents('booking_search_hotels', JSON.stringify({ kind: 'terminal' }), 'call-patch-terminal-continuation') };
587
+ },
588
+ async close() { },
589
+ };
590
+ const patchTerminalPlanner = await createDshEmbeddedBookingPlanner({ runPort: patchTerminalPort });
591
+ const patchTerminalSession = patchTerminalPlanner.plannerFactory(patchTerminalTask);
592
+ const patchDecision = await patchTerminalSession.next({
593
+ task: patchTerminalTask,
594
+ turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: patchTerminalTask.taskId, turnId: patchTerminalTask.lastTurnId, workspace: patchTerminalWorkspace, request: { text: 'Search Dubai' } },
595
+ });
596
+ assertRuntimeMaterializedOperation(patchDecision, 'search.patch', { patch: { destination: { query: 'Dubai' } } }, patchTerminalTask, 'search.patch remains the only first operation');
597
+ if (patchDecision[0]?.kind !== 'operation')
598
+ throw new Error('patch terminal proof did not receive a patch operation');
599
+ const patchReceipt = {
600
+ schemaVersion: 'booking.surface', kind: 'action.receipt', actionId: patchDecision[0].action.actionId, contextRef: patchTerminalTask.contextRef,
601
+ status: 'applied', revision: 1, observation: { kind: 'search.state', resultCount: 0 },
602
+ resultContract: { outcome: 'complete', hardCriteriaMet: true, factRefs: [], gapCodes: [], blockers: [], relaxationsApplied: [] },
603
+ };
604
+ const patchContinuationWorkspace = { ...patchTerminalWorkspace, revision: 1, results: { status: 'idle' } };
605
+ const compiledSearchRun = await patchTerminalSession.next({
606
+ task: {
607
+ ...patchTerminalTask,
608
+ revision: 1,
609
+ lastCompletedAction: completedCheckpoint(patchDecision[0].action, patchTerminalTask.lastTurnId),
610
+ lastReceipt: patchReceipt,
611
+ workspaceSnapshot: patchContinuationWorkspace,
612
+ },
613
+ turn: { schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: patchTerminalTask.taskId, workspace: patchContinuationWorkspace, receipt: patchReceipt },
614
+ });
615
+ assertRuntimeMaterializedOperation(compiledSearchRun, 'search.run', {}, { ...patchTerminalTask, revision: 1 }, 'search.patch receipt deterministically compiles search.run');
616
+ assert.equal(patchTerminalRuns, 1, 'compiled search.run does not invoke the model a second time');
617
+ await patchTerminalPlanner.close();
618
+ const paymentWorkspace = {
619
+ ...workspace,
620
+ surface: 'payment_link',
621
+ visibleHotels: [{ hotelRef: 'hotel-1', name: 'Hotel One', factRefs: ['hotel:1'] }],
622
+ capabilities: { surface: 'payment_link', allowedActions: ['hotel.select'] },
623
+ };
624
+ const paymentTask = { ...task, taskId: 'task-payment-1', surface: 'payment_link', allowedActions: ['hotel.select'], revision: 0, workspaceSnapshot: paymentWorkspace };
625
+ const paymentSession = adapter.plannerFactory(paymentTask);
626
+ const selected = await paymentSession.next({ task: paymentTask, turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: paymentTask.taskId, turnId: 'payment-turn-1', workspace: paymentWorkspace, request: { text: '选择酒店' } } });
627
+ assertRuntimeMaterializedOperation(selected, 'hotel.select', hotelSelect.input, paymentTask, 'refine-results can emit typed hotel.select on payment_link');
628
+ assert.deepEqual(DSH_EMBEDDED_BOOKING_TOOL_NAMES, [
629
+ 'booking_search_hotels',
630
+ 'booking_run_search',
631
+ 'booking_refine_results',
632
+ 'booking_focus_hotel',
633
+ 'booking_select_hotel',
634
+ 'booking_find_room_offers',
635
+ 'booking_view_offers',
636
+ 'booking_compare_offers',
637
+ 'booking_select_offer',
638
+ 'booking_prepare_booking',
639
+ 'booking_prepare_checkout',
640
+ 'booking_observe_booking',
641
+ 'booking_finish_turn',
642
+ ]);
643
+ assert.ok(!DSH_EMBEDDED_BOOKING_TOOL_NAMES.some((name) => /book$|trade|payment/i.test(name)), 'profile has no Book/payment tool');
644
+ const childEnv = buildDshPlannerEnvironment({
645
+ PATH: '/usr/bin',
646
+ LANG: 'en_US.UTF-8',
647
+ LLM_API_KEY: 'model-key',
648
+ LLM_BASE_URL: 'http://model.invalid/v1',
649
+ LLM_MODEL: 'model-v1',
650
+ PORTAL_TOKEN: 'forbidden',
651
+ HOTELBYTE_TOKEN: 'forbidden',
652
+ GOTRY_BOOKING_COPILOT_API_KEY: 'bff-only',
653
+ });
654
+ assert.equal(childEnv.DEEPSEEK_API_KEY, 'model-key');
655
+ assert.equal(childEnv.DEEPSEEK_BASE_URL, 'http://model.invalid/v1');
656
+ assert.equal(childEnv.DEEPSEEK_MODEL, 'model-v1');
657
+ assert.equal(childEnv.PORTAL_TOKEN, undefined);
658
+ assert.equal(childEnv.HOTELBYTE_TOKEN, undefined);
659
+ assert.equal(childEnv.GOTRY_BOOKING_COPILOT_API_KEY, undefined);
660
+ const deepseekNamespaceWinsAtomically = buildDshPlannerEnvironment({
661
+ DEEPSEEK_API_KEY: 'deepseek-key',
662
+ LLM_API_KEY: 'llm-key',
663
+ LLM_BASE_URL: 'http://must-not-cross-mix.invalid/v1',
664
+ LLM_MODEL: 'must-not-cross-mix',
665
+ LLM_MAX_TOKENS: '4096',
666
+ });
667
+ assert.deepEqual(deepseekNamespaceWinsAtomically, {
668
+ DEEPSEEK_API_KEY: 'deepseek-key',
669
+ }, 'the selected DEEPSEEK credential never borrows route fields from the LLM namespace');
670
+ const completeDeepseekNamespaceWinsAtomically = buildDshPlannerEnvironment({
671
+ DEEPSEEK_API_KEY: 'deepseek-key',
672
+ DEEPSEEK_BASE_URL: 'http://deepseek-route.invalid/v1',
673
+ DEEPSEEK_MODEL: 'deepseek-model',
674
+ DEEPSEEK_MAX_TOKENS: '16384',
675
+ LLM_API_KEY: 'llm-key',
676
+ LLM_BASE_URL: 'http://llm-route.invalid/v1',
677
+ LLM_MODEL: 'llm-model',
678
+ LLM_MAX_TOKENS: '8192',
679
+ });
680
+ assert.deepEqual(completeDeepseekNamespaceWinsAtomically, {
681
+ DEEPSEEK_API_KEY: 'deepseek-key',
682
+ DEEPSEEK_BASE_URL: 'http://deepseek-route.invalid/v1',
683
+ DEEPSEEK_MODEL: 'deepseek-model',
684
+ DEEPSEEK_MAX_TOKENS: '16384',
685
+ }, 'a complete DEEPSEEK tuple wins without mixing any LLM route field');
686
+ const llmNamespaceMapsAtomically = buildDshPlannerEnvironment({
687
+ LLM_API_KEY: 'llm-key',
688
+ LLM_BASE_URL: 'http://llm-route.invalid/v1',
689
+ LLM_MODEL: 'llm-model',
690
+ LLM_MAX_TOKENS: '8192',
691
+ });
692
+ assert.deepEqual(llmNamespaceMapsAtomically, {
693
+ DEEPSEEK_API_KEY: 'llm-key',
694
+ DEEPSEEK_BASE_URL: 'http://llm-route.invalid/v1',
695
+ DEEPSEEK_MODEL: 'llm-model',
696
+ DEEPSEEK_MAX_TOKENS: '8192',
697
+ }, 'the provider-neutral LLM route is mapped as one credential/base/model/budget tuple');
698
+ assert.deepEqual(buildDshPlannerEnvironment({
699
+ PATH: '/usr/bin',
700
+ DEEPSEEK_BASE_URL: 'http://orphan-deepseek.invalid/v1',
701
+ DEEPSEEK_MODEL: 'orphan-deepseek-model',
702
+ LLM_BASE_URL: 'http://orphan-llm.invalid/v1',
703
+ LLM_MODEL: 'orphan-llm-model',
704
+ }), { PATH: '/usr/bin' }, 'route fields without a credential never enter the planner subprocess');
705
+ await assert.rejects(createDshEmbeddedBookingPlanner({
706
+ env: { DEEPSEEK_API_KEY: 'model-key', DEEPSEEK_MAX_TOKENS: 'not-a-number' },
707
+ }), /booking_planner_max_tokens_invalid/, 'invalid provider budgets fail at startup instead of reaching the model transport');
708
+ await assert.rejects(createDshEmbeddedBookingPlanner({
709
+ provider: 'minimax-official',
710
+ env: { DEEPSEEK_API_KEY: 'model-key' },
711
+ }), /booking_planner_model_required_for_nondefault_provider/, 'a non-default provider cannot inherit the DeepSeek catalog default model');
712
+ const textChannelPort = {
713
+ async run() {
714
+ return { finalResponse: JSON.stringify({ kind: 'operation', action: searchRun }), events: [] };
715
+ },
716
+ async close() { },
717
+ };
718
+ const textChannel = await createDshEmbeddedBookingPlanner({ runPort: textChannelPort });
719
+ const textDecisions = await textChannel.plannerFactory(task).next({
720
+ task,
721
+ turn: {
722
+ schemaVersion: 'booking.surface',
723
+ kind: 'user.turn',
724
+ taskId: task.taskId,
725
+ turnId: 'dsh-turn-3',
726
+ workspace: {
727
+ ...workspace,
728
+ capabilities: { ...workspace.capabilities, allowedActions: [...workspace.capabilities.allowedActions] },
729
+ },
730
+ request: { text: 'JSON prose must stay prose' },
731
+ },
732
+ });
733
+ assert.equal(textDecisions[0]?.kind, 'error', 'finalResponse text never becomes an executable decision');
734
+ assert.equal(textDecisions[0]?.kind === 'error' ? textDecisions[0].error.code : '', 'PLANNER_TYPED_DECISION_REQUIRED');
735
+ const fencedTextChannel = await createDshEmbeddedBookingPlanner({
736
+ runPort: {
737
+ async run() {
738
+ return { finalResponse: `Here is the plan:\n\`\`\`json\n${JSON.stringify({ kind: 'operation', action: searchRun })}\n\`\`\``, events: [] };
739
+ },
740
+ async close() { },
741
+ },
742
+ });
743
+ const fencedTextDecision = await fencedTextChannel.plannerFactory(task).next({
744
+ task,
745
+ turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: 'dsh-fenced-text', workspace, request: { text: 'Find hotels' } },
746
+ });
747
+ assert.equal(fencedTextDecision[0]?.kind, 'error', 'fenced JSON in finalResponse never becomes an executable decision');
748
+ await fencedTextChannel.close();
749
+ const stringifiedAction = await createDshEmbeddedBookingPlanner({
750
+ runPort: {
751
+ async run() {
752
+ const argumentsText = JSON.stringify({ decision: { kind: 'operation', action: JSON.stringify(searchRun) } });
753
+ return { finalResponse: '', events: [toolCall('booking_search_hotels', argumentsText, 'call-stringified-action'), toolResult('call-stringified-action', true, 'INVALID_ARGS')] };
754
+ },
755
+ async close() { },
756
+ },
757
+ });
758
+ await assert.rejects(stringifiedAction.plannerFactory(task).next({
759
+ task,
760
+ turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: 'dsh-stringified-action', workspace, request: { text: 'Find hotels' } },
761
+ }), /planner_invalid_typed_decision/, 'a stringified decision/action remains rejected even when the tool schema rejects that call');
762
+ await stringifiedAction.close();
763
+ const invalidCallArguments = JSON.stringify({ decision: { kind: 'operation', action: JSON.stringify(searchRun) } });
764
+ const invalidThenValid = await createDshEmbeddedBookingPlanner({
765
+ runPort: {
766
+ async run() {
767
+ return {
768
+ finalResponse: 'ignored prose',
769
+ events: [
770
+ toolCall('booking_search_hotels', invalidCallArguments, 'call-invalid-schema'),
771
+ toolResult('call-invalid-schema', true, 'INVALID_ARGS'),
772
+ ...successfulToolEvents('booking_search_hotels', JSON.stringify({ decision: { kind: 'operation', action: searchRun, intent: { target: 'search.results' } } }), 'call-valid-after-schema-rejection'),
773
+ ],
774
+ };
775
+ },
776
+ async close() { },
777
+ },
778
+ });
779
+ const invalidThenValidDecision = await invalidThenValid.plannerFactory(task).next({
780
+ task,
781
+ turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: 'dsh-invalid-then-valid', workspace, request: { text: 'Find hotels' } },
782
+ });
783
+ assertRuntimeMaterializedOperation(invalidThenValidDecision, 'search.run', {}, task, 'a schema-rejected call may be superseded by the later unique typed success in the same run');
784
+ await invalidThenValid.close();
785
+ const invalidWithoutSchemaRejection = await createDshEmbeddedBookingPlanner({
786
+ runPort: {
787
+ async run() {
788
+ return {
789
+ finalResponse: '',
790
+ events: [
791
+ toolCall('booking_search_hotels', invalidCallArguments, 'call-invalid-without-schema-rejection'),
792
+ toolResult('call-invalid-without-schema-rejection'),
793
+ ...successfulToolEvents('booking_search_hotels', JSON.stringify({ decision: { kind: 'operation', action: searchRun, intent: { target: 'search.results' } } }), 'call-valid-after-unproven-rejection'),
794
+ ],
795
+ };
796
+ },
797
+ async close() { },
798
+ },
799
+ });
800
+ await assert.rejects(invalidWithoutSchemaRejection.plannerFactory(task).next({
801
+ task,
802
+ turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: 'dsh-invalid-without-rejection', workspace, request: { text: 'Find hotels' } },
803
+ }), /planner_invalid_typed_decision/, 'an invalid call cannot be silently skipped when its same-run schema rejection is unproven');
804
+ await invalidWithoutSchemaRejection.close();
805
+ const unauthorisedTyped = await createDshEmbeddedBookingPlanner({
806
+ runPort: {
807
+ async run() {
808
+ return {
809
+ finalResponse: '',
810
+ events: [
811
+ toolCall('booking_search_hotels', JSON.stringify({ decision: { kind: 'operation', action: hotelSelect } }), 'call-unauthorised-action'),
812
+ toolResult('call-unauthorised-action', true, 'INVALID_ARGS'),
813
+ ...successfulToolEvents('booking_search_hotels', JSON.stringify({ decision: { kind: 'operation', action: searchRun, intent: { target: 'search.results' } } }), 'call-valid-after-unauthorised-action'),
814
+ ],
815
+ };
816
+ },
817
+ async close() { },
818
+ },
819
+ });
820
+ await assert.rejects(unauthorisedTyped.plannerFactory(task).next({
821
+ task,
822
+ turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: 'dsh-unauthorised-action', workspace, request: { text: 'Select a hotel' } },
823
+ }), /planner_capability_action_mismatch|planner_surface_action_unsupported/, 'a capability/allowed-action violation cannot be hidden by a later typed success');
824
+ await unauthorisedTyped.close();
825
+ const multipleTypedSuccesses = await createDshEmbeddedBookingPlanner({
826
+ runPort: {
827
+ async run() {
828
+ return {
829
+ finalResponse: '',
830
+ events: [
831
+ ...successfulToolEvents('booking_search_hotels', JSON.stringify({ decision: { kind: 'operation', action: searchRun, intent: { target: 'search.results' } } }), 'call-multiple-1'),
832
+ ...successfulToolEvents('booking_search_hotels', JSON.stringify({ decision: { kind: 'operation', action: { ...searchRun, actionId: 'action-dsh-multiple-2' }, intent: { target: 'search.results' } } }), 'call-multiple-2'),
833
+ ],
834
+ };
835
+ },
836
+ async close() { },
837
+ },
838
+ });
839
+ await assert.rejects(multipleTypedSuccesses.plannerFactory(task).next({
840
+ task,
841
+ turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: 'dsh-multiple-successes', workspace, request: { text: 'Find hotels' } },
842
+ }), /planner_multiple_typed_decisions/, 'multiple typed tool successes fail closed instead of selecting the first action');
843
+ await multipleTypedSuccesses.close();
844
+ const missingToolResult = await createDshEmbeddedBookingPlanner({
845
+ runPort: {
846
+ async run() {
847
+ return { finalResponse: '', events: [toolCall('booking_search_hotels', JSON.stringify({ decision: { kind: 'operation', action: searchRun } }), 'call-missing-result')] };
848
+ },
849
+ async close() { },
850
+ },
851
+ });
852
+ await assert.rejects(missingToolResult.plannerFactory(task).next({
853
+ task,
854
+ turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: 'dsh-missing-result', workspace, request: { text: 'Find hotels' } },
855
+ }), /planner_tool_result_missing/, 'a typed call without its paired result is not executable');
856
+ await missingToolResult.close();
857
+ const mismatchedToolResult = await createDshEmbeddedBookingPlanner({
858
+ runPort: {
859
+ async run() {
860
+ return {
861
+ finalResponse: '',
862
+ events: [
863
+ toolCall('booking_search_hotels', JSON.stringify({ decision: { kind: 'operation', action: searchRun } }), 'call-mismatched-result'),
864
+ toolResult('call-mismatched-result', false, undefined, 'call-other'),
865
+ ],
866
+ };
867
+ },
868
+ async close() { },
869
+ },
870
+ });
871
+ await assert.rejects(mismatchedToolResult.plannerFactory(task).next({
872
+ task,
873
+ turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: 'dsh-mismatched-result', workspace, request: { text: 'Find hotels' } },
874
+ }), /planner_tool_result_malformed/, 'a result whose toolCallId does not match its source call fails closed');
875
+ await mismatchedToolResult.close();
876
+ const wrongToolResultSourceKind = await createDshEmbeddedBookingPlanner({
877
+ runPort: {
878
+ async run() {
879
+ return {
880
+ finalResponse: '',
881
+ events: [
882
+ toolCall('booking_search_hotels', JSON.stringify({ decision: { kind: 'operation', action: searchRun } }), 'call-wrong-source-kind'),
883
+ toolResult('call-wrong-source-kind', false, undefined, 'call-wrong-source-kind', 'model'),
884
+ ],
885
+ };
886
+ },
887
+ async close() { },
888
+ },
889
+ });
890
+ await assert.rejects(wrongToolResultSourceKind.plannerFactory(task).next({
891
+ task,
892
+ turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: 'dsh-wrong-source-kind', workspace, request: { text: 'Find hotels' } },
893
+ }), /planner_tool_result_malformed/, 'a result from a non-tool message source is not an executable tool result');
894
+ await wrongToolResultSourceKind.close();
895
+ const typedCallRejectedThenValid = await createDshEmbeddedBookingPlanner({
896
+ runPort: {
897
+ async run() {
898
+ return {
899
+ finalResponse: '',
900
+ events: [
901
+ toolCall('booking_search_hotels', JSON.stringify({ decision: { kind: 'operation', action: searchRun, intent: { target: 'search.results' } } }), 'call-typed-schema-rejected'),
902
+ toolResult('call-typed-schema-rejected', true, 'INVALID_ARGS'),
903
+ ...successfulToolEvents('booking_search_hotels', JSON.stringify({ decision: { kind: 'operation', action: { ...searchRun, actionId: 'action-dsh-after-typed-rejection' }, intent: { target: 'search.results' } } }), 'call-after-typed-rejection'),
904
+ ],
905
+ };
906
+ },
907
+ async close() { },
908
+ },
909
+ });
910
+ await assert.rejects(typedCallRejectedThenValid.plannerFactory(task).next({
911
+ task,
912
+ turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: 'dsh-typed-rejected-then-valid', workspace, request: { text: 'Find hotels' } },
913
+ }), /planner_tool_call_rejected/, 'a parsed typed call with an error result cannot be skipped in favor of a later action');
914
+ await typedCallRejectedThenValid.close();
915
+ const unsafeFactRefRejectedThenValid = await createDshEmbeddedBookingPlanner({
916
+ runPort: {
917
+ async run() {
918
+ return {
919
+ finalResponse: '',
920
+ events: [
921
+ toolCall('booking_search_hotels', JSON.stringify({ decision: { kind: 'operation', action: { ...searchRun, factRefs: ['draft:destination=Dubai'] }, intent: { target: 'search.results' } } }), 'call-unsafe-fact-ref'),
922
+ toolResult('call-unsafe-fact-ref', true, 'INVALID_ARGS'),
923
+ ...successfulToolEvents('booking_search_hotels', JSON.stringify({ decision: { kind: 'operation', action: { ...searchRun, actionId: 'action-dsh-after-unsafe-ref', factRefs: [] }, intent: { target: 'search.results' } } }), 'call-valid-after-unsafe-ref'),
924
+ ],
925
+ };
926
+ },
927
+ async close() { },
928
+ },
929
+ });
930
+ assertRuntimeMaterializedOperation(await unsafeFactRefRejectedThenValid.plannerFactory(task).next({
931
+ task,
932
+ turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: 'dsh-unsafe-ref-repair', workspace, request: { text: 'Find hotels' } },
933
+ }), 'search.run', {}, task, 'a dsh INVALID_ARGS result lets the model repair an unsafe factRef inside the same run');
934
+ await unsafeFactRefRejectedThenValid.close();
935
+ const reservedFactRefRejectedThenValid = await createDshEmbeddedBookingPlanner({
936
+ runPort: {
937
+ async run() {
938
+ return {
939
+ finalResponse: '',
940
+ events: [
941
+ toolCall('booking_search_hotels', JSON.stringify({ decision: { kind: 'operation', action: { ...searchRun, factRefs: ['modelref:reserved-by-runtime'] }, intent: { target: 'search.results' } } }), 'call-reserved-fact-ref'),
942
+ toolResult('call-reserved-fact-ref', true, 'INVALID_ARGS'),
943
+ ...successfulToolEvents('booking_search_hotels', JSON.stringify({ decision: { kind: 'operation', action: { ...searchRun, actionId: 'action-dsh-after-reserved-ref', factRefs: [] }, intent: { target: 'search.results' } } }), 'call-valid-after-reserved-ref'),
944
+ ],
945
+ };
946
+ },
947
+ async close() { },
948
+ },
949
+ });
950
+ await assert.rejects(reservedFactRefRejectedThenValid.plannerFactory(task).next({
951
+ task,
952
+ turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: 'dsh-reserved-ref-no-repair', workspace, request: { text: 'Find hotels' } },
953
+ }), /planner_invalid_action:reserved_fact_ref/, 'the runtime-owned modelref namespace remains fail-closed even with paired INVALID_ARGS and a later valid call');
954
+ await reservedFactRefRejectedThenValid.close();
955
+ // Issue #473 mixed-authority regression: an INVALID_ARGS + later-valid pair
956
+ // must not launder an authority violation that co-travels with a reference
957
+ // syntax error. Pure shape errors stay repairable; authority rejections
958
+ // remain fail-closed regardless of paired INVALID_ARGS + later canonical.
959
+ const mixedAuthorityCases = [
960
+ {
961
+ name: 'shape-only repair control',
962
+ tool: 'booking_search_hotels',
963
+ badAction: { ...searchRun, factRefs: ['draft:destination=Dubai'] },
964
+ accept: true,
965
+ },
966
+ {
967
+ name: 'capability mismatch plus unsafe factRef',
968
+ tool: 'booking_search_hotels',
969
+ badAction: { ...hotelSelect, factRefs: ['draft:destination=Dubai'] },
970
+ accept: false,
971
+ errorPattern: /planner_capability_action_mismatch/,
972
+ },
973
+ {
974
+ name: 'surface unsupported plus unsafe factRef',
975
+ tool: 'booking_refine_results',
976
+ badAction: { ...hotelSelect, factRefs: ['draft:destination=Dubai'] },
977
+ accept: false,
978
+ errorPattern: /planner_surface_action_unsupported/,
979
+ },
980
+ {
981
+ name: 'revision mismatch plus unsafe factRef',
982
+ tool: 'booking_search_hotels',
983
+ badAction: { ...searchRun, expectedRevision: 99, factRefs: ['draft:destination=Dubai'] },
984
+ accept: false,
985
+ errorPattern: /planner_revision_mismatch/,
986
+ },
987
+ {
988
+ name: 'reserved factRef plus unsafe actionId',
989
+ tool: 'booking_search_hotels',
990
+ badAction: { ...searchRun, actionId: 'bad/action', factRefs: ['modelref:reserved-by-runtime'] },
991
+ accept: false,
992
+ errorPattern: /planner_invalid_action:reserved_fact_ref/,
993
+ },
994
+ {
995
+ name: 'context mismatch plus unsafe factRef control',
996
+ tool: 'booking_search_hotels',
997
+ badAction: { ...searchRun, contextRef: 'ctx-other', factRefs: ['draft:destination=Dubai'] },
998
+ accept: false,
999
+ errorPattern: /planner_context_mismatch/,
1000
+ },
1001
+ {
1002
+ name: 'reserved factRef plus empty actionId',
1003
+ tool: 'booking_search_hotels',
1004
+ badAction: { ...searchRun, actionId: '', factRefs: ['modelref:reserved-by-runtime'] },
1005
+ accept: false,
1006
+ errorPattern: /planner_invalid_action:reserved_fact_ref/,
1007
+ },
1008
+ {
1009
+ name: 'surface unsupported plus nonstring factRef',
1010
+ tool: 'booking_refine_results',
1011
+ badAction: { ...hotelSelect, factRefs: [0] },
1012
+ accept: false,
1013
+ errorPattern: /planner_surface_action_unsupported/,
1014
+ },
1015
+ {
1016
+ name: 'capability mismatch plus unsafe actionId counterpart',
1017
+ tool: 'booking_search_hotels',
1018
+ badAction: { ...hotelSelect, actionId: 'bad/id' },
1019
+ accept: false,
1020
+ errorPattern: /planner_capability_action_mismatch/,
1021
+ },
1022
+ {
1023
+ name: 'revision mismatch plus unsafe actionId counterpart',
1024
+ tool: 'booking_search_hotels',
1025
+ badAction: { ...searchRun, actionId: 'bad/id', expectedRevision: 99 },
1026
+ accept: false,
1027
+ errorPattern: /planner_revision_mismatch/,
1028
+ },
1029
+ {
1030
+ name: 'revision mismatch plus empty actionId',
1031
+ tool: 'booking_search_hotels',
1032
+ badAction: { ...searchRun, actionId: '', expectedRevision: 99 },
1033
+ accept: false,
1034
+ errorPattern: /planner_revision_mismatch/,
1035
+ },
1036
+ {
1037
+ name: 'revision mismatch plus nonstring factRef',
1038
+ tool: 'booking_search_hotels',
1039
+ badAction: { ...searchRun, factRefs: [0], expectedRevision: 99 },
1040
+ accept: false,
1041
+ errorPattern: /planner_revision_mismatch/,
1042
+ },
1043
+ {
1044
+ name: 'revision mismatch plus invalid input',
1045
+ tool: 'booking_search_hotels',
1046
+ badAction: { ...searchRun, expectedRevision: 99, input: { bogus: 1 } },
1047
+ accept: false,
1048
+ errorPattern: /planner_revision_mismatch/,
1049
+ },
1050
+ {
1051
+ name: 'wrong-typed revision repair control',
1052
+ tool: 'booking_search_hotels',
1053
+ badAction: { ...searchRun, expectedRevision: '99', factRefs: ['draft:destination=Dubai'] },
1054
+ accept: true,
1055
+ },
1056
+ ];
1057
+ for (const [index, c] of mixedAuthorityCases.entries()) {
1058
+ const mixedAuthorityPlanner = await createDshEmbeddedBookingPlanner({
1059
+ runPort: {
1060
+ async run() {
1061
+ return {
1062
+ finalResponse: '',
1063
+ events: [
1064
+ toolCall(c.tool, JSON.stringify({ decision: { kind: 'operation', action: c.badAction, intent: { target: 'search.results' } } }), `mixed-authority-${index}-bad`),
1065
+ toolResult(`mixed-authority-${index}-bad`, true, 'INVALID_ARGS'),
1066
+ ...successfulToolEvents('booking_search_hotels', JSON.stringify({ decision: { kind: 'operation', action: searchRun, intent: { target: 'search.results' } } }), `mixed-authority-${index}-good`),
1067
+ ],
1068
+ };
1069
+ },
1070
+ async close() { },
1071
+ },
1072
+ });
1073
+ try {
1074
+ if (c.accept) {
1075
+ const decisions = await mixedAuthorityPlanner.plannerFactory(task).next({
1076
+ task,
1077
+ turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: `dsh-mixed-authority-${index}`, workspace, request: { text: 'Find hotels' } },
1078
+ });
1079
+ assertRuntimeMaterializedOperation(decisions, 'search.run', {}, task, `${c.name} accepts the later canonical call after shape repair`);
1080
+ }
1081
+ else {
1082
+ await assert.rejects(mixedAuthorityPlanner.plannerFactory(task).next({
1083
+ task,
1084
+ turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: `dsh-mixed-authority-${index}`, workspace, request: { text: 'Find hotels' } },
1085
+ }), c.errorPattern, c.name);
1086
+ }
1087
+ }
1088
+ finally {
1089
+ await mixedAuthorityPlanner.close();
1090
+ }
1091
+ }
1092
+ const sanitizedRefPort = {
1093
+ async run() {
1094
+ return {
1095
+ finalResponse: '',
1096
+ events: successfulToolEvents('booking_search_hotels', JSON.stringify({ decision: { kind: 'operation', action: { ...searchRun, factRefs: ['draft:destination=Dubai', 'draft:destination.Dubai'] } } }), 'call-sanitized-ref'),
1097
+ };
1098
+ },
1099
+ async close() { },
1100
+ };
1101
+ const sanitizedRef = await createDshEmbeddedBookingPlanner({ runPort: sanitizedRefPort });
1102
+ await assert.rejects(sanitizedRef.plannerFactory(task).next({
1103
+ task,
1104
+ turn: {
1105
+ schemaVersion: 'booking.surface',
1106
+ kind: 'user.turn',
1107
+ taskId: task.taskId,
1108
+ turnId: 'dsh-turn-6b',
1109
+ workspace,
1110
+ request: { text: 'Find hotels' },
1111
+ },
1112
+ }), /planner_invalid_action/, 'unsafe fact refs are rejected as non-canonical raw arguments');
1113
+ await sanitizedRef.close();
1114
+ const collisionRawRefs = ['fact://a/b', 'fact:..a?b'];
1115
+ for (const [index, rawRef] of collisionRawRefs.entries()) {
1116
+ const collisionPlanner = await createDshEmbeddedBookingPlanner({
1117
+ runPort: {
1118
+ async run() {
1119
+ return {
1120
+ finalResponse: '',
1121
+ events: successfulToolEvents('booking_search_hotels', JSON.stringify({ decision: { kind: 'operation', action: { ...searchRun, actionId: `action-dsh-collision-${index}`, factRefs: [rawRef] } } }), `call-collision-${index}`),
1122
+ };
1123
+ },
1124
+ async close() { },
1125
+ },
1126
+ });
1127
+ await assert.rejects(collisionPlanner.plannerFactory(task).next({ task, turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: `dsh-collision-${index}`, workspace, request: { text: 'Find hotels' } } }), /planner_invalid_action/, 'unsafe refs are rejected without hashing');
1128
+ await collisionPlanner.close();
1129
+ }
1130
+ async function assertFactRefsRejected(factRefs, message) {
1131
+ const planner = await createDshEmbeddedBookingPlanner({
1132
+ runPort: {
1133
+ async run() {
1134
+ return {
1135
+ finalResponse: '',
1136
+ events: successfulToolEvents('booking_search_hotels', JSON.stringify({ decision: { kind: 'operation', action: { ...searchRun, factRefs } } }), 'call-fact-ref-rejection'),
1137
+ };
1138
+ },
1139
+ async close() { },
1140
+ },
1141
+ });
1142
+ await assert.rejects(planner.plannerFactory(task).next({ task, turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: 'dsh-ref-rejection', workspace, request: { text: 'Find hotels' } } }), /planner_invalid_action/, message);
1143
+ await planner.close();
1144
+ }
1145
+ const firstAlias = 'modelref:'.concat(createHash('sha256').update(collisionRawRefs[0], 'utf8').digest('hex'));
1146
+ await assertFactRefsRejected([collisionRawRefs[0], firstAlias], 'unsafe raw ref cannot alias a same-action reserved modelref');
1147
+ await assertFactRefsRejected([collisionRawRefs[0], collisionRawRefs[0]], 'repair-time duplicate factRefs are rejected by canonical validation');
1148
+ await assertFactRefsRejected(['modelref:'.concat('a'.repeat(64))], 'direct modelref namespace input is reserved and rejected');
1149
+ const reservedText = await createDshEmbeddedBookingPlanner({
1150
+ runPort: {
1151
+ async run() {
1152
+ return { finalResponse: JSON.stringify({ kind: 'operation', action: { ...searchRun, factRefs: ['modelref:'.concat('b'.repeat(64))] } }), events: [] };
1153
+ },
1154
+ async close() { },
1155
+ },
1156
+ });
1157
+ const reservedTextDecision = await reservedText.plannerFactory(task).next({ task, turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: 'dsh-reserved-text', workspace, request: { text: 'Find hotels' } } });
1158
+ assert.equal(reservedTextDecision[0]?.kind, 'error', 'finalResponse text stays non-executable even when it contains a reserved ref');
1159
+ await reservedText.close();
1160
+ let stableRefCall = 0;
1161
+ const stableRawRef = 'fact://same/raw';
1162
+ const stablePlanner = await createDshEmbeddedBookingPlanner({
1163
+ runPort: {
1164
+ async run() {
1165
+ stableRefCall += 1;
1166
+ return {
1167
+ finalResponse: '',
1168
+ events: successfulToolEvents('booking_search_hotels', JSON.stringify({ decision: { kind: 'operation', action: { ...searchRun, actionId: `action-dsh-stable-${stableRefCall}`, factRefs: [stableRawRef] } } }), `call-stable-${stableRefCall}`),
1169
+ };
1170
+ },
1171
+ async close() { },
1172
+ },
1173
+ });
1174
+ await assert.rejects(stablePlanner.plannerFactory(task).next({ task, turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: 'dsh-stable-1', workspace, request: { text: 'Find hotels' } } }), /planner_invalid_action/);
1175
+ await stablePlanner.close();
1176
+ const unauthorisedPort = {
1177
+ async run() {
1178
+ return { finalResponse: JSON.stringify({ kind: 'operation', action: hotelSelect }), events: [] };
1179
+ },
1180
+ async close() { },
1181
+ };
1182
+ const unauthorised = await createDshEmbeddedBookingPlanner({ runPort: unauthorisedPort });
1183
+ const unauthorisedDecisions = await unauthorised.plannerFactory(task).next({
1184
+ task,
1185
+ turn: {
1186
+ schemaVersion: 'booking.surface',
1187
+ kind: 'user.turn',
1188
+ taskId: task.taskId,
1189
+ turnId: 'dsh-turn-4',
1190
+ workspace: {
1191
+ ...workspace,
1192
+ capabilities: { ...workspace.capabilities, allowedActions: [...workspace.capabilities.allowedActions] },
1193
+ },
1194
+ request: { text: 'Select a hotel' },
1195
+ },
1196
+ });
1197
+ assert.equal(unauthorisedDecisions[0]?.kind, 'error', 'text-channel decisions outside allowedActions stay non-executable');
1198
+ const fragmentRefPort = {
1199
+ async run() {
1200
+ return {
1201
+ finalResponse: '',
1202
+ events: successfulToolEvents('booking_search_hotels', JSON.stringify({ decision: { kind: 'operation', action: { ...searchRun, factRefs: [`fact://turn_${task.lastTurnId}/request`] } } }), 'call-fragment-ref'),
1203
+ };
1204
+ },
1205
+ async close() { },
1206
+ };
1207
+ const fragmentRef = await createDshEmbeddedBookingPlanner({ runPort: fragmentRefPort });
1208
+ await assert.rejects(fragmentRef.plannerFactory(task).next({
1209
+ task,
1210
+ turn: {
1211
+ schemaVersion: 'booking.surface',
1212
+ kind: 'user.turn',
1213
+ taskId: task.taskId,
1214
+ turnId: 'dsh-turn-7',
1215
+ workspace,
1216
+ request: { text: 'Find hotels' },
1217
+ },
1218
+ }), /planner_invalid_action/, 'JSON-pointer fragment factRefs are rejected without hashing');
1219
+ await fragmentRef.close();
1220
+ const truncatedPort = {
1221
+ async run() {
1222
+ // A reasoning-token budget cut in finalResponse is text-only evidence and
1223
+ // must never be repaired into an executable decision.
1224
+ const truncated = JSON.stringify({ decision: { action: { ...searchRun, factRefs: ['turn_cap-request'] }, kind: 'operation' } }).replace(/}+$/, '');
1225
+ return { finalResponse: truncated, events: [] };
1226
+ },
1227
+ async close() { },
1228
+ };
1229
+ const truncatedRecovery = await createDshEmbeddedBookingPlanner({ runPort: truncatedPort });
1230
+ const truncatedDecisions = await truncatedRecovery.plannerFactory(task).next({
1231
+ task,
1232
+ turn: {
1233
+ schemaVersion: 'booking.surface',
1234
+ kind: 'user.turn',
1235
+ taskId: task.taskId,
1236
+ turnId: 'dsh-turn-8',
1237
+ workspace,
1238
+ request: { text: 'Find hotels' },
1239
+ },
1240
+ });
1241
+ assert.equal(truncatedDecisions[0]?.kind, 'error', 'truncated finalResponse never recovers into an executable decision');
1242
+ await truncatedRecovery.close();
1243
+ const unsafeRefPort = {
1244
+ async run() {
1245
+ return {
1246
+ finalResponse: '',
1247
+ events: successfulToolEvents('booking_search_hotels', JSON.stringify({ decision: { kind: 'operation', action: { ...searchRun, factRefs: [42] } } }), 'call-unsafe-ref'),
1248
+ };
1249
+ },
1250
+ async close() { },
1251
+ };
1252
+ const unsafeRef = await createDshEmbeddedBookingPlanner({ runPort: unsafeRefPort });
1253
+ await assert.rejects(unsafeRef.plannerFactory(task).next({
1254
+ task,
1255
+ turn: {
1256
+ schemaVersion: 'booking.surface',
1257
+ kind: 'user.turn',
1258
+ taskId: task.taskId,
1259
+ turnId: 'dsh-turn-6',
1260
+ workspace,
1261
+ request: { text: 'Find hotels' },
1262
+ },
1263
+ }), /planner_invalid_action/, 'non-string factRef entries retry as parse-class failures, not ledger-boundary crashes');
1264
+ await unsafeRef.close();
1265
+ // A workspace with loadedOffers for a hotel absent from the availability
1266
+ // state (UI-loaded offers) must not crash the prompt projection.
1267
+ const foreignOfferWorkspace = {
1268
+ ...workspace,
1269
+ loadedOffers: [{ offerRef: "offer-ui-1", offerVersionRef: "offerv-ui-1", hotelRef: "hotel-ui-9", evidenceLevel: "rate_loaded", factRefs: [] }],
1270
+ };
1271
+ let uiOffersRuns = 0;
1272
+ const uiOffersPort = {
1273
+ async run(prompt) {
1274
+ uiOffersRuns += 1;
1275
+ if (uiOffersRuns === 1)
1276
+ assert.ok(prompt.includes("hotel-ui-9"), "workspace loadedOffers reach the planner payload");
1277
+ return { finalResponse: "", events: [] };
1278
+ },
1279
+ async close() { },
1280
+ };
1281
+ const uiOffers = await createDshEmbeddedBookingPlanner({ runPort: uiOffersPort });
1282
+ const uiOffersDecisions = await uiOffers.plannerFactory(task).next({
1283
+ task,
1284
+ turn: { schemaVersion: "booking.surface", kind: "user.turn", taskId: task.taskId, turnId: "dsh-turn-ui", workspace: foreignOfferWorkspace, request: { text: "Prepare the loaded offer" } },
1285
+ });
1286
+ assert.equal(uiOffersDecisions[0]?.kind, "error", "foreign loadedOffers reach the payload without crashing the projection");
1287
+ await uiOffers.close();
1288
+ let plainProseRuns = 0;
1289
+ const plainProsePort = {
1290
+ async run() {
1291
+ plainProseRuns += 1;
1292
+ return { finalResponse: 'I would search hotels in Dubai for you.', events: [] };
1293
+ },
1294
+ async close() { },
1295
+ };
1296
+ const plainProse = await createDshEmbeddedBookingPlanner({ runPort: plainProsePort });
1297
+ const plainProseDecisions = await plainProse.plannerFactory(task).next({
1298
+ task,
1299
+ turn: {
1300
+ schemaVersion: 'booking.surface',
1301
+ kind: 'user.turn',
1302
+ taskId: task.taskId,
1303
+ turnId: 'dsh-turn-5',
1304
+ workspace,
1305
+ request: { text: 'Find hotels' },
1306
+ },
1307
+ });
1308
+ assert.equal(plainProseRuns, 3, 'genuine prose receives the bounded typed-decision correction budget');
1309
+ assert.deepEqual(plainProseDecisions[0], {
1310
+ kind: 'error',
1311
+ error: {
1312
+ code: 'PLANNER_TYPED_DECISION_REQUIRED',
1313
+ message: 'GoTry produced no typed capability decision; assistant prose was ignored.',
1314
+ retryable: false,
1315
+ },
1316
+ }, 'prose without a typed decision envelope is never executable or client-retryable');
1317
+ let authFailureRuns = 0;
1318
+ const authFailure = await createDshEmbeddedBookingPlanner({
1319
+ runPort: {
1320
+ async run() {
1321
+ authFailureRuns += 1;
1322
+ return {
1323
+ finalResponse: '',
1324
+ events: [{ type: 'turn/end', data: { reason: { kind: 'error', error: { code: 'AUTH', status: 401, message: 'provider authentication failed' } } } }],
1325
+ notifications: [{ method: 'session.event', params: {} }, { method: 'session.status', params: {} }],
1326
+ };
1327
+ },
1328
+ async close() { },
1329
+ },
1330
+ });
1331
+ const authFailureDecision = await authFailure.plannerFactory(task).next({
1332
+ task,
1333
+ turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: 'dsh-provider-auth', workspace, request: { text: 'Find hotels' } },
1334
+ });
1335
+ assert.equal(authFailureRuns, 1, 'provider AUTH failure fails closed without three empty-decision retries');
1336
+ assert.deepEqual(authFailureDecision[0], { kind: 'error', error: { code: 'PLANNER_PROVIDER_AUTH_FAILED', message: 'The planner provider rejected authentication.', retryable: false } });
1337
+ await authFailure.close();
1338
+ let quotaFailureRuns = 0;
1339
+ const quotaFailure = await createDshEmbeddedBookingPlanner({
1340
+ runPort: {
1341
+ async run() {
1342
+ quotaFailureRuns += 1;
1343
+ return {
1344
+ finalResponse: '',
1345
+ events: [{
1346
+ type: 'assistant/attempt',
1347
+ data: {
1348
+ stream: [{
1349
+ type: 'chunk',
1350
+ time: 1,
1351
+ chunk: { type: 'finish', reason: { kind: 'error', failure: { code: 'QUOTA', status: 429, message: 'provider quota exhausted' } } },
1352
+ }],
1353
+ },
1354
+ }],
1355
+ notifications: [{ method: 'session.event', params: {} }, { method: 'session.status', params: {} }],
1356
+ };
1357
+ },
1358
+ async close() { },
1359
+ },
1360
+ });
1361
+ const quotaFailureDecision = await quotaFailure.plannerFactory(task).next({
1362
+ task,
1363
+ turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: 'dsh-provider-quota', workspace, request: { text: 'Find hotels' } },
1364
+ });
1365
+ assert.equal(quotaFailureRuns, 1, 'provider QUOTA failure fails closed without three empty-decision retries');
1366
+ assert.deepEqual(quotaFailureDecision[0], { kind: 'error', error: { code: 'PLANNER_PROVIDER_QUOTA_EXHAUSTED', message: 'The planner provider quota is exhausted.', retryable: false } });
1367
+ await quotaFailure.close();
1368
+ let abortedFailureRuns = 0;
1369
+ const abortedFailure = await createDshEmbeddedBookingPlanner({
1370
+ runPort: {
1371
+ async run() {
1372
+ abortedFailureRuns += 1;
1373
+ return {
1374
+ finalResponse: '',
1375
+ events: [{
1376
+ type: 'assistant/attempt',
1377
+ data: {
1378
+ stream: [{
1379
+ type: 'chunk',
1380
+ chunk: { type: 'finish', reason: { kind: 'aborted', failure: { code: 'TRANSPORT', message: 'provider stream aborted' } } },
1381
+ }],
1382
+ },
1383
+ }],
1384
+ };
1385
+ },
1386
+ async close() { },
1387
+ },
1388
+ });
1389
+ const abortedFailureDecision = await abortedFailure.plannerFactory(task).next({
1390
+ task,
1391
+ turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: 'dsh-provider-aborted', workspace, request: { text: 'Find hotels' } },
1392
+ });
1393
+ assert.equal(abortedFailureRuns, 1, 'packed aborted provider failure fails closed without empty-decision retries');
1394
+ assert.deepEqual(abortedFailureDecision[0], { kind: 'error', error: { code: 'PLANNER_PROVIDER_UNAVAILABLE', message: 'The planner provider is temporarily unavailable.', retryable: true } });
1395
+ await abortedFailure.close();
1396
+ for (const fixture of [
1397
+ {
1398
+ name: 'rate-limit',
1399
+ failure: { code: 'RATE_LIMIT', status: 429, message: 'provider rate limited' },
1400
+ expected: { code: 'PLANNER_PROVIDER_RATE_LIMITED', message: 'The planner provider rate-limited the request.', retryable: true },
1401
+ },
1402
+ {
1403
+ name: 'transport',
1404
+ failure: { code: 'TRANSPORT', message: 'provider transport failed' },
1405
+ expected: { code: 'PLANNER_PROVIDER_UNAVAILABLE', message: 'The planner provider is temporarily unavailable.', retryable: true },
1406
+ },
1407
+ {
1408
+ name: 'empty-response',
1409
+ failure: { code: 'EMPTY_RESPONSE', message: 'provider returned no content' },
1410
+ expected: { code: 'PLANNER_PROVIDER_UNAVAILABLE', message: 'The planner provider is temporarily unavailable.', retryable: true },
1411
+ },
1412
+ {
1413
+ name: 'malformed-response',
1414
+ failure: { code: 'MALFORMED_RESPONSE', message: 'provider returned malformed data' },
1415
+ expected: { code: 'PLANNER_PROVIDER_RESPONSE_INVALID', message: 'The planner provider returned an invalid response.', retryable: false },
1416
+ },
1417
+ {
1418
+ name: 'invalid-response',
1419
+ failure: { code: 'INVALID_RESPONSE', message: 'provider response failed validation' },
1420
+ expected: { code: 'PLANNER_PROVIDER_RESPONSE_INVALID', message: 'The planner provider returned an invalid response.', retryable: false },
1421
+ },
1422
+ {
1423
+ name: 'invalid-request',
1424
+ failure: { code: 'INVALID_REQUEST', status: 400, message: 'provider rejected the request' },
1425
+ expected: { code: 'PLANNER_PROVIDER_REQUEST_REJECTED', message: 'The planner provider rejected the model request.', retryable: false },
1426
+ },
1427
+ {
1428
+ name: 'context-window',
1429
+ failure: { code: 'CONTEXT_WINDOW_EXCEEDED', status: 400, message: 'provider context window exceeded' },
1430
+ expected: { code: 'PLANNER_PROVIDER_REQUEST_REJECTED', message: 'The planner provider rejected the model request.', retryable: false },
1431
+ },
1432
+ {
1433
+ name: 'unsupported-option',
1434
+ failure: { code: 'UNSUPPORTED_OPTION', message: 'provider option unsupported' },
1435
+ expected: { code: 'PLANNER_CONFIGURATION_INVALID', message: 'The planner provider configuration is invalid.', retryable: false },
1436
+ },
1437
+ ]) {
1438
+ let runs = 0;
1439
+ const planner = await createDshEmbeddedBookingPlanner({
1440
+ runPort: {
1441
+ async run() {
1442
+ runs += 1;
1443
+ return {
1444
+ finalResponse: '',
1445
+ events: [{ type: 'turn/end', data: { reason: { kind: 'error', error: fixture.failure } } }],
1446
+ };
1447
+ },
1448
+ async close() { },
1449
+ },
1450
+ });
1451
+ const decision = await planner.plannerFactory(task).next({
1452
+ task,
1453
+ turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: `dsh-provider-${fixture.name}`, workspace, request: { text: 'Find hotels' } },
1454
+ });
1455
+ assert.equal(runs, 1, `provider ${fixture.name} failure is not mistaken for an empty decision`);
1456
+ assert.deepEqual(decision[0], { kind: 'error', error: fixture.expected });
1457
+ await planner.close();
1458
+ }
1459
+ const operationBeforeFailure = await createDshEmbeddedBookingPlanner({
1460
+ runPort: {
1461
+ async run() {
1462
+ return {
1463
+ finalResponse: '',
1464
+ events: [
1465
+ ...successfulToolEvents('booking_search_hotels', JSON.stringify({ decision: { kind: 'operation', action: searchRun, intent: { target: 'search.results' } } }), 'call-operation-before-failure'),
1466
+ { type: 'turn/end', data: { reason: { kind: 'error', error: { code: 'AUTH', status: 401, message: 'post-tool provider failure' } } } },
1467
+ ],
1468
+ };
1469
+ },
1470
+ async close() { },
1471
+ },
1472
+ });
1473
+ const operationBeforeFailureDecision = await operationBeforeFailure.plannerFactory(task).next({
1474
+ task,
1475
+ turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: 'dsh-operation-before-provider-failure', workspace, request: { text: 'Find hotels' } },
1476
+ });
1477
+ assertRuntimeMaterializedOperation(operationBeforeFailureDecision, 'search.run', {}, task, 'a valid receipt-gated operation outranks a later provider failure');
1478
+ await operationBeforeFailure.close();
1479
+ const finalResponseBeforeTerminalFailure = await createDshEmbeddedBookingPlanner({
1480
+ runPort: {
1481
+ async run() {
1482
+ return {
1483
+ finalResponse: JSON.stringify({ kind: 'operation', action: searchRun }),
1484
+ events: [
1485
+ { type: 'turn/end', data: { reason: { kind: 'error', error: { code: 'AUTH', status: 401, message: 'terminal provider failure' } } } },
1486
+ ],
1487
+ };
1488
+ },
1489
+ async close() { },
1490
+ },
1491
+ });
1492
+ const finalResponseBeforeTerminalFailureDecision = await finalResponseBeforeTerminalFailure.plannerFactory(task).next({
1493
+ task,
1494
+ turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: 'dsh-final-response-before-terminal-failure', workspace, request: { text: 'Find hotels' } },
1495
+ });
1496
+ assert.deepEqual(finalResponseBeforeTerminalFailureDecision, [{ kind: 'error', error: { code: 'PLANNER_PROVIDER_AUTH_FAILED', message: 'The planner provider rejected authentication.', retryable: false } }], 'authoritative turn/end error does not erase a successful typed tool result');
1497
+ await finalResponseBeforeTerminalFailure.close();
1498
+ for (const [name, malformedTerminal] of [
1499
+ ['missing-reason', { type: 'turn/end', data: {} }],
1500
+ ['missing-error', { type: 'turn/end', data: { reason: { kind: 'error' } } }],
1501
+ ]) {
1502
+ const malformedTerminalPlanner = await createDshEmbeddedBookingPlanner({
1503
+ runPort: {
1504
+ async run() {
1505
+ return {
1506
+ finalResponse: JSON.stringify({ kind: 'operation', action: searchRun }),
1507
+ events: [malformedTerminal],
1508
+ };
1509
+ },
1510
+ async close() { },
1511
+ },
1512
+ });
1513
+ const malformedTerminalDecision = await malformedTerminalPlanner.plannerFactory(task).next({
1514
+ task,
1515
+ turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: `dsh-malformed-terminal-${name}`, workspace, request: { text: 'Find hotels' } },
1516
+ });
1517
+ assert.deepEqual(malformedTerminalDecision, [{ kind: 'error', error: { code: 'PLANNER_FAILED', message: 'The planner stopped unexpectedly before producing a usable action.', retryable: false } }], `malformed terminal ${name} fails closed before compatibility finalResponse recovery`);
1518
+ await malformedTerminalPlanner.close();
1519
+ }
1520
+ const finalTextAfterTransientAttempt = await createDshEmbeddedBookingPlanner({
1521
+ runPort: {
1522
+ async run() {
1523
+ return {
1524
+ finalResponse: JSON.stringify({ kind: 'operation', action: searchRun }),
1525
+ events: [
1526
+ {
1527
+ type: 'assistant/attempt',
1528
+ data: { stream: [{ type: 'chunk', chunk: { type: 'finish', reason: { kind: 'aborted', failure: { code: 'RATE_LIMIT', status: 429, message: 'recovered attempt' } } } }] },
1529
+ },
1530
+ { type: 'turn/end', data: { reason: { kind: 'completed' } } },
1531
+ ],
1532
+ };
1533
+ },
1534
+ async close() { },
1535
+ },
1536
+ });
1537
+ const finalTextAfterTransientAttemptDecision = await finalTextAfterTransientAttempt.plannerFactory(task).next({
1538
+ task,
1539
+ turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: 'dsh-final-text-provider-attempt', workspace, request: { text: 'Find hotels' } },
1540
+ });
1541
+ assert.deepEqual(finalTextAfterTransientAttemptDecision, [{ kind: 'error', error: { code: 'PLANNER_TYPED_DECISION_REQUIRED', message: 'GoTry produced no typed capability decision; assistant prose was ignored.', retryable: false } }], 'a completed turn ignores an earlier attempt failure but never promotes finalResponse text');
1542
+ await finalTextAfterTransientAttempt.close();
1543
+ const attemptFailureWithoutTerminalEvent = await createDshEmbeddedBookingPlanner({
1544
+ runPort: {
1545
+ async run() {
1546
+ return {
1547
+ finalResponse: JSON.stringify({ kind: 'operation', action: searchRun }),
1548
+ events: [
1549
+ {
1550
+ type: 'assistant/attempt',
1551
+ data: { stream: [{ type: 'chunk', chunk: { type: 'finish', reason: { kind: 'error', failure: { code: 'RATE_LIMIT', status: 429, message: 'truncated capture' } } } }] },
1552
+ },
1553
+ ],
1554
+ };
1555
+ },
1556
+ async close() { },
1557
+ },
1558
+ });
1559
+ const attemptFailureWithoutTerminalEventDecision = await attemptFailureWithoutTerminalEvent.plannerFactory(task).next({
1560
+ task,
1561
+ turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: 'dsh-attempt-failure-without-terminal-event', workspace, request: { text: 'Find hotels' } },
1562
+ });
1563
+ assert.deepEqual(attemptFailureWithoutTerminalEventDecision, [{ kind: 'error', error: { code: 'PLANNER_PROVIDER_RATE_LIMITED', message: 'The planner provider rate-limited the request.', retryable: true } }], 'without a terminal event, the packed provider attempt failure remains authoritative over finalResponse text');
1564
+ await attemptFailureWithoutTerminalEvent.close();
1565
+ let proseAfterTransientAttemptRuns = 0;
1566
+ const proseAfterTransientAttempt = await createDshEmbeddedBookingPlanner({
1567
+ runPort: {
1568
+ async run() {
1569
+ proseAfterTransientAttemptRuns += 1;
1570
+ return {
1571
+ finalResponse: 'I recovered but still emitted no tool call.',
1572
+ events: [
1573
+ {
1574
+ type: 'assistant/attempt',
1575
+ data: { stream: [{ type: 'chunk', chunk: { type: 'finish', reason: { kind: 'error', failure: { code: 'EMPTY_RESPONSE', message: 'recovered attempt' } } } }] },
1576
+ },
1577
+ { type: 'turn/end', data: { reason: { kind: 'completed' } } },
1578
+ ],
1579
+ };
1580
+ },
1581
+ async close() { },
1582
+ },
1583
+ });
1584
+ const proseAfterTransientAttemptDecision = await proseAfterTransientAttempt.plannerFactory(task).next({
1585
+ task,
1586
+ turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: 'dsh-prose-after-provider-attempt', workspace, request: { text: 'Find hotels' } },
1587
+ });
1588
+ assert.equal(proseAfterTransientAttemptRuns, 3);
1589
+ assert.equal(proseAfterTransientAttemptDecision[0]?.kind === 'error' ? proseAfterTransientAttemptDecision[0].error.code : '', 'PLANNER_TYPED_DECISION_REQUIRED', 'a completed prose-only turn is not relabelled with a stale attempt failure');
1590
+ await proseAfterTransientAttempt.close();
1591
+ for (const [code, message] of [
1592
+ ['PLANNER_PROVIDER_AUTH_FAILED', 'The planner provider rejected authentication.'],
1593
+ ['PLANNER_PROVIDER_QUOTA_EXHAUSTED', 'The planner provider quota is exhausted.'],
1594
+ ['PLANNER_PROVIDER_RATE_LIMITED', 'The planner provider rate-limited the request.'],
1595
+ ['PLANNER_PROVIDER_UNAVAILABLE', 'The planner provider is temporarily unavailable.'],
1596
+ ['PLANNER_PROVIDER_REQUEST_REJECTED', 'The planner provider rejected the model request.'],
1597
+ ['PLANNER_PROVIDER_RESPONSE_INVALID', 'The planner provider returned an invalid response.'],
1598
+ ['PLANNER_CONFIGURATION_INVALID', 'The planner provider configuration is invalid.'],
1599
+ ]) {
1600
+ assert.equal(normalizeBookingErrorCode(code.toLowerCase()), code, `${code} survives the closed error-code registry`);
1601
+ assert.equal(safeBookingErrorMessage(code), message, `${code} has a non-provider-authored safe message`);
1602
+ }
1603
+ const diagnosticSecretMarker = 'ISSUE_3580_PROVIDER_SECRET_MUST_NOT_BE_LOGGED';
1604
+ const diagnosticLogs = [];
1605
+ const originalConsoleError = console.error;
1606
+ try {
1607
+ console.error = (...args) => { diagnosticLogs.push(args.map((value) => String(value)).join(' ')); };
1608
+ const proseWithSecret = await createDshEmbeddedBookingPlanner({
1609
+ runPort: {
1610
+ async run() {
1611
+ return {
1612
+ finalResponse: `provider prose ${diagnosticSecretMarker}`,
1613
+ notifications: [{ message: diagnosticSecretMarker }],
1614
+ events: [{ type: 'provider/debug', data: { message: diagnosticSecretMarker } }],
1615
+ };
1616
+ },
1617
+ async close() { },
1618
+ },
1619
+ });
1620
+ const proseWithSecretDecision = await proseWithSecret.plannerFactory(task).next({
1621
+ task,
1622
+ turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: 'dsh-secret-prose', workspace, request: { text: 'Find hotels' } },
1623
+ });
1624
+ assert.equal(proseWithSecretDecision[0]?.kind === 'error' ? proseWithSecretDecision[0].error.code : '', 'PLANNER_TYPED_DECISION_REQUIRED');
1625
+ await proseWithSecret.close();
1626
+ const forbiddenSecret = await createDshEmbeddedBookingPlanner({
1627
+ runPort: {
1628
+ async run() {
1629
+ const callId = 'call-forbidden-secret';
1630
+ return {
1631
+ finalResponse: '',
1632
+ events: [
1633
+ toolCall(`gotry_book_${diagnosticSecretMarker}`, '{}', callId),
1634
+ toolResult(callId, true, 'INVALID_ARGS'),
1635
+ ],
1636
+ };
1637
+ },
1638
+ async close() { },
1639
+ },
1640
+ });
1641
+ await assert.rejects(forbiddenSecret.plannerFactory(task).next({ task, turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: 'dsh-secret-tool', workspace, request: { text: 'Find hotels' } } }), (error) => !String(error).includes(diagnosticSecretMarker) && /planner_forbidden_tool/.test(String(error)), 'provider-authored tool names do not enter thrown errors');
1642
+ await forbiddenSecret.close();
1643
+ }
1644
+ finally {
1645
+ console.error = originalConsoleError;
1646
+ }
1647
+ assert.equal(diagnosticLogs.some((line) => line.includes(diagnosticSecretMarker)), false, 'provider-authored text, notifications, events, and tool names are absent from planner logs');
1648
+ const forbiddenPort = {
1649
+ async run() {
1650
+ return {
1651
+ finalResponse: '',
1652
+ events: [
1653
+ toolCall('gotry_book', JSON.stringify({ decision: { kind: 'operation', action: { ...searchRun, kind: 'book' } } }), 'call-forbidden'),
1654
+ toolResult('call-forbidden', true, 'INVALID_ARGS'),
1655
+ ...successfulToolEvents('booking_search_hotels', JSON.stringify({ decision: { kind: 'operation', action: searchRun } }), 'call-after-forbidden'),
1656
+ ],
1657
+ };
1658
+ },
1659
+ async close() { },
1660
+ };
1661
+ const forbidden = await createDshEmbeddedBookingPlanner({ runPort: forbiddenPort });
1662
+ await assert.rejects(forbidden.plannerFactory(task).next({
1663
+ task,
1664
+ turn: {
1665
+ schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: 'dsh-turn-4',
1666
+ workspace: { ...workspace, capabilities: { ...workspace.capabilities, allowedActions: [...workspace.capabilities.allowedActions] } },
1667
+ request: { text: '帮我下单' },
1668
+ },
1669
+ }), /planner_forbidden_tool/);
1670
+ let forbiddenCrossRunRuns = 0;
1671
+ const forbiddenCrossRun = await createDshEmbeddedBookingPlanner({
1672
+ runPort: {
1673
+ async run() {
1674
+ forbiddenCrossRunRuns += 1;
1675
+ return forbiddenCrossRunRuns === 1
1676
+ ? {
1677
+ finalResponse: '',
1678
+ events: [
1679
+ toolCall('gotry_book', JSON.stringify({ decision: { kind: 'operation', action: { ...searchRun, kind: 'book' } } }), 'call-cross-run-forbidden'),
1680
+ toolResult('call-cross-run-forbidden', true, 'INVALID_ARGS'),
1681
+ ],
1682
+ }
1683
+ : {
1684
+ finalResponse: '',
1685
+ events: successfulToolEvents('booking_search_hotels', JSON.stringify({ decision: { kind: 'operation', action: searchRun } }), 'call-cross-run-search'),
1686
+ };
1687
+ },
1688
+ async close() { },
1689
+ },
1690
+ });
1691
+ await assert.rejects(forbiddenCrossRun.plannerFactory(task).next({
1692
+ task,
1693
+ turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: 'dsh-cross-run-forbidden', workspace, request: { text: '帮我下单' } },
1694
+ }), /planner_forbidden_tool/, 'a forbidden tool cannot be washed into a valid action by a later provider run');
1695
+ assert.equal(forbiddenCrossRunRuns, 1, 'safety errors fail closed without a correction run');
1696
+ await forbiddenCrossRun.close();
1697
+ let invalidCrossRunRuns = 0;
1698
+ const invalidCrossRun = await createDshEmbeddedBookingPlanner({
1699
+ runPort: {
1700
+ async run() {
1701
+ invalidCrossRunRuns += 1;
1702
+ return invalidCrossRunRuns === 1
1703
+ ? {
1704
+ finalResponse: '',
1705
+ events: [
1706
+ toolCall('booking_search_hotels', 'not-json', 'call-cross-run-invalid'),
1707
+ toolResult('call-cross-run-invalid', true, 'INVALID_ARGS'),
1708
+ ],
1709
+ }
1710
+ : {
1711
+ finalResponse: '',
1712
+ events: successfulToolEvents('booking_search_hotels', JSON.stringify({ decision: { kind: 'operation', action: searchRun } }), 'call-cross-run-invalid-recovery'),
1713
+ };
1714
+ },
1715
+ async close() { },
1716
+ },
1717
+ });
1718
+ await assert.rejects(invalidCrossRun.plannerFactory(task).next({
1719
+ task,
1720
+ turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: 'dsh-cross-run-invalid', workspace, request: { text: 'Find hotels' } },
1721
+ }), /planner_invalid_tool_arguments/, 'a schema-invalid call cannot be washed into a valid action by a later provider run');
1722
+ assert.equal(invalidCrossRunRuns, 1, 'event-authority schema errors fail closed without a correction run');
1723
+ await invalidCrossRun.close();
1724
+ const confirmedTask = {
1725
+ ...task,
1726
+ taskId: 'task-dsh-confirmed',
1727
+ contextRef: 'ctx-dsh-confirmed',
1728
+ revision: 1,
1729
+ allowedActions: ['search.patch', 'search.run', 'offers.query', 'offer.check', 'checkout.prepare'],
1730
+ availability: { initialized: true, recoveryStarted: true, availabilityPhase: 'terminal', activeHotelOrdinal: 0, hotelRefs: ['hotel-confirmed'], hotels: { 'hotel-confirmed': { hotelRef: 'hotel-confirmed', status: 'confirmed', generation: 1, generationNo: 1, currentOfferRefs: ['offer-confirmed'], invalidatedOfferRefs: [], tombstonedOfferRefs: [], tombstonedOfferVersionRefs: [], checksIssued: 1, checkCount: 1, offerQueriesIssued: 0, freshOffersRequired: false, lastEvidence: 'confirmed', currentGeneration: { generationId: 'hotel-confirmed:generation:1', source: { kind: 'workspace_snapshot', workspaceDigest: 'a'.repeat(64), workspaceRevision: 0 }, offerSetDigest: 'b'.repeat(64), orderedOfferRefs: ['offer-confirmed'], evidence: 'complete', valid: false } } }, attempts: [], queryReservations: [], terminal: { code: 'availability_confirmed', hotelRefs: ['hotel-confirmed'], reason: 'confirmed', evidence: 'conclusive' } },
1731
+ lastCompletedAction: completedCheckpoint({
1732
+ ...searchRun,
1733
+ kind: 'offer.check',
1734
+ actionId: 'action-confirmed-check',
1735
+ contextRef: 'ctx-dsh-confirmed',
1736
+ factRefs: [],
1737
+ input: { offerRef: 'offer-confirmed', offerVersionRef: 'offer-confirmed:v1' },
1738
+ }, 'dsh-confirmed-check-turn'),
1739
+ lastReceipt: {
1740
+ schemaVersion: 'booking.surface', kind: 'action.receipt', actionId: 'action-confirmed-check', contextRef: 'ctx-dsh-confirmed', status: 'applied', revision: 1,
1741
+ observation: { kind: 'offer.availability', offerRef: 'offer-confirmed', checkedOfferVersionRef: 'offer-confirmed:v1', currentOfferVersionRef: 'offer-confirmed:v1', verifiedOfferRef: 'verified-offer-confirmed', available: true, changedFactRefs: [] },
1742
+ resultContract: { outcome: 'complete', hardCriteriaMet: true, factRefs: ['offer:confirmed'], gapCodes: [], blockers: [], relaxationsApplied: [] },
1743
+ },
1744
+ };
1745
+ const confirmedWorkspace = {
1746
+ ...workspace,
1747
+ contextRef: confirmedTask.contextRef,
1748
+ revision: 1,
1749
+ visibleHotels: [{ hotelRef: 'hotel-confirmed', name: 'Confirmed Hotel', factRefs: [] }],
1750
+ loadedOffers: [{ offerRef: 'offer-confirmed', offerVersionRef: 'offer-confirmed:v1', hotelRef: 'hotel-confirmed', evidenceLevel: 'rate_loaded', factRefs: [] }],
1751
+ shortlistedOfferRefs: ['offer-confirmed'],
1752
+ selectedOfferRef: 'offer-confirmed',
1753
+ verifiedOffer: { offerRef: 'offer-confirmed', offerVersionRef: 'offer-confirmed:v1', verifiedOfferRef: 'verified-offer-confirmed', expiresAt: '2026-09-09T11:00:00.000Z' },
1754
+ capabilities: { surface: 'tenant', allowedActions: [...confirmedTask.allowedActions] },
1755
+ };
1756
+ confirmedTask.workspaceSnapshot = confirmedWorkspace;
1757
+ const confirmedPrompts = [];
1758
+ const confirmedCheckout = await createDshEmbeddedBookingPlanner({
1759
+ runPort: {
1760
+ async run(prompt) {
1761
+ confirmedPrompts.push(prompt);
1762
+ return { finalResponse: '', events: successfulToolEvents('booking_prepare_booking', JSON.stringify({ decision: { kind: 'operation', action: { ...checkoutPrepare, contextRef: confirmedTask.contextRef }, intent: { target: 'checkout.prepared' } } }), 'call-confirmed-checkout') };
1763
+ },
1764
+ async close() { },
1765
+ },
1766
+ });
1767
+ const confirmedCheckoutDecision = await confirmedCheckout.plannerFactory(confirmedTask).next({ task: confirmedTask, turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: confirmedTask.taskId, turnId: 'dsh-confirmed-checkout', workspace: confirmedWorkspace, request: { text: '继续预订' } } });
1768
+ assertRuntimeMaterializedOperation(confirmedCheckoutDecision, 'checkout.prepare', checkoutPrepare.input, confirmedTask, 'availability_confirmed can still produce checkout.prepare through the canonical booking_prepare_booking tool');
1769
+ assert.deepEqual(confirmedTask.allowedActions, ['search.patch', 'search.run', 'offers.query', 'offer.check', 'checkout.prepare'], 'prompt projection does not mutate durable task allowedActions');
1770
+ assert.deepEqual(promptPayload(confirmedPrompts[0]).task.allowedActions, ['checkout.prepare'], 'availability_confirmed prompt narrows planner-visible actions to checkout.prepare');
1771
+ assert.deepEqual(promptPayload(confirmedPrompts[0]).turn.workspace.capabilities.allowedActions, ['checkout.prepare'], 'availability_confirmed prompt exposes one consistent action list in the turn workspace');
1772
+ assert.deepEqual(confirmedWorkspace.capabilities.allowedActions, confirmedTask.allowedActions, 'prompt projection does not mutate the authoritative turn workspace');
1773
+ assert.equal(promptPayload(confirmedPrompts[0]).task.availability.terminalCode, 'availability_confirmed', 'confirmed prompt still exposes the availability terminal reason');
1774
+ await confirmedCheckout.close();
1775
+ const noCheckoutPrompt = [];
1776
+ const noCheckoutTask = { ...confirmedTask, taskId: 'task-dsh-confirmed-no-checkout', allowedActions: ['search.run'] };
1777
+ const noCheckoutPlanner = await createDshEmbeddedBookingPlanner({
1778
+ runPort: {
1779
+ async run(prompt) {
1780
+ noCheckoutPrompt.push(prompt);
1781
+ return { finalResponse: '', events: successfulToolEvents('booking_prepare_booking', JSON.stringify({ kind: 'terminal' }), 'call-confirmed-no-checkout') };
1782
+ },
1783
+ async close() { },
1784
+ },
1785
+ });
1786
+ await noCheckoutPlanner.plannerFactory(noCheckoutTask).next({ task: noCheckoutTask, turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: noCheckoutTask.taskId, turnId: 'dsh-confirmed-no-checkout', workspace: { ...confirmedWorkspace, capabilities: { surface: 'tenant', allowedActions: [...noCheckoutTask.allowedActions] } }, request: { text: '继续预订' } } });
1787
+ assert.deepEqual(promptPayload(noCheckoutPrompt[0]).task.allowedActions, [], 'confirmed prompt never broadens a durable allowlist that lacks checkout.prepare');
1788
+ assert.deepEqual(promptPayload(noCheckoutPrompt[0]).turn.workspace.capabilities.allowedActions, [], 'confirmed prompt does not leak a broader workspace capability list');
1789
+ await noCheckoutPlanner.close();
1790
+ const confirmedProseOnly = await createDshEmbeddedBookingPlanner({
1791
+ runPort: {
1792
+ async run(prompt) {
1793
+ confirmedPrompts.push(prompt);
1794
+ return { finalResponse: JSON.stringify({ kind: 'operation', action: { ...checkoutPrepare, contextRef: confirmedTask.contextRef } }), events: [] };
1795
+ },
1796
+ async close() { },
1797
+ },
1798
+ });
1799
+ const confirmedProseOnlyDecision = await confirmedProseOnly.plannerFactory(confirmedTask).next({ task: confirmedTask, turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: confirmedTask.taskId, turnId: 'dsh-confirmed-prose-only', workspace: confirmedWorkspace, request: { text: '继续预订' } } });
1800
+ assert.equal(confirmedProseOnlyDecision[0]?.kind === 'error' ? confirmedProseOnlyDecision[0].error.code : '', 'PLANNER_TYPED_DECISION_REQUIRED', 'availability_confirmed does not make prose-only checkout JSON executable');
1801
+ assert.deepEqual(promptPayload(confirmedPrompts.at(-3)).task.allowedActions, ['checkout.prepare'], 'prose-only correction starts from the narrowed confirmed prompt');
1802
+ await confirmedProseOnly.close();
1803
+ const finalityPlanner = await createDshEmbeddedBookingPlanner({
1804
+ runPort: {
1805
+ async run(prompt) {
1806
+ confirmedPrompts.push(prompt);
1807
+ return { finalResponse: '', events: successfulToolEvents('booking_prepare_booking', JSON.stringify({ kind: 'terminal' }), 'call-confirmed-terminal') };
1808
+ },
1809
+ async close() { },
1810
+ },
1811
+ });
1812
+ assert.deepEqual(await finalityPlanner.plannerFactory(confirmedTask).next({ task: confirmedTask, turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: confirmedTask.taskId, turnId: 'dsh-confirmed-terminal', workspace: confirmedWorkspace, request: { text: '只检查可订状态' } } }), [{ kind: 'terminal', terminal: { status: 'completed', summary: 'offer_available', factRefs: ['offer:confirmed'] } }], 'terminal intent is materialized from the authoritative availability receipt');
1813
+ assert.deepEqual(promptPayload(confirmedPrompts.at(-1)).task.allowedActions, ['checkout.prepare'], 'terminal intent still receives the narrowed confirmed prompt');
1814
+ await finalityPlanner.close();
1815
+ const modelErrorPlanner = await createDshEmbeddedBookingPlanner({
1816
+ runPort: {
1817
+ async run() {
1818
+ return { finalResponse: '', events: successfulToolEvents('booking_prepare_booking', JSON.stringify({ decision: { kind: 'error', error: { code: 'PLANNER_FAILED', message: 'forged', retryable: false } } }), 'call-confirmed-error') };
1819
+ },
1820
+ async close() { },
1821
+ },
1822
+ });
1823
+ await assert.rejects(modelErrorPlanner.plannerFactory(confirmedTask).next({ task: confirmedTask, turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: confirmedTask.taskId, turnId: 'dsh-confirmed-error', workspace: confirmedWorkspace, request: { text: '只检查可订状态' } } }), /planner_nonoperation_runtime_owned/, 'the model cannot impersonate a provider/runtime error decision');
1824
+ await modelErrorPlanner.close();
1825
+ const terminalTask = {
1826
+ schemaVersion: 'booking.surface', taskId: 'task-dsh-terminal', contextRef: 'ctx-dsh-terminal', surface: 'tenant', revision: 0,
1827
+ allowedActions: ['search.run'], userTurnCount: 1, lastTurnId: 'dsh-terminal-turn-1', operationCount: 0, phase: 'planning', lastSequence: 0,
1828
+ availability: { initialized: true, recoveryStarted: true, availabilityPhase: 'terminal', activeHotelOrdinal: 0, hotelRefs: [], hotels: {}, attempts: [], queryReservations: [], terminal: { code: 'availability_exhausted_complete', hotelRefs: [], reason: 'no_current_offers', evidence: 'conclusive' } },
1829
+ };
1830
+ const terminalWorkspace = {
1831
+ schemaVersion: 'booking.surface', contextRef: terminalTask.contextRef, surface: 'tenant', revision: 0, locale: 'en-US', currency: 'AED',
1832
+ searchDraft: {}, results: { status: 'ready', resultCount: 1, searchSessionRef: 'search-terminal-1' }, visibleHotels: [], loadedOffers: [], shortlistedOfferRefs: [],
1833
+ capabilities: { surface: 'tenant', allowedActions: ['search.run'] },
1834
+ };
1835
+ terminalTask.workspaceSnapshot = terminalWorkspace;
1836
+ let terminalRuns = 0;
1837
+ const terminalPort = {
1838
+ async run() {
1839
+ terminalRuns += 1;
1840
+ return terminalRuns === 1
1841
+ ? {
1842
+ finalResponse: '',
1843
+ events: successfulToolEvents('booking_search_hotels', JSON.stringify({ decision: { kind: 'operation', action: { ...searchRun, schemaVersion: 'booking.surface', contextRef: terminalTask.contextRef, actionId: 'action-dsh-terminal' }, intent: { target: 'search.results' } } }), 'call-terminal-operation'),
1844
+ }
1845
+ : {
1846
+ finalResponse: '',
1847
+ events: successfulToolEvents('booking_search_hotels', JSON.stringify({ kind: 'terminal' }), 'call-terminal-continuation'),
1848
+ };
1849
+ },
1850
+ async close() { },
1851
+ };
1852
+ const terminalAdapter = await createDshEmbeddedBookingPlanner({ runPort: terminalPort });
1853
+ const terminalSession = terminalAdapter.plannerFactory(terminalTask);
1854
+ const terminalDecisions = await terminalSession.next({ task: terminalTask, turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: terminalTask.taskId, turnId: 'dsh-terminal-turn-1', workspace: terminalWorkspace, request: { text: 'find hotels' } } });
1855
+ assert.equal(terminalDecisions[0]?.kind, 'operation', 'real DSH adapter accepts the canonical typed action');
1856
+ assert.equal(terminalDecisions[0]?.kind === 'operation' ? terminalDecisions[0].action.schemaVersion : '', 'booking.surface');
1857
+ const terminalActionId = terminalDecisions[0]?.kind === 'operation' ? terminalDecisions[0].action.actionId : 'action-dsh-terminal';
1858
+ const terminalContinuation = { schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: terminalTask.taskId, workspace: { ...terminalWorkspace, revision: 1, results: { ...terminalWorkspace.results, searchSessionRef: 'search-terminal-1' } }, receipt: { schemaVersion: 'booking.surface', kind: 'action.receipt', actionId: terminalActionId, contextRef: terminalTask.contextRef, status: 'applied', revision: 1, observation: { kind: 'search.state', searchSessionRef: 'search-terminal-1', resultCount: 1 }, resultContract: { outcome: 'complete', hardCriteriaMet: true, factRefs: [], gapCodes: [], blockers: [], relaxationsApplied: [] } } };
1859
+ const terminalContinuationWorkspace = terminalContinuation.workspace;
1860
+ if (terminalDecisions[0]?.kind !== 'operation')
1861
+ throw new Error('terminal proof did not produce an operation checkpoint');
1862
+ const terminalContinuationDecision = await terminalSession.next({ task: { ...terminalTask, revision: 1, workspaceSnapshot: terminalContinuationWorkspace, lastCompletedAction: completedCheckpoint(terminalDecisions[0].action, 'dsh-terminal-turn-1'), lastReceipt: terminalContinuation.receipt }, turn: terminalContinuation });
1863
+ assert.deepEqual(terminalContinuationDecision, [{ kind: 'terminal', terminal: { status: 'completed', summary: 'search_results_ready', factRefs: [] } }], 'real DSH planner materializes receipt continuation terminal state');
1864
+ const gappedSearchReceipt = {
1865
+ ...terminalContinuation.receipt,
1866
+ resultContract: { ...terminalContinuation.receipt.resultContract, gapCodes: ['criterion_must_not_met'] },
1867
+ };
1868
+ const gappedSearchTerminal = await terminalSession.next({
1869
+ task: { ...terminalTask, revision: 1, workspaceSnapshot: terminalContinuationWorkspace, lastCompletedAction: completedCheckpoint(terminalDecisions[0].action, 'dsh-terminal-turn-1'), lastReceipt: gappedSearchReceipt },
1870
+ turn: { ...terminalContinuation, receipt: gappedSearchReceipt },
1871
+ });
1872
+ assert.deepEqual(gappedSearchTerminal, [{ kind: 'terminal', terminal: { status: 'stopped', summary: 'booking_constraints_unmet', factRefs: [] } }], 'a receipt that declares a hard-criteria gap cannot be promoted to a completed search terminal');
1873
+ await assert.rejects(terminalAdapter.plannerFactory(terminalTask).next({
1874
+ task: terminalTask,
1875
+ turn: { schemaVersion: 'booking.surface', kind: 'user.turn.ingress', requestKey: 'browser-request', surfaceHint: 'tenant', workspace: { schemaVersion: 'booking.surface', revision: 0, locale: 'en-US', currency: 'AED', searchDraft: {}, results: { status: 'idle' }, visibleHotels: [], loadedOffers: [], shortlistedOfferRefs: [] }, request: { text: 'browser ingress must be BFF-bound' } },
1876
+ }), /planner_identity_required/);
1877
+ // Regression: an index-keyed occupancy object is not a canonical array. It
1878
+ // must fail at the same runPort -> tool/call -> parseToolDecision boundary the
1879
+ // real adapter uses; the adapter never rewrites it into executable data.
1880
+ const indexKeyedRoomsPort = {
1881
+ async run() {
1882
+ return {
1883
+ finalResponse: '',
1884
+ events: [
1885
+ ...successfulToolEvents('booking_search_hotels', JSON.stringify({
1886
+ decision: {
1887
+ kind: 'operation',
1888
+ action: {
1889
+ schemaVersion: 'booking.surface',
1890
+ kind: 'search.patch',
1891
+ actionId: 'action-dsh-index-keyed-rooms',
1892
+ contextRef: task.contextRef,
1893
+ expectedRevision: 0,
1894
+ reason: 'Patch occupancy from an index-keyed model payload.',
1895
+ factRefs: [],
1896
+ input: { patch: { occupancy: { rooms: { '0': { adults: 2, childAges: {} }, '1': {} } } } },
1897
+ },
1898
+ },
1899
+ }), 'call-index-keyed-rooms'),
1900
+ ],
1901
+ };
1902
+ },
1903
+ async close() { },
1904
+ };
1905
+ const indexKeyedRooms = await createDshEmbeddedBookingPlanner({ runPort: indexKeyedRoomsPort });
1906
+ await assert.rejects(indexKeyedRooms.plannerFactory(task).next({
1907
+ task,
1908
+ turn: {
1909
+ schemaVersion: 'booking.surface',
1910
+ kind: 'user.turn',
1911
+ taskId: task.taskId,
1912
+ turnId: 'dsh-turn-index-keyed-rooms',
1913
+ workspace: { ...workspace, capabilities: { ...workspace.capabilities, allowedActions: [...workspace.capabilities.allowedActions] } },
1914
+ request: { text: 'Two adults, one room' },
1915
+ },
1916
+ }), /planner_invalid_action/, 'index-keyed occupancy rooms are not canonical raw arguments');
1917
+ const wrongRevision = await createDshEmbeddedBookingPlanner({ runPort: {
1918
+ async run() { return { finalResponse: '', events: successfulToolEvents('booking_search_hotels', JSON.stringify({ decision: { kind: 'operation', action: { ...searchRun, expectedRevision: task.revision + 1 } } }), 'call-wrong-revision') }; },
1919
+ async close() { },
1920
+ } });
1921
+ await assert.rejects(wrongRevision.plannerFactory(task).next({ task, turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: 'dsh-wrong-revision', workspace, request: { text: 'Find hotels' } } }), /planner_revision_mismatch/, 'a success receipt cannot authorize a model revision different from task.revision');
1922
+ await wrongRevision.close();
1923
+ let unsafeActionIdRuns = 0;
1924
+ const unsafeActionId = await createDshEmbeddedBookingPlanner({ runPort: {
1925
+ async run() {
1926
+ unsafeActionIdRuns += 1;
1927
+ return {
1928
+ finalResponse: '',
1929
+ events: successfulToolEvents('booking_search_hotels', JSON.stringify({ decision: { kind: 'operation', action: { ...searchRun, actionId: 'bad/id' } } }), 'call-unsafe-action-id'),
1930
+ };
1931
+ },
1932
+ async close() { },
1933
+ } });
1934
+ await assert.rejects(unsafeActionId.plannerFactory(task).next({ task, turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: 'dsh-unsafe-action-id', workspace, request: { text: 'Find hotels' } } }), /planner_invalid_action|unsafe_action_id/, 'a success receipt cannot authorize an unsafe model-authored action id');
1935
+ assert.equal(unsafeActionIdRuns, 1, 'unsafe action ids fail closed without a cross-run retry');
1936
+ await unsafeActionId.close();
1937
+ async function assertTraceRejected(events, error, message) {
1938
+ const planner = await createDshEmbeddedBookingPlanner({ runPort: { async run() { return { finalResponse: '', events }; }, async close() { } } });
1939
+ await assert.rejects(planner.plannerFactory(task).next({ task, turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: `dsh-trace-${message}`, workspace, request: { text: 'Find hotels' } } }), error, message);
1940
+ await planner.close();
1941
+ }
1942
+ const traceCall = toolCall('booking_search_hotels', JSON.stringify({ decision: { kind: 'operation', action: searchRun } }), 'call-trace');
1943
+ const traceResult = toolResult('call-trace');
1944
+ await assertTraceRejected([traceCall, toolCall('booking_search_hotels', JSON.stringify({ decision: { kind: 'operation', action: searchRun } }), 'call-trace'), traceResult], /planner_tool_call_duplicate_call_id/, 'duplicate callId fails closed');
1945
+ await assertTraceRejected([traceCall, traceResult, traceResult], /planner_tool_result_duplicate/, 'duplicate result fails closed');
1946
+ await assertTraceRejected([toolResult('call-unmatched')], /planner_tool_result_unmatched/, 'unmatched result fails closed');
1947
+ await assertTraceRejected([traceResult, traceCall], /planner_tool_result_out_of_order/, 'out-of-order result fails closed');
1948
+ // Regression for #282: a non-empty room that carries child ages but omits
1949
+ // adults is a semantic validation failure, not disposable representation.
1950
+ // DSH exposes INVALID_ARGS to the model, which must emit a later canonical
1951
+ // call inside the same run; the adapter does not start a fresh provider run.
1952
+ let occupancyRepairRuns = 0;
1953
+ const occupancyRepairPort = {
1954
+ async run() {
1955
+ occupancyRepairRuns += 1;
1956
+ return {
1957
+ finalResponse: '',
1958
+ events: [
1959
+ toolCall('booking_search_hotels', JSON.stringify({
1960
+ decision: { kind: 'operation', action: { ...searchRun, kind: 'search.patch', actionId: 'action-dsh-282-occupancy-invalid', reason: 'Preserve both requested rooms and their child ages.', input: { patch: { occupancy: { rooms: [{ adults: 2, childAges: [6] }, { childAges: [4] }] } } } }, intent: { target: 'search.results' } },
1961
+ }), 'call-occupancy-invalid'),
1962
+ toolResult('call-occupancy-invalid', true, 'INVALID_ARGS'),
1963
+ ...successfulToolEvents('booking_search_hotels', JSON.stringify({
1964
+ decision: { kind: 'operation', action: { ...searchRun, kind: 'search.patch', actionId: 'action-dsh-282-occupancy-valid', reason: 'Preserve both requested rooms and their child ages.', input: { patch: { occupancy: { rooms: [{ adults: 2, childAges: [6] }, { adults: 1, childAges: [4] }] } } } }, intent: { target: 'search.results' } },
1965
+ }), 'call-occupancy-valid'),
1966
+ ],
1967
+ };
1968
+ },
1969
+ async close() { },
1970
+ };
1971
+ const occupancyRepair = await createDshEmbeddedBookingPlanner({ runPort: occupancyRepairPort });
1972
+ const occupancyRepairDecision = await occupancyRepair.plannerFactory(task).next({
1973
+ task,
1974
+ turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: 'dsh-turn-282-occupancy', workspace, request: { text: '请保留两个房间和各自的儿童年龄:第一间2成人1儿童6岁,第二间1成人1儿童4岁' } },
1975
+ });
1976
+ assert.equal(occupancyRepairRuns, 1, 'missing adults is corrected within the same provider run');
1977
+ assert.deepEqual(occupancyRepairDecision[0]?.kind === 'operation' ? occupancyRepairDecision[0].action.input : undefined, { patch: { occupancy: { rooms: [{ adults: 2, childAges: [6] }, { adults: 1, childAges: [4] }] } } }, 'corrected occupancy preserves both non-empty rooms and child criteria');
1978
+ // Event-order regression (PR461 review F2): an INVALID_ARGS rejection that
1979
+ // arrives after the accepted correction succeeded has not been observed by
1980
+ // the model at decision time; the planning authority must fail closed.
1981
+ let lateInvalidResultRuns = 0;
1982
+ const lateInvalidResultPort = {
1983
+ async run() {
1984
+ lateInvalidResultRuns += 1;
1985
+ return {
1986
+ finalResponse: '',
1987
+ events: [
1988
+ toolCall('booking_search_hotels', JSON.stringify({
1989
+ decision: { kind: 'operation', action: { ...searchRun, kind: 'search.patch', actionId: 'action-dsh-order-late-invalid', reason: 'Late invalid call.', input: { patch: { occupancy: { rooms: [{ adults: 2, childAges: [6] }, { childAges: [4] }] } } } }, intent: { target: 'search.results' } },
1990
+ }), 'call-order-late-invalid'),
1991
+ toolCall('booking_search_hotels', JSON.stringify({
1992
+ decision: { kind: 'operation', action: { ...searchRun, kind: 'search.patch', actionId: 'action-dsh-order-late-valid', reason: 'Late valid call.', input: { patch: { occupancy: { rooms: [{ adults: 2, childAges: [6] }, { adults: 1, childAges: [4] }] } } } }, intent: { target: 'search.results' } },
1993
+ }), 'call-order-late-valid'),
1994
+ toolResult('call-order-late-valid'),
1995
+ toolResult('call-order-late-invalid', true, 'INVALID_ARGS'),
1996
+ ],
1997
+ };
1998
+ },
1999
+ async close() { },
2000
+ };
2001
+ const lateInvalidResult = await createDshEmbeddedBookingPlanner({ runPort: lateInvalidResultPort });
2002
+ await assert.rejects(lateInvalidResult.plannerFactory(task).next({
2003
+ task,
2004
+ turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: 'dsh-turn-order-late', workspace, request: { text: 'Find hotels' } },
2005
+ }), /planner_typed_decision_after_candidate/, 'rejection feedback that lands after the accepted correction must fail closed');
2006
+ assert.equal(lateInvalidResultRuns, 1, 'late rejection result fails closed within a single provider run');
2007
+ await lateInvalidResult.close();
2008
+ // Event-order regression (PR461 review F2): the correction call itself is
2009
+ // emitted before the model receives the INVALID_ARGS rejection for the
2010
+ // earlier invalid call; the planning authority must refuse to honour it.
2011
+ let preFeedbackCorrectionRuns = 0;
2012
+ const preFeedbackCorrectionPort = {
2013
+ async run() {
2014
+ preFeedbackCorrectionRuns += 1;
2015
+ return {
2016
+ finalResponse: '',
2017
+ events: [
2018
+ toolCall('booking_search_hotels', JSON.stringify({
2019
+ decision: { kind: 'operation', action: { ...searchRun, kind: 'search.patch', actionId: 'action-dsh-order-pre-invalid', reason: 'Pre-feedback invalid call.', input: { patch: { occupancy: { rooms: [{ adults: 2, childAges: [6] }, { childAges: [4] }] } } } }, intent: { target: 'search.results' } },
2020
+ }), 'call-order-pre-invalid'),
2021
+ toolCall('booking_search_hotels', JSON.stringify({
2022
+ decision: { kind: 'operation', action: { ...searchRun, kind: 'search.patch', actionId: 'action-dsh-order-pre-valid', reason: 'Pre-feedback valid call.', input: { patch: { occupancy: { rooms: [{ adults: 2, childAges: [6] }, { adults: 1, childAges: [4] }] } } } }, intent: { target: 'search.results' } },
2023
+ }), 'call-order-pre-valid'),
2024
+ toolResult('call-order-pre-invalid', true, 'INVALID_ARGS'),
2025
+ toolResult('call-order-pre-valid'),
2026
+ ],
2027
+ };
2028
+ },
2029
+ async close() { },
2030
+ };
2031
+ const preFeedbackCorrection = await createDshEmbeddedBookingPlanner({ runPort: preFeedbackCorrectionPort });
2032
+ await assert.rejects(preFeedbackCorrection.plannerFactory(task).next({
2033
+ task,
2034
+ turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: 'dsh-turn-order-pre', workspace, request: { text: 'Find hotels' } },
2035
+ }), /planner_typed_decision_after_candidate/, 'correction emitted before its INVALID_ARGS feedback must fail closed');
2036
+ assert.equal(preFeedbackCorrectionRuns, 1, 'pre-feedback correction fails closed within a single provider run');
2037
+ await preFeedbackCorrection.close();
2038
+ // A prose-only first response receives the same bounded correction treatment
2039
+ // as a malformed typed call; a valid second typed call is returned immediately.
2040
+ let proseRecoveryRuns = 0;
2041
+ const proseRecoveryPort = {
2042
+ async run(prompt) {
2043
+ proseRecoveryRuns += 1;
2044
+ if (proseRecoveryRuns === 1)
2045
+ return { finalResponse: '我会为你搜索酒店。', events: [] };
2046
+ assert.match(prompt, /no booking capability tool call/, 'prose correction is sent as the next counted prompt');
2047
+ return { finalResponse: '', events: successfulToolEvents('booking_search_hotels', JSON.stringify({ decision: { kind: 'operation', action: searchRun, intent: { target: 'search.results' } } }), 'call-prose-correction') };
2048
+ },
2049
+ async close() { },
2050
+ };
2051
+ const proseRecovery = await createDshEmbeddedBookingPlanner({ runPort: proseRecoveryPort });
2052
+ const proseRecoveryDecision = await proseRecovery.plannerFactory(task).next({
2053
+ task,
2054
+ turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: 'dsh-turn-282-prose', workspace, request: { text: 'Find hotels' } },
2055
+ });
2056
+ assert.equal(proseRecoveryRuns, 2, 'prose correction uses the second counted call');
2057
+ assertRuntimeMaterializedOperation(proseRecoveryDecision, 'search.run', {}, task, 'valid correction is returned instead of discarded');
2058
+ // A malformed typed event is an authority error and fails on the first call.
2059
+ // A provider failure likewise propagates instead of being silently swallowed.
2060
+ let malformedRuns = 0;
2061
+ const malformedPort = {
2062
+ async run() {
2063
+ malformedRuns += 1;
2064
+ return {
2065
+ finalResponse: '',
2066
+ events: successfulToolEvents('booking_search_hotels', JSON.stringify({ decision: { kind: 'operation', action: { ...searchRun, input: { patch: { occupancy: { rooms: [{ childAges: [4] }] } } } } } }), `call-malformed-${malformedRuns}`),
2067
+ };
2068
+ },
2069
+ async close() { },
2070
+ };
2071
+ const malformed = await createDshEmbeddedBookingPlanner({ runPort: malformedPort });
2072
+ await assert.rejects(malformed.plannerFactory(task).next({ task, turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: 'dsh-turn-282-malformed', workspace, request: { text: 'Find hotels' } } }), /planner_invalid_action/, 'repeated malformed output fails closed');
2073
+ assert.equal(malformedRuns, 1, 'malformed typed output is not retried across provider runs');
2074
+ let providerRuns = 0;
2075
+ const providerErrorPort = {
2076
+ async run() {
2077
+ providerRuns += 1;
2078
+ throw new Error('provider_transport_failure');
2079
+ },
2080
+ async close() { },
2081
+ };
2082
+ const providerError = await createDshEmbeddedBookingPlanner({ runPort: providerErrorPort });
2083
+ await assert.rejects(providerError.plannerFactory(task).next({ task, turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: 'dsh-turn-282-provider', workspace, request: { text: 'Find hotels' } } }), /provider_transport_failure/, 'provider failures remain visible to the caller');
2084
+ assert.equal(providerRuns, 1, 'provider failures are not silently swallowed or retried');
2085
+ await Promise.all([adapter.close(), textChannel.close(), fencedTextChannel.close(), stringifiedAction.close(), invalidThenValid.close(), invalidWithoutSchemaRejection.close(), unauthorised.close(), unauthorisedTyped.close(), multipleTypedSuccesses.close(), missingToolResult.close(), mismatchedToolResult.close(), fragmentRef.close(), truncatedRecovery.close(), sanitizedRef.close(), unsafeRef.close(), uiOffers.close(), forbidden.close(), terminalAdapter.close(), indexKeyedRooms.close(), occupancyRepair.close(), lateInvalidResult.close(), preFeedbackCorrection.close(), proseRecovery.close(), malformed.close(), providerError.close(), reservedText.close(), finalTextAfterTransientAttempt.close(), attemptFailureWithoutTerminalEvent.close()]);
2086
+ // Turn deadline/port-isolation contract. These cases intentionally sit at
2087
+ // the planner boundary: a provider that never settles must not hold the
2088
+ // booking workspace for the provider's (potentially minutes-long) idle wait,
2089
+ // and timing out task A must not close task B's runtime.
2090
+ const plannerTimeoutTask = {
2091
+ ...task,
2092
+ taskId: 'task-dsh-timeout-a',
2093
+ lastTurnId: 'dsh-turn-timeout-a',
2094
+ contextRef: 'ctx-dsh-timeout-a',
2095
+ workspaceSnapshot: { ...workspace, contextRef: 'ctx-dsh-timeout-a' },
2096
+ };
2097
+ const plannerTimeoutTurn = {
2098
+ schemaVersion: 'booking.surface',
2099
+ kind: 'user.turn',
2100
+ taskId: plannerTimeoutTask.taskId,
2101
+ turnId: plannerTimeoutTask.lastTurnId,
2102
+ workspace: plannerTimeoutTask.workspaceSnapshot,
2103
+ request: { text: 'Find hotels' },
2104
+ };
2105
+ function waitMs(ms) {
2106
+ return new Promise((resolve) => setTimeout(resolve, ms));
2107
+ }
2108
+ async function resolveWithin(promise, ms) {
2109
+ return Promise.race([promise, waitMs(ms).then(() => ({ testDeadline: true }))]);
2110
+ }
2111
+ let timeoutPortClosed = 0;
2112
+ const timeoutMetrics = [];
2113
+ const neverResolvingPort = {
2114
+ run: () => new Promise((_resolve) => undefined),
2115
+ async close() { timeoutPortClosed += 1; },
2116
+ };
2117
+ const timeoutPlanner = await createDshEmbeddedBookingPlanner({
2118
+ runPortFactory: () => neverResolvingPort,
2119
+ turnTimeoutMs: 25,
2120
+ onMetric: (metric) => timeoutMetrics.push(metric),
2121
+ });
2122
+ const timeoutResult = await resolveWithin(timeoutPlanner.plannerFactory(plannerTimeoutTask).next({ task: plannerTimeoutTask, turn: plannerTimeoutTurn }), 250);
2123
+ assert.notDeepEqual(timeoutResult, { testDeadline: true }, 'planner turn deadline returns before a never-resolving provider');
2124
+ assert.equal(timeoutResult[0]?.kind, 'error', 'provider timeout is a typed planner error');
2125
+ assert.equal(timeoutResult[0]?.error?.code, 'PLANNER_PROVIDER_TIMEOUT', 'provider timeout uses the stable error code');
2126
+ assert.equal(timeoutMetrics[0]?.outcome, 'timeout', 'timeout contributes a safe latency metric');
2127
+ assert.equal(timeoutMetrics[0]?.firstPassValid, false);
2128
+ assert.ok((timeoutMetrics[0]?.elapsedMs ?? 999) < 250, 'timeout latency metric reflects the bounded turn');
2129
+ assert.equal(timeoutPortClosed, 1, 'timing out a task closes its owned run port');
2130
+ await timeoutPlanner.close();
2131
+ // A port that reports its own boot but never observed a completed handshake is
2132
+ // stuck starting OUR runtime: no provider call happened, so the turn must not
2133
+ // claim a provider timeout. The never-resolving port asserted just above
2134
+ // deliberately exposes no boot observation at all, which is why it keeps the
2135
+ // provider-timeout classification.
2136
+ const bootTimeoutTask = {
2137
+ ...task,
2138
+ taskId: 'task-dsh-boot-timeout',
2139
+ lastTurnId: 'dsh-turn-boot-timeout',
2140
+ contextRef: 'ctx-dsh-boot-timeout',
2141
+ workspaceSnapshot: { ...workspace, contextRef: 'ctx-dsh-boot-timeout' },
2142
+ };
2143
+ const bootTimeoutTurn = {
2144
+ ...plannerTimeoutTurn,
2145
+ taskId: bootTimeoutTask.taskId,
2146
+ turnId: bootTimeoutTask.lastTurnId,
2147
+ workspace: bootTimeoutTask.workspaceSnapshot,
2148
+ };
2149
+ let bootTimeoutPortClosed = 0;
2150
+ const bootMetrics = [];
2151
+ const bootTimeoutPlanner = await createDshEmbeddedBookingPlanner({
2152
+ runPortFactory: () => ({
2153
+ run: () => new Promise((_resolve) => undefined),
2154
+ async close() { bootTimeoutPortClosed += 1; },
2155
+ bootObservation: () => undefined,
2156
+ }),
2157
+ turnTimeoutMs: 25,
2158
+ onMetric: (metric) => bootMetrics.push(metric),
2159
+ });
2160
+ const bootTimeoutResult = await resolveWithin(bootTimeoutPlanner.plannerFactory(bootTimeoutTask).next({ task: bootTimeoutTask, turn: bootTimeoutTurn }), 250);
2161
+ assert.notDeepEqual(bootTimeoutResult, { testDeadline: true }, 'a never-started runtime stays inside the turn deadline');
2162
+ assert.equal(bootTimeoutResult[0]?.error?.code, 'PLANNER_BOOT_TIMEOUT', 'a stall before the handshake completes is a local boot timeout');
2163
+ assert.equal(bootMetrics[0]?.outcome, 'boot_timeout', 'a boot timeout reports its own metric outcome');
2164
+ assert.equal(bootTimeoutPortClosed, 1, 'a boot timeout closes its owned run port');
2165
+ await bootTimeoutPlanner.close();
2166
+ // The worker-side handshake deadline crosses the port as a closed code; it must
2167
+ // reach the same typed error instead of degrading into an unstructured throw.
2168
+ let bootRejectPortClosed = 0;
2169
+ const bootRejectPlanner = await createDshEmbeddedBookingPlanner({
2170
+ runPortFactory: () => ({
2171
+ run: () => Promise.reject(new Error('HARNESS_BOOT_TIMEOUT')),
2172
+ async close() { bootRejectPortClosed += 1; },
2173
+ bootObservation: () => undefined,
2174
+ }),
2175
+ turnTimeoutMs: 1_000,
2176
+ });
2177
+ const bootRejectResult = await resolveWithin(bootRejectPlanner.plannerFactory(bootTimeoutTask).next({ task: bootTimeoutTask, turn: bootTimeoutTurn }), 250);
2178
+ assert.notDeepEqual(bootRejectResult, { testDeadline: true }, 'an expired handshake settles inside the turn deadline');
2179
+ assert.equal(bootRejectResult[0]?.error?.code, 'PLANNER_BOOT_TIMEOUT', 'an expired local handshake is typed as a boot timeout');
2180
+ assert.equal(bootRejectPortClosed, 1, 'an expired handshake closes its owned run port');
2181
+ await bootRejectPlanner.close();
2182
+ let taskAClosed = 0;
2183
+ let taskARuns = 0;
2184
+ let taskBRuns = 0;
2185
+ let taskBClosed = 0;
2186
+ const isolatedPlanner = await createDshEmbeddedBookingPlanner({
2187
+ runPortFactory: (taskId) => {
2188
+ if (taskId === 'task-dsh-isolated-a') {
2189
+ return {
2190
+ run: () => {
2191
+ taskARuns += 1;
2192
+ return new Promise((_resolve) => undefined);
2193
+ },
2194
+ async close() { taskAClosed += 1; },
2195
+ };
2196
+ }
2197
+ return {
2198
+ async run() {
2199
+ taskBRuns += 1;
2200
+ return {
2201
+ finalResponse: '',
2202
+ events: successfulToolEvents('booking_search_hotels', JSON.stringify({ kind: 'search.run', input: {}, intent: { target: 'search.results' } }), 'call-isolated-b'),
2203
+ };
2204
+ },
2205
+ async close() { taskBClosed += 1; },
2206
+ };
2207
+ },
2208
+ turnTimeoutMs: 25,
2209
+ });
2210
+ const isolatedTaskA = { ...plannerTimeoutTask, taskId: 'task-dsh-isolated-a', lastTurnId: 'dsh-turn-isolated-a' };
2211
+ const isolatedTaskB = { ...plannerTimeoutTask, taskId: 'task-dsh-isolated-b', lastTurnId: 'dsh-turn-isolated-b' };
2212
+ const isolatedTurn = (isolatedTask) => ({ ...plannerTimeoutTurn, taskId: isolatedTask.taskId, turnId: isolatedTask.lastTurnId });
2213
+ const isolatedA = resolveWithin(isolatedPlanner.plannerFactory(isolatedTaskA).next({ task: isolatedTaskA, turn: isolatedTurn(isolatedTaskA) }), 250);
2214
+ const isolatedB = await isolatedPlanner.plannerFactory(isolatedTaskB).next({ task: isolatedTaskB, turn: isolatedTurn(isolatedTaskB) });
2215
+ const isolatedATimeout = await isolatedA;
2216
+ assert.notDeepEqual(isolatedATimeout, { testDeadline: true }, 'task A timeout is bounded');
2217
+ assert.equal(isolatedATimeout[0]?.error?.code, 'PLANNER_PROVIDER_TIMEOUT', 'task A receives a typed timeout');
2218
+ assert.equal(taskAClosed, 1, 'task A timeout closes only task A port');
2219
+ assertRuntimeMaterializedOperation(isolatedB, 'search.run', {}, isolatedTaskB, 'task B remains executable after task A timeout');
2220
+ assert.equal(taskARuns, 1, 'task A provider is called once');
2221
+ assert.equal(taskBRuns, 1, 'task B provider is called once');
2222
+ await waitMs(0);
2223
+ assert.equal(taskBClosed, 1, 'an accepted operation retires its task-owned provider port');
2224
+ await isolatedPlanner.close();
2225
+ // Receipt continuation rebuilds the DSH port from durable task state after
2226
+ // the previous operation's port has been retired.
2227
+ let rebuiltPortCount = 0;
2228
+ let rebuiltPortCloseCount = 0;
2229
+ const rebuiltTask = { ...plannerTimeoutTask, taskId: 'task-dsh-rebuild-port', lastTurnId: 'dsh-turn-rebuild-port' };
2230
+ const rebuiltWorkspace = { ...rebuiltTask.workspaceSnapshot, results: { status: 'idle' } };
2231
+ const rebuiltPlanner = await createDshEmbeddedBookingPlanner({
2232
+ runPortFactory: () => {
2233
+ const portOrdinal = ++rebuiltPortCount;
2234
+ return {
2235
+ async run() {
2236
+ return portOrdinal === 1
2237
+ ? { finalResponse: '', events: successfulToolEvents('booking_search_hotels', JSON.stringify({ kind: 'search.run', input: {}, intent: { target: 'search.results' } }), 'call-rebuild-first') }
2238
+ : { finalResponse: '', events: successfulToolEvents('booking_search_hotels', JSON.stringify({ kind: 'terminal' }), 'call-rebuild-terminal') };
2239
+ },
2240
+ async close() { rebuiltPortCloseCount += 1; },
2241
+ };
2242
+ },
2243
+ turnTimeoutMs: 250,
2244
+ });
2245
+ const rebuiltSession = rebuiltPlanner.plannerFactory(rebuiltTask);
2246
+ const rebuiltFirst = await rebuiltSession.next({ task: rebuiltTask, turn: { ...plannerTimeoutTurn, taskId: rebuiltTask.taskId, turnId: rebuiltTask.lastTurnId, workspace: rebuiltWorkspace } });
2247
+ assertRuntimeMaterializedOperation(rebuiltFirst, 'search.run', {}, rebuiltTask, 'first rebuilt-port turn emits search.run');
2248
+ await waitMs(0);
2249
+ assert.equal(rebuiltPortCount, 1, 'first receipt interval creates one provider port');
2250
+ assert.equal(rebuiltPortCloseCount, 1, 'first accepted operation retires its provider port');
2251
+ if (rebuiltFirst[0]?.kind !== 'operation')
2252
+ throw new Error('rebuilt-port first turn did not produce an operation');
2253
+ const rebuiltReceipt = {
2254
+ schemaVersion: 'booking.surface', kind: 'action.receipt', actionId: rebuiltFirst[0].action.actionId, contextRef: rebuiltTask.contextRef,
2255
+ status: 'applied', revision: 1, observation: { kind: 'search.state', searchSessionRef: 'rebuilt-search-1', resultCount: 1 },
2256
+ resultContract: { outcome: 'complete', hardCriteriaMet: true, factRefs: [], gapCodes: [], blockers: [], relaxationsApplied: [] },
2257
+ };
2258
+ const rebuiltReadyWorkspace = { ...rebuiltWorkspace, revision: 1, results: { status: 'ready', resultCount: 1, searchSessionRef: 'rebuilt-search-1' } };
2259
+ const rebuiltSecond = await rebuiltSession.next({
2260
+ task: { ...rebuiltTask, revision: 1, workspaceSnapshot: rebuiltReadyWorkspace, lastCompletedAction: completedCheckpoint(rebuiltFirst[0].action, rebuiltTask.lastTurnId), lastReceipt: rebuiltReceipt },
2261
+ turn: { schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: rebuiltTask.taskId, workspace: rebuiltReadyWorkspace, receipt: rebuiltReceipt },
2262
+ });
2263
+ assert.deepEqual(rebuiltSecond, [{ kind: 'terminal', terminal: { status: 'completed', summary: 'search_results_ready', factRefs: [] } }], 'rebuilt port continuation can terminalize only after ready search workspace');
2264
+ assert.equal(rebuiltPortCount, 2, 'receipt continuation creates a fresh provider port');
2265
+ await rebuiltPlanner.close();
2266
+ let latePortClosed = 0;
2267
+ const lateResolutionPort = {
2268
+ async run() {
2269
+ await waitMs(80);
2270
+ return {
2271
+ finalResponse: '',
2272
+ events: successfulToolEvents('booking_search_hotels', JSON.stringify({ kind: 'search.run', input: {}, intent: { target: 'search.results' } }), 'call-late-resolution'),
2273
+ };
2274
+ },
2275
+ async close() { latePortClosed += 1; },
2276
+ };
2277
+ const latePlanner = await createDshEmbeddedBookingPlanner({
2278
+ runPortFactory: () => lateResolutionPort,
2279
+ turnTimeoutMs: 25,
2280
+ });
2281
+ const lateResult = await resolveWithin(latePlanner.plannerFactory(plannerTimeoutTask).next({ task: plannerTimeoutTask, turn: plannerTimeoutTurn }), 250);
2282
+ assert.notDeepEqual(lateResult, { testDeadline: true }, 'late provider response does not extend the turn');
2283
+ assert.equal(lateResult[0]?.error?.code, 'PLANNER_PROVIDER_TIMEOUT', 'late response returns timeout');
2284
+ await waitMs(110);
2285
+ assert.equal(latePortClosed, 1, 'late response port is closed after timeout');
2286
+ assert.notEqual(lateResult[0]?.kind, 'operation', 'late resolution cannot become an executable decision');
2287
+ await latePlanner.close();
2288
+ // Shutdown waits for a task port whose factory and close are both still in
2289
+ // flight. The planner cannot return while a late-created Harness child remains.
2290
+ let resolveDeferredFactory;
2291
+ let resolveDeferredClose;
2292
+ let deferredCloseCalls = 0;
2293
+ const deferredFactoryPlanner = await createDshEmbeddedBookingPlanner({
2294
+ runPortFactory: () => new Promise((resolve) => { resolveDeferredFactory = resolve; }),
2295
+ turnTimeoutMs: 1_000,
2296
+ });
2297
+ const deferredSession = deferredFactoryPlanner.plannerFactory(plannerTimeoutTask);
2298
+ const deferredNextOutcome = deferredSession.next({ task: plannerTimeoutTask, turn: plannerTimeoutTurn }).then(() => null, (error) => error);
2299
+ await waitMs(0);
2300
+ let deferredShutdownSettled = false;
2301
+ const deferredShutdown = deferredFactoryPlanner.close().then(() => { deferredShutdownSettled = true; });
2302
+ await waitMs(5);
2303
+ assert.equal(deferredShutdownSettled, false, 'shutdown waits for a pending task-port factory');
2304
+ resolveDeferredFactory({
2305
+ async run() { throw new Error('closed planner must not call a late-created port'); },
2306
+ async close() {
2307
+ deferredCloseCalls += 1;
2308
+ await new Promise((resolve) => { resolveDeferredClose = resolve; });
2309
+ },
2310
+ });
2311
+ await waitMs(5);
2312
+ assert.equal(deferredCloseCalls, 1, 'shutdown closes the port produced by a late factory resolution');
2313
+ assert.equal(deferredShutdownSettled, false, 'shutdown waits for late port cleanup to settle');
2314
+ resolveDeferredClose();
2315
+ await deferredShutdown;
2316
+ assert.match(String((await deferredNextOutcome)?.message), /planner_closed/, 'an in-flight turn cannot use a port after planner shutdown');
2317
+ // A failed close remains sticky; a non-retryable SDK cleanup cannot be
2318
+ // re-invoked and then falsely reported as successful.
2319
+ let cleanupAttempts = 0;
2320
+ const retryCleanupPlanner = await createDshEmbeddedBookingPlanner({
2321
+ runPortFactory: () => ({
2322
+ async run() {
2323
+ return { finalResponse: '', events: successfulToolEvents('booking_search_hotels', JSON.stringify({ kind: 'search.run', input: {}, intent: { target: 'search.results' } }), 'call-cleanup-retry') };
2324
+ },
2325
+ async close() {
2326
+ cleanupAttempts += 1;
2327
+ if (cleanupAttempts === 1)
2328
+ throw new Error('fixture_cleanup_failed');
2329
+ },
2330
+ }),
2331
+ });
2332
+ await retryCleanupPlanner.plannerFactory(plannerTimeoutTask).next({ task: plannerTimeoutTask, turn: plannerTimeoutTurn });
2333
+ await assert.rejects(retryCleanupPlanner.close(), /booking_planner_cleanup_failed/, 'shutdown surfaces a task-port cleanup failure');
2334
+ await assert.rejects(retryCleanupPlanner.close(), /booking_planner_cleanup_failed/, 'later close callers observe the same cleanup failure');
2335
+ assert.equal(cleanupAttempts, 1, 'a terminal cleanup promise is never re-invoked and laundered into success');
2336
+ // Compact proposal authority matrix. The model supplies only kind + input;
2337
+ // all identity, revision, reason, and evidence bindings remain runtime-owned.
2338
+ async function runCompactAuthorityCase(authorityTask, proposal, turnId = authorityTask.lastTurnId ?? 'authority-turn') {
2339
+ const authorityWorkspace = authorityTask.workspaceSnapshot;
2340
+ const proposalKind = proposal.kind === 'operation' && typeof proposal.action === 'object' && proposal.action !== null
2341
+ ? proposal.action.kind
2342
+ : proposal.kind;
2343
+ const toolName = proposalKind === 'hotel.focus' || proposalKind === 'hotel.select'
2344
+ ? 'booking_refine_results'
2345
+ : proposalKind === 'offers.query' || proposalKind === 'offers.view.patch'
2346
+ ? 'booking_find_room_offers'
2347
+ : proposalKind === 'offer.select' || proposalKind === 'offers.compare'
2348
+ ? 'booking_compare_offers'
2349
+ : proposalKind === 'checkout.prepare' || proposalKind === 'offer.check'
2350
+ ? 'booking_prepare_booking'
2351
+ : 'booking_search_hotels';
2352
+ const planner = await createDshEmbeddedBookingPlanner({
2353
+ runPort: {
2354
+ async run() {
2355
+ return {
2356
+ finalResponse: '',
2357
+ events: successfulToolEvents(toolName, JSON.stringify(proposal), `call-authority-${turnId}`),
2358
+ };
2359
+ },
2360
+ async close() { },
2361
+ },
2362
+ });
2363
+ try {
2364
+ return await planner.plannerFactory(authorityTask).next({
2365
+ task: authorityTask,
2366
+ turn: {
2367
+ schemaVersion: 'booking.surface',
2368
+ kind: 'user.turn',
2369
+ taskId: authorityTask.taskId,
2370
+ turnId,
2371
+ workspace: authorityWorkspace,
2372
+ request: { text: 'Apply this booking request' },
2373
+ },
2374
+ });
2375
+ }
2376
+ finally {
2377
+ await planner.close();
2378
+ }
2379
+ }
2380
+ const budgetTask = {
2381
+ ...task,
2382
+ taskId: 'task-dsh-authority-budget',
2383
+ lastTurnId: 'dsh-turn-authority-budget',
2384
+ workspaceSnapshot: { ...workspace, currency: 'AED' },
2385
+ };
2386
+ const budgetProposal = {
2387
+ kind: 'search.patch',
2388
+ input: { patch: { budget: { strength: 'must', value: { max: { amount: '1000' } } } } },
2389
+ intent: { target: 'search.results' },
2390
+ };
2391
+ const [budgetDecision] = await runCompactAuthorityCase(budgetTask, budgetProposal);
2392
+ assert.equal(budgetDecision?.kind, 'operation', 'budget compact proposal materializes an operation');
2393
+ if (budgetDecision?.kind !== 'operation')
2394
+ throw new Error('budget proposal did not materialize');
2395
+ assert.deepEqual(budgetDecision.action.input.patch.budget.value.max, {
2396
+ amount: '1000',
2397
+ currency: 'AED',
2398
+ sourceFactRef: 'turn:dsh-turn-authority-budget',
2399
+ }, 'budget amount is hydrated from workspace currency and durable turn evidence');
2400
+ assert.deepEqual(budgetDecision.action.factRefs, ['turn:dsh-turn-authority-budget'], 'hydrated budget contributes the durable turn fact reference');
2401
+ const intentHydrationTask = {
2402
+ ...budgetTask,
2403
+ taskId: 'task-dsh-intent-hydration',
2404
+ lastTurnId: 'dsh-turn-intent-hydration',
2405
+ allowedActions: ['search.patch', 'offers.compare'],
2406
+ workspaceSnapshot: {
2407
+ ...budgetTask.workspaceSnapshot,
2408
+ capabilities: { surface: 'tenant', allowedActions: ['search.patch', 'offers.compare'] },
2409
+ },
2410
+ };
2411
+ const [intentHydrationDecision] = await runCompactAuthorityCase(intentHydrationTask, {
2412
+ kind: 'search.patch',
2413
+ input: { patch: { destination: { query: 'Dubai' } } },
2414
+ intent: {
2415
+ target: 'offers.compared',
2416
+ offerCriteria: {
2417
+ totalPriceMax: { strength: 'must', value: { amount: '1000' } },
2418
+ },
2419
+ },
2420
+ });
2421
+ assert.equal(intentHydrationDecision?.kind, 'operation');
2422
+ if (intentHydrationDecision?.kind !== 'operation')
2423
+ throw new Error('intent hydration did not materialize an operation');
2424
+ assert.deepEqual(intentHydrationDecision.intent, {
2425
+ schemaVersion: BOOKING_INTENT_SCHEMA_VERSION,
2426
+ target: 'offers.compared',
2427
+ offerCriteria: {
2428
+ totalPriceMax: {
2429
+ strength: 'must',
2430
+ value: { amount: '1000', currency: 'AED', sourceFactRef: 'turn:dsh-turn-intent-hydration' },
2431
+ },
2432
+ targetCount: 3,
2433
+ },
2434
+ }, 'intent money receives runtime provenance and unspecified comparison count defaults to three');
2435
+ // A continuation repeats only plugin-facing semantic intent fields. The
2436
+ // durable sourceFactRef remains bound to the original turn, so hydrated money
2437
+ // must not force a repair/nudge or mutate the intent on the next provider pass.
2438
+ const hydratedMoneyCriteria = {
2439
+ totalPriceMax: {
2440
+ strength: 'must',
2441
+ value: { amount: '1000', currency: 'AED', sourceFactRef: 'turn:dsh-turn-money-origin' },
2442
+ },
2443
+ targetCount: 2,
2444
+ };
2445
+ const hydratedMoneyProjection = {
2446
+ schemaVersion: BOOKING_INTENT_SCHEMA_VERSION,
2447
+ target: 'offers.refined',
2448
+ offerCriteria: hydratedMoneyCriteria,
2449
+ };
2450
+ const hydratedMoneyIntentBase = {
2451
+ taskId: 'task-dsh-money-continuation', contextRef: 'ctx-dsh-money-continuation',
2452
+ sourceTurnId: 'dsh-turn-money-origin', sourceRequestDigest: bookingDigest('find two affordable offers'),
2453
+ projection: hydratedMoneyProjection,
2454
+ };
2455
+ const hydratedMoneyIntent = {
2456
+ ...hydratedMoneyIntentBase,
2457
+ intentDigest: bookingDigest(hydratedMoneyIntentBase),
2458
+ };
2459
+ const hydratedMoneyWorkspace = {
2460
+ ...workspace,
2461
+ contextRef: hydratedMoneyIntent.contextRef,
2462
+ revision: 1,
2463
+ visibleHotels: [{ hotelRef: 'hotel-1', name: 'Hotel One', factRefs: ['hotel:1'] }],
2464
+ capabilities: { surface: 'tenant', allowedActions: ['offers.query', 'offers.view.patch'] },
2465
+ };
2466
+ const hydratedMoneyPriorAction = {
2467
+ ...searchRun,
2468
+ kind: 'offers.query', actionId: 'action-money-query', contextRef: hydratedMoneyIntent.contextRef,
2469
+ factRefs: [],
2470
+ input: { hotelRefs: ['hotel-1'], criteria: hydratedMoneyCriteria },
2471
+ };
2472
+ const hydratedMoneyReceipt = {
2473
+ schemaVersion: 'booking.surface', kind: 'action.receipt', actionId: hydratedMoneyPriorAction.actionId,
2474
+ contextRef: hydratedMoneyIntent.contextRef, status: 'applied', revision: 1,
2475
+ observation: { kind: 'offers.state', hotelRefs: ['hotel-1'], offerRefs: ['offer-money-a', 'offer-money-b'], loadedHotelCount: 1 },
2476
+ resultContract: { outcome: 'complete', requestedCount: 2, actualCount: 2, hardCriteriaMet: true, factRefs: [], gapCodes: [], blockers: [], relaxationsApplied: [] },
2477
+ };
2478
+ const hydratedMoneyPrompts = [];
2479
+ const hydratedMoneyMetrics = [];
2480
+ const hydratedMoneyPlanner = await createDshEmbeddedBookingPlanner({
2481
+ runPort: {
2482
+ async run(prompt) {
2483
+ hydratedMoneyPrompts.push(prompt);
2484
+ return {
2485
+ finalResponse: '',
2486
+ events: successfulToolEvents('booking_find_room_offers', JSON.stringify({
2487
+ kind: 'offers.view.patch',
2488
+ input: { hotelRef: 'hotel-1', criteria: { totalPriceMax: { strength: 'must', value: { amount: '1000', currency: 'AED' } }, targetCount: 2 } },
2489
+ intent: { target: 'offers.refined', offerCriteria: { totalPriceMax: { strength: 'must', value: { amount: '1000', currency: 'AED' } }, targetCount: 2 } },
2490
+ }), 'call-money-continuation'),
2491
+ };
2492
+ },
2493
+ async close() { },
2494
+ },
2495
+ onMetric: (metric) => hydratedMoneyMetrics.push(metric),
2496
+ });
2497
+ const hydratedMoneyTask = {
2498
+ ...task,
2499
+ taskId: hydratedMoneyIntent.taskId,
2500
+ contextRef: hydratedMoneyIntent.contextRef,
2501
+ revision: 1,
2502
+ lastTurnId: 'dsh-turn-money-continuation',
2503
+ allowedActions: ['offers.query', 'offers.view.patch'],
2504
+ activeIntent: hydratedMoneyIntent,
2505
+ lastCompletedAction: completedCheckpoint(hydratedMoneyPriorAction, hydratedMoneyIntent.sourceTurnId),
2506
+ lastReceipt: hydratedMoneyReceipt,
2507
+ workspaceSnapshot: hydratedMoneyWorkspace,
2508
+ };
2509
+ const [hydratedMoneyContinuation] = await hydratedMoneyPlanner.plannerFactory(hydratedMoneyTask).next({
2510
+ task: hydratedMoneyTask,
2511
+ turn: {
2512
+ schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: hydratedMoneyTask.taskId,
2513
+ workspace: hydratedMoneyWorkspace, receipt: hydratedMoneyReceipt,
2514
+ },
2515
+ });
2516
+ assert.equal(hydratedMoneyContinuation?.kind, 'operation', 'hydrated-money continuation remains a first-pass operation');
2517
+ if (hydratedMoneyContinuation?.kind !== 'operation')
2518
+ throw new Error('hydrated-money continuation did not produce an operation');
2519
+ assert.deepEqual(hydratedMoneyContinuation.action.input.criteria, hydratedMoneyCriteria, 'continuation action keeps the durable money provenance binding');
2520
+ assert.equal(hydratedMoneyMetrics[0]?.firstPassValid, true, 'hydrated-money continuation requires no schema repair or prose nudge');
2521
+ const hydratedMoneyPayload = promptPayload(hydratedMoneyPrompts[0]);
2522
+ assert.deepEqual(hydratedMoneyPayload.task.activeIntent, {
2523
+ target: 'offers.refined', offerCriteria: {
2524
+ totalPriceMax: { strength: 'must', value: { amount: '1000', currency: 'AED' } }, targetCount: 2,
2525
+ },
2526
+ }, 'continuation payload projects exactly the plugin semantic intent fields');
2527
+ assert.ok(!JSON.stringify(hydratedMoneyPayload.task.activeIntent).includes('sourceFactRef'), 'continuation payload omits runtime money provenance');
2528
+ assert.ok(!Object.prototype.hasOwnProperty.call(hydratedMoneyPayload.task.activeIntent, 'schemaVersion'), 'continuation payload omits runtime intent schemaVersion');
2529
+ await hydratedMoneyPlanner.close();
2530
+ const offerCriteria = {
2531
+ meals: { strength: 'must', value: ['breakfast'] },
2532
+ freeCancellation: { strength: 'must', value: true },
2533
+ targetCount: 3,
2534
+ };
2535
+ const offerCriteriaBindingTask = {
2536
+ ...task,
2537
+ taskId: 'task-dsh-intent-action-binding',
2538
+ lastTurnId: 'dsh-turn-intent-action-binding',
2539
+ allowedActions: ['offers.query'],
2540
+ workspaceSnapshot: {
2541
+ ...workspace,
2542
+ visibleHotels: [{ hotelRef: 'hotel-1', name: 'Hotel One', factRefs: ['hotel:1'] }],
2543
+ capabilities: { surface: 'tenant', allowedActions: ['offers.query'] },
2544
+ },
2545
+ };
2546
+ const [offerCriteriaBindingDecision] = await runCompactAuthorityCase(offerCriteriaBindingTask, {
2547
+ kind: 'offers.query',
2548
+ input: { hotelRefs: ['hotel-1'], criteria: { targetCount: 1 } },
2549
+ intent: { target: 'offers.loaded', offerCriteria },
2550
+ });
2551
+ assert.equal(offerCriteriaBindingDecision?.kind, 'operation');
2552
+ if (offerCriteriaBindingDecision?.kind !== 'operation')
2553
+ throw new Error('offer criteria binding did not materialize an operation');
2554
+ assert.deepEqual(offerCriteriaBindingDecision.action.input.criteria, offerCriteria, 'runtime compilation binds the authoritative intent criteria instead of executing a weaker model-authored query');
2555
+ await assert.rejects(runCompactAuthorityCase({
2556
+ ...intentHydrationTask,
2557
+ taskId: 'task-dsh-intent-unsupported',
2558
+ allowedActions: ['search.patch'],
2559
+ workspaceSnapshot: {
2560
+ ...intentHydrationTask.workspaceSnapshot,
2561
+ capabilities: { surface: 'tenant', allowedActions: ['search.patch'] },
2562
+ },
2563
+ }, {
2564
+ kind: 'search.patch',
2565
+ input: { patch: { destination: { query: 'Dubai' } } },
2566
+ intent: { target: 'offers.compared' },
2567
+ }), /planner_intent_target_unsupported/, 'a typed intent cannot expand the current surface capability policy');
2568
+ // An intent target is durable planner state, not Harness session state. The
2569
+ // offers.compared target survives retirement/rebuild of the per-turn port and
2570
+ // cannot be terminalized after an earlier offers.query receipt.
2571
+ const comparedWorkspace = {
2572
+ ...workspace,
2573
+ loadedOffers: [
2574
+ { offerRef: 'offer-compared-a', offerVersionRef: 'offer-compared-a:v1', hotelRef: 'hotel-1', evidenceLevel: 'rate_loaded', factRefs: ['offer:compared:a'] },
2575
+ { offerRef: 'offer-compared-b', offerVersionRef: 'offer-compared-b:v1', hotelRef: 'hotel-1', evidenceLevel: 'rate_loaded', factRefs: ['offer:compared:b'] },
2576
+ ],
2577
+ capabilities: { surface: 'tenant', allowedActions: ['offers.query', 'offers.compare'] },
2578
+ };
2579
+ const comparedIntent = { schemaVersion: BOOKING_INTENT_SCHEMA_VERSION, target: 'offers.compared', offerCriteria: { targetCount: 2 } };
2580
+ const comparedIntentBase = { taskId: 'task-dsh-intent-compared', contextRef: 'ctx-dsh-intent-compared', sourceTurnId: 'dsh-intent-compared', sourceRequestDigest: bookingDigest('compare these offers'), projection: comparedIntent };
2581
+ const comparedActiveIntent = { ...comparedIntentBase, intentDigest: bookingDigest(comparedIntentBase) };
2582
+ const comparedTask = {
2583
+ ...task,
2584
+ taskId: 'task-dsh-intent-compared', contextRef: 'ctx-dsh-intent-compared', lastTurnId: comparedIntentBase.sourceTurnId,
2585
+ allowedActions: ['offers.query', 'offers.compare'], workspaceSnapshot: { ...comparedWorkspace, contextRef: 'ctx-dsh-intent-compared', revision: 1 },
2586
+ };
2587
+ let comparedPortOrdinal = 0;
2588
+ const comparedPlanner = await createDshEmbeddedBookingPlanner({
2589
+ runPortFactory: () => {
2590
+ comparedPortOrdinal += 1;
2591
+ return {
2592
+ async run() {
2593
+ return comparedPortOrdinal === 1
2594
+ ? { finalResponse: '', events: successfulToolEvents('booking_compare_offers', JSON.stringify({ kind: 'offers.compare', input: { offerRefs: ['offer-compared-a', 'offer-compared-b'], requestedCount: 2 }, intent: { target: 'offers.compared', offerCriteria: { targetCount: 2 } } }), `call-compared-${comparedPortOrdinal}`) }
2595
+ : { finalResponse: '', events: successfulToolEvents('booking_compare_offers', JSON.stringify({ kind: 'terminal' }), `call-compared-${comparedPortOrdinal}`) };
2596
+ },
2597
+ async close() { },
2598
+ };
2599
+ },
2600
+ });
2601
+ const comparedSession = comparedPlanner.plannerFactory(comparedTask);
2602
+ const comparedFirst = await comparedSession.next({ task: comparedTask, turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: comparedTask.taskId, turnId: comparedTask.lastTurnId, workspace: comparedTask.workspaceSnapshot, request: { text: 'Compare these offers' } } });
2603
+ assert.equal(comparedFirst[0]?.kind, 'operation');
2604
+ if (comparedFirst[0]?.kind !== 'operation')
2605
+ throw new Error('intent comparison did not produce an operation');
2606
+ assert.deepEqual(comparedFirst[0].intent, comparedIntent, 'model proposal intent receives runtime schemaVersion');
2607
+ const comparedReceipt = {
2608
+ schemaVersion: 'booking.surface', kind: 'action.receipt', actionId: comparedFirst[0].action.actionId, contextRef: comparedTask.contextRef,
2609
+ status: 'applied', revision: 1, observation: { kind: 'offers.state', hotelRefs: ['hotel-1'], offerRefs: ['offer-compared-a', 'offer-compared-b'], loadedHotelCount: 1 },
2610
+ resultContract: { outcome: 'complete', requestedCount: 2, actualCount: 2, hardCriteriaMet: true, factRefs: [], gapCodes: [], blockers: [], relaxationsApplied: [] },
2611
+ };
2612
+ const priorOffersQuery = { ...searchRun, kind: 'offers.query', actionId: 'action-prior-offers-query', contextRef: comparedTask.contextRef, factRefs: [], input: { hotelRefs: ['hotel-1'], criteria: { targetCount: 2 } } };
2613
+ const prematureReceipt = { ...comparedReceipt, actionId: priorOffersQuery.actionId, observation: { kind: 'offers.state', hotelRefs: ['hotel-1'], offerRefs: ['offer-compared-a'], loadedHotelCount: 1 } };
2614
+ const prematureIntentTask = { ...comparedTask, revision: 1, activeIntent: comparedActiveIntent, lastCompletedAction: completedCheckpoint(priorOffersQuery, comparedTask.lastTurnId), lastReceipt: prematureReceipt };
2615
+ await assert.rejects(comparedSession.next({ task: prematureIntentTask, turn: { schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: comparedTask.taskId, workspace: { ...comparedTask.workspaceSnapshot, revision: 1 }, receipt: prematureIntentTask.lastReceipt } }), /planner_terminal_intent_target_unachieved/, 'offers.compared intent cannot terminalize after a non-target offers receipt');
2616
+ const comparedTerminalTask = { ...prematureIntentTask, lastCompletedAction: completedCheckpoint(comparedFirst[0].action, comparedTask.lastTurnId), lastReceipt: comparedReceipt };
2617
+ const comparedTerminal = await comparedSession.next({ task: comparedTerminalTask, turn: { schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: comparedTask.taskId, workspace: { ...comparedTask.workspaceSnapshot, revision: 1 }, receipt: comparedReceipt } });
2618
+ assert.deepEqual(comparedTerminal, [{ kind: 'terminal', terminal: { status: 'completed', summary: 'room_offers_ready', factRefs: [] } }], 'offers.compared intent survives per-turn port retirement and terminalizes only at its target');
2619
+ assert.equal(comparedPortOrdinal, 3, 'intent continuation rebuilds the retired provider port after the guarded premature attempt');
2620
+ if (comparedFirst[0].action.kind !== 'offers.compare')
2621
+ throw new Error('comparison proof received the wrong action kind');
2622
+ const underfilledCompareAction = {
2623
+ ...comparedFirst[0].action,
2624
+ actionId: 'action-underfilled-compare',
2625
+ input: { offerRefs: ['offer-compared-a'], requestedCount: 2 },
2626
+ };
2627
+ const underfilledCompareReceipt = {
2628
+ ...comparedReceipt,
2629
+ actionId: underfilledCompareAction.actionId,
2630
+ observation: { kind: 'offers.state', hotelRefs: ['hotel-1'], offerRefs: ['offer-compared-a'], loadedHotelCount: 1 },
2631
+ resultContract: { ...comparedReceipt.resultContract, actualCount: 1 },
2632
+ };
2633
+ const underfilledCompareTask = {
2634
+ ...comparedTerminalTask,
2635
+ lastCompletedAction: completedCheckpoint(underfilledCompareAction, comparedTask.lastTurnId),
2636
+ lastReceipt: underfilledCompareReceipt,
2637
+ };
2638
+ const underfilledTerminal = await comparedSession.next({
2639
+ task: underfilledCompareTask,
2640
+ turn: { schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: comparedTask.taskId, workspace: { ...comparedTask.workspaceSnapshot, revision: 1 }, receipt: underfilledCompareReceipt },
2641
+ });
2642
+ assert.deepEqual(underfilledTerminal, [{ kind: 'terminal', terminal: { status: 'stopped', summary: 'offer_target_not_reached', factRefs: [] } }], 'a receipt cannot claim a completed comparison when fewer offers than the durable target were compared');
2643
+ const falseActualCountReceipt = {
2644
+ ...comparedReceipt,
2645
+ resultContract: { ...comparedReceipt.resultContract, actualCount: 1 },
2646
+ };
2647
+ const falseActualCountTerminal = await comparedSession.next({
2648
+ task: { ...comparedTerminalTask, lastReceipt: falseActualCountReceipt },
2649
+ turn: { schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: comparedTask.taskId, workspace: { ...comparedTask.workspaceSnapshot, revision: 1 }, receipt: falseActualCountReceipt },
2650
+ });
2651
+ assert.deepEqual(falseActualCountTerminal, [{ kind: 'terminal', terminal: { status: 'stopped', summary: 'offer_target_not_reached', factRefs: [] } }], 'the terminal uses receipt actualCount rather than the model-authored comparison input length');
2652
+ const constrainedOfferCriteria = {
2653
+ meals: { strength: 'must', value: ['breakfast'] },
2654
+ freeCancellation: { strength: 'must', value: true },
2655
+ targetCount: 2,
2656
+ };
2657
+ const constrainedProjection = {
2658
+ schemaVersion: BOOKING_INTENT_SCHEMA_VERSION,
2659
+ target: 'offers.compared',
2660
+ offerCriteria: constrainedOfferCriteria,
2661
+ };
2662
+ const constrainedIntentBase = {
2663
+ ...comparedIntentBase,
2664
+ projection: constrainedProjection,
2665
+ };
2666
+ const constrainedIntent = {
2667
+ ...constrainedIntentBase,
2668
+ intentDigest: bookingDigest(constrainedIntentBase),
2669
+ };
2670
+ const mismatchedCriteriaTask = {
2671
+ ...comparedTerminalTask,
2672
+ activeIntent: constrainedIntent,
2673
+ availability: {
2674
+ ...comparedTerminalTask.availability,
2675
+ criteria: { targetCount: 2 },
2676
+ criteriaDigest: bookingDigest({ targetCount: 2 }),
2677
+ },
2678
+ };
2679
+ await assert.rejects(comparedSession.next({ task: mismatchedCriteriaTask, turn: { schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: comparedTask.taskId, workspace: { ...comparedTask.workspaceSnapshot, revision: 1 }, receipt: comparedReceipt } }), /planner_terminal_intent_criteria_unachieved/, 'matching an action kind cannot erase meal or cancellation constraints from the durable intent');
2680
+ const constrainedCriteriaTask = {
2681
+ ...mismatchedCriteriaTask,
2682
+ availability: {
2683
+ ...mismatchedCriteriaTask.availability,
2684
+ criteria: constrainedOfferCriteria,
2685
+ criteriaDigest: bookingDigest(constrainedOfferCriteria),
2686
+ },
2687
+ };
2688
+ const constrainedTerminal = await comparedSession.next({
2689
+ task: constrainedCriteriaTask,
2690
+ turn: { schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: comparedTask.taskId, workspace: { ...comparedTask.workspaceSnapshot, revision: 1 }, receipt: comparedReceipt },
2691
+ });
2692
+ assert.deepEqual(constrainedTerminal, [{ kind: 'terminal', terminal: { status: 'completed', summary: 'room_offers_ready', factRefs: [] } }], 'matching criteria lineage permits the requested comparison waypoint');
2693
+ function offerTargetIntentCheckpoint(taskId, contextRef, sourceTurnId, target, offerCriteria) {
2694
+ const base = {
2695
+ taskId, contextRef, sourceTurnId, sourceRequestDigest: bookingDigest(`${target}:${offerCriteria.targetCount}`),
2696
+ projection: { schemaVersion: BOOKING_INTENT_SCHEMA_VERSION, target, offerCriteria },
2697
+ };
2698
+ return { ...base, intentDigest: bookingDigest(base) };
2699
+ }
2700
+ const queryTargetIntent = offerTargetIntentCheckpoint('task-dsh-offer-query-underfill', 'ctx-dsh-offer-query-underfill', 'dsh-offer-query-underfill', 'offers.loaded', { targetCount: 2 });
2701
+ const queryTargetAction = {
2702
+ ...searchRun, kind: 'offers.query', actionId: 'action-offer-query-underfill', contextRef: queryTargetIntent.contextRef, factRefs: [],
2703
+ input: { hotelRefs: ['hotel-1'], criteria: { targetCount: 2 } },
2704
+ };
2705
+ const queryTargetReceipt = {
2706
+ ...comparedReceipt, actionId: queryTargetAction.actionId, contextRef: queryTargetIntent.contextRef,
2707
+ observation: { kind: 'offers.state', hotelRefs: ['hotel-1'], offerRefs: ['offer-compared-a'], loadedHotelCount: 1 },
2708
+ resultContract: { ...comparedReceipt.resultContract, requestedCount: 2, actualCount: 1 },
2709
+ };
2710
+ const queryTargetTask = {
2711
+ ...comparedTask, taskId: queryTargetIntent.taskId, contextRef: queryTargetIntent.contextRef,
2712
+ allowedActions: ['offers.query'], revision: 1, activeIntent: queryTargetIntent,
2713
+ lastCompletedAction: completedCheckpoint(queryTargetAction, queryTargetIntent.sourceTurnId), lastReceipt: queryTargetReceipt,
2714
+ workspaceSnapshot: { ...comparedWorkspace, contextRef: queryTargetIntent.contextRef, revision: 1, capabilities: { surface: 'tenant', allowedActions: ['offers.query'] } },
2715
+ };
2716
+ assert.deepEqual(await runTerminalContinuation(queryTargetTask, queryTargetReceipt), [{ kind: 'terminal', terminal: { status: 'stopped', summary: 'offer_target_not_reached', factRefs: [] } }], 'offers.query underfill cannot terminalize as completed');
2717
+ const queryInconsistentReceipt = {
2718
+ ...queryTargetReceipt,
2719
+ observation: { kind: 'offers.state', hotelRefs: ['hotel-1'], offerRefs: ['offer-compared-a', 'offer-compared-b'], loadedHotelCount: 1 },
2720
+ resultContract: { ...queryTargetReceipt.resultContract, actualCount: 1 },
2721
+ };
2722
+ assert.deepEqual(await runTerminalContinuation({ ...queryTargetTask, lastReceipt: queryInconsistentReceipt }, queryInconsistentReceipt), [{ kind: 'terminal', terminal: { status: 'stopped', summary: 'offer_target_not_reached', factRefs: [] } }], 'offers.query rejects inconsistent authoritative actualCount and offer refs');
2723
+ const viewTargetIntent = offerTargetIntentCheckpoint('task-dsh-offer-view-underfill', 'ctx-dsh-offer-view-underfill', 'dsh-offer-view-underfill', 'offers.refined', { targetCount: 2 });
2724
+ const viewTargetAction = {
2725
+ ...searchRun, kind: 'offers.view.patch', actionId: 'action-offer-view-underfill', contextRef: viewTargetIntent.contextRef, factRefs: [],
2726
+ input: { hotelRef: 'hotel-1', criteria: { targetCount: 2 } },
2727
+ };
2728
+ const viewTargetReceipt = {
2729
+ ...comparedReceipt, actionId: viewTargetAction.actionId, contextRef: viewTargetIntent.contextRef,
2730
+ observation: { kind: 'offers.state', hotelRefs: ['hotel-1'], offerRefs: ['offer-compared-a'], loadedHotelCount: 1 },
2731
+ resultContract: { ...comparedReceipt.resultContract, requestedCount: 2, actualCount: 1 },
2732
+ };
2733
+ const viewTargetTask = {
2734
+ ...comparedTask, taskId: viewTargetIntent.taskId, contextRef: viewTargetIntent.contextRef,
2735
+ allowedActions: ['offers.view.patch'], revision: 1, activeIntent: viewTargetIntent,
2736
+ lastCompletedAction: completedCheckpoint(viewTargetAction, viewTargetIntent.sourceTurnId), lastReceipt: viewTargetReceipt,
2737
+ workspaceSnapshot: { ...comparedWorkspace, contextRef: viewTargetIntent.contextRef, revision: 1, capabilities: { surface: 'tenant', allowedActions: ['offers.view.patch'] } },
2738
+ };
2739
+ assert.deepEqual(await runTerminalContinuation(viewTargetTask, viewTargetReceipt), [{ kind: 'terminal', terminal: { status: 'stopped', summary: 'offer_target_not_reached', factRefs: [] } }], 'offers.view.patch underfill cannot terminalize as completed');
2740
+ const viewInconsistentReceipt = {
2741
+ ...viewTargetReceipt,
2742
+ observation: { kind: 'offers.state', hotelRefs: ['hotel-1'], offerRefs: ['offer-compared-a', 'offer-compared-b'], loadedHotelCount: 1 },
2743
+ resultContract: { ...viewTargetReceipt.resultContract, actualCount: 1 },
2744
+ };
2745
+ assert.deepEqual(await runTerminalContinuation({ ...viewTargetTask, lastReceipt: viewInconsistentReceipt }, viewInconsistentReceipt), [{ kind: 'terminal', terminal: { status: 'stopped', summary: 'offer_target_not_reached', factRefs: [] } }], 'offers.view.patch rejects inconsistent authoritative actualCount and offer refs');
2746
+ // A later single-offer waypoint carries the earlier multi-offer goal through
2747
+ // the authoritative Compare Tray. It must not require fake count fields on an
2748
+ // offer.selection receipt, and it must not forget an underfilled shortlist.
2749
+ const selectedProjection = { schemaVersion: BOOKING_INTENT_SCHEMA_VERSION, target: 'offer.selected', offerCriteria: { targetCount: 2 } };
2750
+ const selectedIntentBase = { taskId: 'task-dsh-offer-selected-count', contextRef: 'ctx-dsh-offer-selected-count', sourceTurnId: 'dsh-offer-selected-count', sourceRequestDigest: bookingDigest('find two offers and select one'), projection: selectedProjection };
2751
+ const selectedIntent = { ...selectedIntentBase, intentDigest: bookingDigest(selectedIntentBase) };
2752
+ const selectedAction = {
2753
+ ...searchRun, kind: 'offer.select', actionId: 'action-offer-selected-count', contextRef: selectedIntent.contextRef, factRefs: [],
2754
+ input: { offerRef: 'offer-compared-a', offerVersionRef: 'offer-compared-a:v1' },
2755
+ };
2756
+ const selectedReceipt = {
2757
+ ...comparedReceipt, actionId: selectedAction.actionId, contextRef: selectedIntent.contextRef,
2758
+ observation: { kind: 'offer.selection', offerRef: 'offer-compared-a', offerVersionRef: 'offer-compared-a:v1' },
2759
+ resultContract: { ...comparedReceipt.resultContract, requestedCount: undefined, actualCount: undefined },
2760
+ };
2761
+ const selectedTask = {
2762
+ ...comparedTask, taskId: selectedIntent.taskId, contextRef: selectedIntent.contextRef,
2763
+ allowedActions: ['offer.select'], revision: 1, activeIntent: selectedIntent,
2764
+ lastCompletedAction: completedCheckpoint(selectedAction, selectedIntent.sourceTurnId), lastReceipt: selectedReceipt,
2765
+ workspaceSnapshot: {
2766
+ ...comparedWorkspace, contextRef: selectedIntent.contextRef, revision: 1,
2767
+ shortlistedOfferRefs: ['offer-compared-a', 'offer-compared-b'], selectedOfferRef: 'offer-compared-a',
2768
+ capabilities: { surface: 'tenant', allowedActions: ['offer.select'] },
2769
+ },
2770
+ };
2771
+ assert.deepEqual(await runTerminalContinuation(selectedTask, selectedReceipt), [{ kind: 'terminal', terminal: { status: 'completed', summary: 'offer_selected', factRefs: [] } }], 'a selected offer in a target-sized authoritative shortlist completes without fabricated receipt counts');
2772
+ assert.deepEqual(await runTerminalContinuation({ ...selectedTask, workspaceSnapshot: { ...selectedTask.workspaceSnapshot, shortlistedOfferRefs: ['offer-compared-a'] } }, selectedReceipt), [{ kind: 'terminal', terminal: { status: 'stopped', summary: 'offer_target_not_reached', factRefs: [] } }], 'a later offer waypoint cannot forget that its authoritative shortlist was underfilled');
2773
+ await comparedPlanner.close();
2774
+ const visibleAuthorityWorkspace = {
2775
+ ...workspace,
2776
+ visibleHotels: [{ hotelRef: 'hotel-authority-visible', name: 'Visible Hotel', factRefs: ['hotel:authority-visible'] }],
2777
+ capabilities: { surface: 'tenant', allowedActions: ['hotel.focus'] },
2778
+ };
2779
+ const visibleHotelTask = {
2780
+ ...task,
2781
+ taskId: 'task-dsh-authority-hotel',
2782
+ lastTurnId: 'dsh-turn-authority-hotel',
2783
+ allowedActions: ['hotel.focus'],
2784
+ workspaceSnapshot: visibleAuthorityWorkspace,
2785
+ };
2786
+ await assert.rejects(runCompactAuthorityCase(visibleHotelTask, { kind: 'hotel.focus', input: { hotelRef: 'hotel-not-visible' }, intent: { target: 'hotel.focused' } }), /planner_hotel_ref_unbound/, 'hotel.focus rejects a hotel reference absent from visible workspace facts');
2787
+ const visibleHotelDecisions = await runCompactAuthorityCase(visibleHotelTask, { kind: 'hotel.focus', input: { hotelRef: 'hotel-authority-visible' }, intent: { target: 'hotel.focused' } });
2788
+ assertRuntimeMaterializedOperation(visibleHotelDecisions, 'hotel.focus', { hotelRef: 'hotel-authority-visible' }, visibleHotelTask, 'hotel.focus accepts only a visible hotel');
2789
+ assert.deepEqual(visibleHotelDecisions[0]?.action?.factRefs, ['hotel:authority-visible'], 'hotel.focus derives visible hotel facts');
2790
+ const offerAuthorityWorkspace = {
2791
+ ...workspace,
2792
+ loadedOffers: [{ offerRef: 'offer-authority', offerVersionRef: 'offer-authority:v2', hotelRef: 'hotel-1', evidenceLevel: 'rate_loaded', factRefs: ['offer:authority:v2'] }],
2793
+ capabilities: { surface: 'tenant', allowedActions: ['offer.select'] },
2794
+ };
2795
+ const offerTask = {
2796
+ ...task,
2797
+ taskId: 'task-dsh-authority-offer',
2798
+ lastTurnId: 'dsh-turn-authority-offer',
2799
+ allowedActions: ['offer.select'],
2800
+ workspaceSnapshot: offerAuthorityWorkspace,
2801
+ };
2802
+ await assert.rejects(runCompactAuthorityCase(offerTask, { kind: 'offer.select', input: { offerRef: 'offer-authority', offerVersionRef: 'offer-authority:v1' }, intent: { target: 'offer.selected' } }), /planner_offer_ref_unbound/, 'offer.select rejects an obsolete offer version');
2803
+ const offerDecisions = await runCompactAuthorityCase(offerTask, { kind: 'offer.select', input: { offerRef: 'offer-authority', offerVersionRef: 'offer-authority:v2' }, intent: { target: 'offer.selected' } });
2804
+ assertRuntimeMaterializedOperation(offerDecisions, 'offer.select', { offerRef: 'offer-authority', offerVersionRef: 'offer-authority:v2' }, offerTask, 'offer.select accepts the loaded offer version');
2805
+ assert.deepEqual(offerDecisions[0]?.action?.factRefs, ['offer:authority:v2'], 'offer.select derives authoritative offer facts');
2806
+ const checkoutAuthorityWorkspace = {
2807
+ ...offerAuthorityWorkspace,
2808
+ verifiedOffer: { offerRef: 'offer-authority', offerVersionRef: 'offer-authority:v2', verifiedOfferRef: 'verified-authority:v2', expiresAt: '2099-01-01T00:00:00.000Z' },
2809
+ capabilities: { surface: 'tenant', allowedActions: ['checkout.prepare'] },
2810
+ };
2811
+ const checkoutTask = {
2812
+ ...offerTask,
2813
+ taskId: 'task-dsh-authority-checkout',
2814
+ lastTurnId: 'dsh-turn-authority-checkout',
2815
+ allowedActions: ['checkout.prepare'],
2816
+ workspaceSnapshot: checkoutAuthorityWorkspace,
2817
+ };
2818
+ await assert.rejects(runCompactAuthorityCase(checkoutTask, { kind: 'checkout.prepare', input: { offerRef: 'offer-authority', offerVersionRef: 'offer-authority:v2', verifiedOfferRef: 'verified-authority:wrong' }, intent: { target: 'checkout.prepared' } }), /planner_verified_offer_ref_unbound/, 'checkout.prepare rejects a mismatched verified-offer triplet');
2819
+ const legacyAuthorityTask = {
2820
+ ...checkoutTask,
2821
+ taskId: 'task-dsh-authority-legacy',
2822
+ lastTurnId: 'dsh-turn-authority-legacy',
2823
+ allowedActions: ['search.run'],
2824
+ workspaceSnapshot: { ...workspace, capabilities: { surface: 'tenant', allowedActions: ['search.run'] } },
2825
+ };
2826
+ const legacyAuthorityAction = {
2827
+ ...searchRun,
2828
+ actionId: 'model-authored-action-id',
2829
+ reason: 'model-authored reason must not survive',
2830
+ factRefs: ['model-authored-fact'],
2831
+ };
2832
+ const [legacyAuthorityDecision] = await runCompactAuthorityCase(legacyAuthorityTask, { decision: { kind: 'operation', action: legacyAuthorityAction, intent: { target: 'search.results' } } });
2833
+ assert.equal(legacyAuthorityDecision?.kind, 'operation', 'legacy full action remains a rolling-compatibility input');
2834
+ if (legacyAuthorityDecision?.kind !== 'operation')
2835
+ throw new Error('legacy authority proposal did not materialize');
2836
+ assert.notEqual(legacyAuthorityDecision.action.actionId, legacyAuthorityAction.actionId, 'legacy model actionId is replaced by runtime identity');
2837
+ assert.notEqual(legacyAuthorityDecision.action.reason, legacyAuthorityAction.reason, 'legacy model reason is replaced by runtime reason');
2838
+ assert.notDeepEqual(legacyAuthorityDecision.action.factRefs, legacyAuthorityAction.factRefs, 'legacy model factRefs are not trusted');
2839
+ console.log('BOOKING COPILOT DSH PLANNER PROOF: typed proposals/runtime authority/task timeout isolation/no Book OK');