@danceiny/gotry 0.2.0-rc.31 → 0.2.0-rc.33

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 (128) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/bin/gotry-bootstrap.js +63 -53
  4. package/bin/gotry-hbcli-release.d.ts +3 -0
  5. package/bin/gotry-hbcli-release.js +4 -0
  6. package/client/client.js +66 -21
  7. package/dist/capabilities/doctor.js +7 -8
  8. package/dist/capabilities/effect.js +45 -12
  9. package/dist/capabilities/flyai-config.js +12 -10
  10. package/dist/capabilities/flyai.js +87 -56
  11. package/dist/capabilities/hbcli-config.js +156 -0
  12. package/dist/capabilities/hbcli.js +29 -2
  13. package/dist/capabilities/resilience.js +6 -6
  14. package/dist/capabilities/session/adapters/ctrip-flight.js +42 -7
  15. package/dist/capabilities/session/adapters/ctrip-hotel.js +52 -3
  16. package/dist/capabilities/session/bridge-ledger.js +168 -67
  17. package/dist/capabilities/session/ctrip-city.js +186 -0
  18. package/dist/capabilities/session/extension-bridge.js +148 -47
  19. package/dist/capabilities/session/gui-flight.js +949 -0
  20. package/dist/capabilities/session/gui-hotel.js +470 -0
  21. package/dist/capabilities/session-search.js +102 -14
  22. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +129 -2
  23. package/dist/scripts/booking-copilot-dsh-readiness-proof.js +53 -5
  24. package/dist/scripts/booking-copilot-gateway-chat-adapter-tests.js +233 -0
  25. package/dist/scripts/booking-copilot-planner-live-probe.js +1 -0
  26. package/dist/scripts/booking-copilot-startup-proof-tests.js +1 -1
  27. package/dist/scripts/bootstrap-tests.js +23 -6
  28. package/dist/scripts/bridge-ledger-tests.js +20 -18
  29. package/dist/scripts/ctrip-city-tests.js +80 -0
  30. package/dist/scripts/effect-tests.js +602 -504
  31. package/dist/scripts/extension-tests.js +14 -8
  32. package/dist/scripts/fixtures/tenant-runaway-worker-fixture.mjs +73 -0
  33. package/dist/scripts/flight-malformed-tests.js +1 -1
  34. package/dist/scripts/flyai-setup-tests.js +19 -4
  35. package/dist/scripts/flyai-tests.js +768 -653
  36. package/dist/scripts/gotry-web-api-tests.js +5 -0
  37. package/dist/scripts/gotry-web-client-tests.js +20 -1
  38. package/dist/scripts/gui-flight-live.js +27 -0
  39. package/dist/scripts/hbcli-tests.js +1 -1
  40. package/dist/scripts/hbcli-upgrade-tests.js +107 -0
  41. package/dist/scripts/hbcli-web-setup-tests.js +114 -0
  42. package/dist/scripts/hotel-date-gate-tests.js +1 -1
  43. package/dist/scripts/hotel-result-tests.js +169 -0
  44. package/dist/scripts/issue-271-liveness-tests.js +1 -1
  45. package/dist/scripts/mutation-check.js +153 -0
  46. package/dist/scripts/nightly-evidence.js +4 -111
  47. package/dist/scripts/session-tests.js +211 -6
  48. package/dist/scripts/tenant-bridge-org-tests.js +1264 -0
  49. package/dist/scripts/tenant-capacity-probe.js +263 -0
  50. package/dist/scripts/tenant-compute-isolation-tests.js +707 -0
  51. package/dist/scripts/tenant-egress-credentials-tests.js +822 -0
  52. package/dist/scripts/tenant-executor-binding-tests.js +203 -0
  53. package/dist/scripts/tenant-prompt-partition-tests.js +265 -0
  54. package/dist/scripts/tenant-seam-adversarial-tests.js +957 -0
  55. package/dist/scripts/tenant-seam-quota-tests.js +383 -0
  56. package/dist/scripts/tenant-seam-test-kit.js +292 -0
  57. package/dist/scripts/tenant-state-copies-tests.js +607 -0
  58. package/dist/scripts/tenant-state-drill-tests.js +298 -0
  59. package/dist/scripts/tenant-state-drill-worker.js +164 -0
  60. package/dist/scripts/tenant-state-durable-conformance.js +101 -0
  61. package/dist/scripts/tenant-state-handoff-tests.js +854 -0
  62. package/dist/scripts/tenant-state-lease-conformance.js +142 -0
  63. package/dist/scripts/tenant-state-lease-worker.js +28 -0
  64. package/dist/scripts/tenant-state-router-tests.js +464 -0
  65. package/dist/scripts/tenant-state-test-kit.js +92 -0
  66. package/dist/scripts/tenant-state-tests.js +564 -0
  67. package/dist/src/backend/modules/booking-executor.js +61 -6
  68. package/dist/src/backend/modules/session-search.js +136 -39
  69. package/dist/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  70. package/dist/src/booking-surface/dsh-planner.js +150 -19
  71. package/dist/src/booking-surface/index.js +1 -0
  72. package/dist/src/booking-surface/managed-dsh-run-port.js +121 -7
  73. package/dist/src/booking-surface/server.js +193 -80
  74. package/dist/src/booking-surface/startup.js +153 -0
  75. package/dist/src/booking-surface/tenancy.js +188 -0
  76. package/dist/src/gotry-web-api.js +64 -2
  77. package/dist/src/index.js +70 -18
  78. package/dist/src/llm-price-table.js +148 -0
  79. package/dist/src/tenant-seam/index.js +8 -0
  80. package/dist/src/tenant-seam/llm-broker.js +508 -0
  81. package/dist/src/tenant-seam/process-governor.js +281 -0
  82. package/dist/src/tenant-seam/quotas.js +360 -0
  83. package/dist/src/tenant-seam/seam.js +92 -0
  84. package/dist/src/tenant-state/durable-store.js +158 -0
  85. package/dist/src/tenant-state/epoch-copies.js +422 -0
  86. package/dist/src/tenant-state/errors.js +108 -0
  87. package/dist/src/tenant-state/ids.js +101 -0
  88. package/dist/src/tenant-state/index.js +11 -0
  89. package/dist/src/tenant-state/lease-store.js +203 -0
  90. package/dist/src/tenant-state/tenant-lease.js +150 -0
  91. package/dist/src/tenant-state/tenant-router.js +650 -0
  92. package/extension/manifest.json +2 -2
  93. package/package.json +5 -1
  94. package/ts/capabilities/flyai-config.ts +12 -10
  95. package/ts/capabilities/flyai.ts +91 -50
  96. package/ts/capabilities/hbcli-config.ts +128 -0
  97. package/ts/capabilities/hbcli.ts +30 -3
  98. package/ts/capabilities/session/adapters/ctrip-flight.ts +43 -7
  99. package/ts/capabilities/session/adapters/ctrip-hotel.ts +51 -4
  100. package/ts/capabilities/session/bridge-ledger.ts +192 -78
  101. package/ts/capabilities/session/ctrip-city.ts +158 -0
  102. package/ts/capabilities/session/extension-bridge.ts +185 -67
  103. package/ts/capabilities/session/gui-flight.ts +1022 -0
  104. package/ts/capabilities/session/gui-hotel.ts +505 -0
  105. package/ts/capabilities/session-search.ts +130 -18
  106. package/ts/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  107. package/ts/src/booking-surface/dsh-planner.ts +169 -19
  108. package/ts/src/booking-surface/index.ts +1 -0
  109. package/ts/src/booking-surface/managed-dsh-run-port.ts +150 -7
  110. package/ts/src/booking-surface/server.ts +215 -84
  111. package/ts/src/booking-surface/startup.ts +155 -0
  112. package/ts/src/booking-surface/tenancy.ts +242 -0
  113. package/ts/src/gotry-web-api.ts +51 -2
  114. package/ts/src/index.ts +67 -19
  115. package/ts/src/llm-price-table.ts +180 -0
  116. package/ts/src/tenant-seam/index.ts +9 -0
  117. package/ts/src/tenant-seam/llm-broker.ts +520 -0
  118. package/ts/src/tenant-seam/process-governor.ts +343 -0
  119. package/ts/src/tenant-seam/quotas.ts +433 -0
  120. package/ts/src/tenant-seam/seam.ts +142 -0
  121. package/ts/src/tenant-state/durable-store.ts +171 -0
  122. package/ts/src/tenant-state/epoch-copies.ts +491 -0
  123. package/ts/src/tenant-state/errors.ts +128 -0
  124. package/ts/src/tenant-state/ids.ts +108 -0
  125. package/ts/src/tenant-state/index.ts +12 -0
  126. package/ts/src/tenant-state/lease-store.ts +266 -0
  127. package/ts/src/tenant-state/tenant-lease.ts +175 -0
  128. package/ts/src/tenant-state/tenant-router.ts +743 -0
@@ -782,6 +782,54 @@ const invalidThenValidDecision = await invalidThenValid.plannerFactory(task).nex
782
782
  });
783
783
  assertRuntimeMaterializedOperation(invalidThenValidDecision, 'search.run', {}, task, 'a schema-rejected call may be superseded by the later unique typed success in the same run');
784
784
  await invalidThenValid.close();
785
+ let schemaRepairRuns = 0;
786
+ const schemaRejectedThenRepaired = await createDshEmbeddedBookingPlanner({
787
+ runPort: {
788
+ async run() {
789
+ schemaRepairRuns += 1;
790
+ if (schemaRepairRuns === 1) {
791
+ return {
792
+ finalResponse: '',
793
+ events: [
794
+ toolCall('booking_search_hotels', invalidCallArguments, 'call-schema-rejected'),
795
+ toolResult('call-schema-rejected', true, 'INVALID_ARGS'),
796
+ ],
797
+ };
798
+ }
799
+ return { finalResponse: '', events: successfulToolEvents('booking_search_hotels', JSON.stringify({ decision: { kind: 'operation', action: searchRun, intent: { target: 'search.results' } } }), 'call-schema-repaired') };
800
+ },
801
+ async close() { },
802
+ },
803
+ });
804
+ const schemaRepairedDecision = await schemaRejectedThenRepaired.plannerFactory(task).next({
805
+ task,
806
+ turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: 'dsh-schema-repair', workspace, request: { text: 'Find hotels' } },
807
+ });
808
+ assertRuntimeMaterializedOperation(schemaRepairedDecision, 'search.run', {}, task, 'one schema-shaped rejection is retried once and the repaired call is accepted');
809
+ assert.equal(schemaRepairRuns, 2, 'schema rejection consumes a bounded retry attempt');
810
+ await schemaRejectedThenRepaired.close();
811
+ let repeatedSchemaRejectionRuns = 0;
812
+ const repeatedSchemaRejections = await createDshEmbeddedBookingPlanner({
813
+ runPort: {
814
+ async run() {
815
+ repeatedSchemaRejectionRuns += 1;
816
+ return {
817
+ finalResponse: '',
818
+ events: [
819
+ toolCall('booking_search_hotels', invalidCallArguments, 'call-repeated-schema-rejection'),
820
+ toolResult('call-repeated-schema-rejection', true, 'INVALID_ARGS'),
821
+ ],
822
+ };
823
+ },
824
+ async close() { },
825
+ },
826
+ });
827
+ await assert.rejects(repeatedSchemaRejections.plannerFactory(task).next({
828
+ task,
829
+ turn: { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId: 'dsh-repeated-schema-rejection', workspace, request: { text: 'Find hotels' } },
830
+ }), /planner_invalid_typed_decision/, 'a schema-invalid repair must fail closed');
831
+ assert.equal(repeatedSchemaRejectionRuns, 2, 'a turn gets only one schema repair retry even if the repair is rejected again');
832
+ await repeatedSchemaRejections.close();
785
833
  const invalidWithoutSchemaRejection = await createDshEmbeddedBookingPlanner({
786
834
  runPort: {
787
835
  async run() {
@@ -1718,8 +1766,8 @@ const invalidCrossRun = await createDshEmbeddedBookingPlanner({
1718
1766
  await assert.rejects(invalidCrossRun.plannerFactory(task).next({
1719
1767
  task,
1720
1768
  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');
1769
+ }), /planner_booking_intent_required/, 'a schema-invalid call gets one repair run, but a still-invalid repaired payload cannot become a valid action');
1770
+ assert.equal(invalidCrossRunRuns, 2, 'one bounded schema-repair retry, not an unbounded correction loop');
1723
1771
  await invalidCrossRun.close();
1724
1772
  const confirmedTask = {
1725
1773
  ...task,
@@ -2179,6 +2227,85 @@ assert.notDeepEqual(bootRejectResult, { testDeadline: true }, 'an expired handsh
2179
2227
  assert.equal(bootRejectResult[0]?.error?.code, 'PLANNER_BOOT_TIMEOUT', 'an expired local handshake is typed as a boot timeout');
2180
2228
  assert.equal(bootRejectPortClosed, 1, 'an expired handshake closes its owned run port');
2181
2229
  await bootRejectPlanner.close();
2230
+ // A cold first boot that exceeds the local handshake budget must not fail the
2231
+ // turn while deadline headroom remains: the failed boot warmed the OS page
2232
+ // cache, so one fresh-port retry converges (Danceiny/gotry#660). The retry
2233
+ // must not consume a provider attempt, and the failed port must be closed.
2234
+ let bootRetryFirstClosed = 0;
2235
+ let bootRetrySecondRuns = 0;
2236
+ let bootRetrySecondClosed = 0;
2237
+ let bootRetryPortIndex = 0;
2238
+ const bootRetryPlanner = await createDshEmbeddedBookingPlanner({
2239
+ runPortFactory: () => {
2240
+ bootRetryPortIndex += 1;
2241
+ if (bootRetryPortIndex === 1) {
2242
+ return {
2243
+ run: () => Promise.reject(new Error('HARNESS_BOOT_TIMEOUT')),
2244
+ async close() { bootRetryFirstClosed += 1; },
2245
+ bootObservation: () => undefined,
2246
+ };
2247
+ }
2248
+ return {
2249
+ async run() {
2250
+ bootRetrySecondRuns += 1;
2251
+ return {
2252
+ finalResponse: '',
2253
+ events: successfulToolEvents('booking_search_hotels', JSON.stringify({ kind: 'search.run', input: {}, intent: { target: 'search.results' } }), 'call-boot-retry'),
2254
+ };
2255
+ },
2256
+ async close() { bootRetrySecondClosed += 1; },
2257
+ };
2258
+ },
2259
+ turnTimeoutMs: 30_000,
2260
+ });
2261
+ const bootRetryResult = await resolveWithin(bootRetryPlanner.plannerFactory(bootTimeoutTask).next({ task: bootTimeoutTask, turn: bootTimeoutTurn }), 250);
2262
+ assert.notDeepEqual(bootRetryResult, { testDeadline: true }, 'a retried boot stays inside the turn deadline');
2263
+ assert.equal(bootRetryResult[0]?.kind, 'operation', 'a fresh-port retry after a cold boot timeout produces the operation decision');
2264
+ assert.equal(bootRetryResult[0]?.action?.kind, 'search.run', 'the retried decision carries the capability action');
2265
+ assert.equal(bootRetryFirstClosed, 1, 'the failed boot port is closed by the retry');
2266
+ assert.ok(bootRetrySecondRuns >= 1, 'the fresh port actually ran the provider request');
2267
+ await bootRetryPlanner.close();
2268
+ // A fresh-port retry must never run on the port it just retired. A factory
2269
+ // that returns one shared instance hands the closed port back, and running it
2270
+ // rejects untyped out of next() (the #669 readiness-proof crash). Such a retry
2271
+ // cannot be funded, so each retry branch settles exactly as it does when the
2272
+ // deadline cannot fund one. These fakes reject like the real port once closed.
2273
+ let sharedBootFactoryCalls = 0;
2274
+ let sharedBootCloses = 0;
2275
+ const sharedBootPort = {
2276
+ run: () => Promise.reject(new Error(sharedBootCloses > 0 ? 'booking_planner_run_port_closed' : 'HARNESS_BOOT_TIMEOUT')),
2277
+ async close() { sharedBootCloses += 1; },
2278
+ bootObservation: () => undefined,
2279
+ };
2280
+ const sharedBootPlanner = await createDshEmbeddedBookingPlanner({
2281
+ runPortFactory: () => { sharedBootFactoryCalls += 1; return sharedBootPort; },
2282
+ turnTimeoutMs: 30_000,
2283
+ });
2284
+ const sharedBootResult = await resolveWithin(sharedBootPlanner.plannerFactory(bootTimeoutTask).next({ task: bootTimeoutTask, turn: bootTimeoutTurn }), 250);
2285
+ assert.notDeepEqual(sharedBootResult, { testDeadline: true }, 'a refused boot retry settles inside the turn deadline');
2286
+ assert.equal(sharedBootResult[0]?.error?.code, 'PLANNER_BOOT_TIMEOUT', 'a boot retry handed its own retired port settles as the typed boot timeout');
2287
+ assert.equal(sharedBootFactoryCalls, 2, 'the boot retry asked the factory for a fresh port once');
2288
+ assert.equal(sharedBootCloses, 1, 'the retired port is closed exactly once and never run again');
2289
+ await sharedBootPlanner.close();
2290
+ let sharedStallFactoryCalls = 0;
2291
+ let sharedStallCloses = 0;
2292
+ const sharedStallPort = {
2293
+ run: () => sharedStallCloses > 0
2294
+ ? Promise.reject(new Error('booking_planner_run_port_closed'))
2295
+ : new Promise((_resolve) => undefined),
2296
+ async close() { sharedStallCloses += 1; },
2297
+ };
2298
+ const sharedStallPlanner = await createDshEmbeddedBookingPlanner({
2299
+ runPortFactory: () => { sharedStallFactoryCalls += 1; return sharedStallPort; },
2300
+ turnTimeoutMs: 30_000,
2301
+ stallSoftBudgetMs: 20,
2302
+ });
2303
+ const sharedStallResult = await resolveWithin(sharedStallPlanner.plannerFactory(bootTimeoutTask).next({ task: bootTimeoutTask, turn: bootTimeoutTurn }), 500);
2304
+ assert.notDeepEqual(sharedStallResult, { testDeadline: true }, 'a refused stall retry settles inside the turn deadline');
2305
+ assert.equal(sharedStallResult[0]?.error?.code, 'PLANNER_PROVIDER_TIMEOUT', 'a stall retry handed its own retired port settles as the typed provider timeout');
2306
+ assert.equal(sharedStallFactoryCalls, 2, 'the stall retry asked the factory for a fresh port once');
2307
+ assert.equal(sharedStallCloses, 1, 'the stalled port is closed exactly once and never run again');
2308
+ await sharedStallPlanner.close();
2182
2309
  let taskAClosed = 0;
2183
2310
  let taskARuns = 0;
2184
2311
  let taskBRuns = 0;
@@ -157,11 +157,15 @@ try {
157
157
  // The local boot budget must govern its own phase. With a model-stall budget
158
158
  // wider than the boot budget, only the explicit initialize deadline can
159
159
  // settle this turn inside the asserted window; the SDK's 10s fallback or the
160
- // 13.3s stall budget would both land later and fail the bound below.
160
+ // 16s stall budget would both land later and fail the bound below.
161
+ // The 24s deadline funds the fresh-port boot retry (#669) with margin, but
162
+ // this factory hands back its one shared port, which the boot timeout has
163
+ // just retired: the planner must refuse to run on it and settle typed.
161
164
  phase = 'boot_budget_bound';
162
165
  const budgetGate = gate('boot-budget');
163
166
  port = await createRealRunPort({ stateRoot: root, env, dshBin: budgetGate.dshBin, maxTokens: 512 });
164
- planner = await createDshEmbeddedBookingPlanner({ runPortFactory: () => port, turnTimeoutMs: 20_000 });
167
+ let budgetPortRequests = 0;
168
+ planner = await createDshEmbeddedBookingPlanner({ runPortFactory: () => { budgetPortRequests += 1; return port; }, turnTimeoutMs: 24_000 });
165
169
  const budgetAt = Date.now();
166
170
  const budgetResultPromise = planner.plannerFactory(task).next({ turn, task });
167
171
  await until(() => existsSync(budgetGate.marker), 3_000, 'boot-budget runtime never entered startup gate');
@@ -169,14 +173,15 @@ try {
169
173
  const budgetResult = await bounded(budgetResultPromise, 15_000, 'boot budget did not settle the turn');
170
174
  const budgetElapsedMs = Date.now() - budgetAt;
171
175
  assert.equal(budgetResult[0]?.kind === 'error' && budgetResult[0].error.code, 'PLANNER_BOOT_TIMEOUT');
176
+ assert.equal(budgetPortRequests, 2, 'the funded boot retry asked for a fresh port and was handed back the retired one');
172
177
  assert.ok(budgetElapsedMs >= PLANNER_BOOT_BUDGET_MS, `the boot budget must be consumed before it fires (${budgetElapsedMs}ms)`);
173
- assert.ok(budgetElapsedMs < PLANNER_BOOT_BUDGET_MS + 6_000, `the local boot budget, not the 13.3s stall budget, must settle this turn (${budgetElapsedMs}ms)`);
178
+ assert.ok(budgetElapsedMs < PLANNER_BOOT_BUDGET_MS + 6_000, `the local boot budget, not the 16s stall budget, must settle this turn (${budgetElapsedMs}ms)`);
174
179
  assert.equal(requests.length, 1, 'a blocked handshake makes no provider request');
175
180
  await bounded(planner.close(), 3_000, 'boot-budget tree cleanup exceeded budget');
176
181
  planner = undefined;
177
182
  port = undefined;
178
183
  assert.ok(!alive(budgetPids.pid) && !alive(budgetPids.workerPid), 'boot-budget runtime and worker both reaped');
179
- console.log('DSH READINESS BOOT BUDGET:', JSON.stringify({ budgetMs: PLANNER_BOOT_BUDGET_MS, elapsedMs: budgetElapsedMs, code: 'PLANNER_BOOT_TIMEOUT', providerRequests: 0, treeReaped: true }));
184
+ console.log('DSH READINESS BOOT BUDGET:', JSON.stringify({ budgetMs: PLANNER_BOOT_BUDGET_MS, elapsedMs: budgetElapsedMs, code: 'PLANNER_BOOT_TIMEOUT', providerRequests: 0, retryRefused: true, treeReaped: true }));
180
185
  phase = 'initialize_failure';
181
186
  const failedMarker = join(root, 'failed-start.json');
182
187
  const failedBin = join(root, 'failed-dsh.mjs');
@@ -191,6 +196,49 @@ try {
191
196
  port = undefined;
192
197
  assert.ok(!alive(failed.pid) && !alive(failed.workerPid), 'failed runtime and worker both reaped');
193
198
  console.log('DSH READINESS FAILURE CLASSIFICATION:', JSON.stringify({ exitCode: failed.exitCode, signal: failed.signal, error: 'HARNESS_START_FAILED', rawStderrExposed: false, providerRequests: 0, treeReaped: true }));
199
+ // A cold first boot that exceeds the local budget is retried on a fresh
200
+ // runtime while the deadline still funds a viable run (#669). The first
201
+ // runtime stays blocked before initialize; the second is released up front,
202
+ // so it completes initialize and reaches the provider, which never answers.
203
+ // The 24s deadline funds the retry with margin on a slow host, and the 9s
204
+ // stall budget ends the stalled second run before a third boot fits.
205
+ phase = 'boot_retry_fresh_port';
206
+ const retryCold = gate('retry-cold');
207
+ const retryWarm = gate('retry-warm');
208
+ writeFileSync(retryWarm.release, 'release');
209
+ const retryPorts = [];
210
+ planner = await createDshEmbeddedBookingPlanner({
211
+ runPortFactory: async () => {
212
+ const dshBin = (retryPorts.length === 0 ? retryCold : retryWarm).dshBin;
213
+ const created = await createRealRunPort({ stateRoot: root, env, dshBin, maxTokens: 512 });
214
+ retryPorts.push(created);
215
+ return created;
216
+ },
217
+ turnTimeoutMs: 24_000,
218
+ stallSoftBudgetMs: 9_000,
219
+ });
220
+ const requestsBeforeRetry = requests.length;
221
+ const retryAt = Date.now();
222
+ const retryResultPromise = planner.plannerFactory(task).next({ turn, task });
223
+ await until(() => existsSync(retryCold.marker), 3_000, 'cold retry runtime never entered startup gate');
224
+ const coldRetryPids = retryCold.pids();
225
+ evidence = { ...coldRetryPids, providerRequests: requests.length };
226
+ await until(() => existsSync(retryWarm.marker), 15_000, 'the boot timeout never retried on a fresh runtime');
227
+ const retriedAfterMs = Date.now() - retryAt;
228
+ const warmRetryPids = retryWarm.pids();
229
+ assert.ok(retriedAfterMs >= PLANNER_BOOT_BUDGET_MS, `the retry waits for the local boot budget (${retriedAfterMs}ms)`);
230
+ assert.ok(retriedAfterMs < PLANNER_BOOT_BUDGET_MS + 6_000, `the local boot budget, not the SDK fallback, triggers the retry (${retriedAfterMs}ms)`);
231
+ assert.ok(!alive(coldRetryPids.pid) && !alive(coldRetryPids.workerPid), 'the timed-out runtime is reaped before the fresh one starts');
232
+ await until(() => requests.length === requestsBeforeRetry + 1, 10_000, 'the fresh runtime never reached the provider');
233
+ const retryResult = await bounded(retryResultPromise, 20_000, 'the retried turn did not settle');
234
+ const retryElapsedMs = Date.now() - retryAt;
235
+ assert.equal(retryResult[0]?.kind === 'error' && retryResult[0].error.code, 'PLANNER_PROVIDER_TIMEOUT', 'a retried boot that reached a stalled provider is a provider timeout, not a boot timeout');
236
+ assert.equal(retryPorts.length, 2, 'exactly one fresh-port retry');
237
+ assert.equal(requests.length, requestsBeforeRetry + 1, 'only the fresh runtime made a provider request');
238
+ await bounded(planner.close(), 3_000, 'boot-retry tree cleanup exceeded budget');
239
+ planner = undefined;
240
+ assert.ok(!alive(warmRetryPids.pid) && !alive(warmRetryPids.workerPid), 'the fresh runtime and worker are reaped on close');
241
+ console.log('DSH READINESS BOOT RETRY:', JSON.stringify({ budgetMs: PLANNER_BOOT_BUDGET_MS, retriedAfterMs, elapsedMs: retryElapsedMs, code: 'PLANNER_PROVIDER_TIMEOUT', ports: retryPorts.length, providerRequests: 1, treeReaped: true }));
194
242
  // Opt-in distribution run: boot fresh runtimes back to back so the local
195
243
  // initialize budget can be set from measured percentiles instead of a guess.
196
244
  // Each sample is a new worker plus a new dsh home, so the page cache is the
@@ -235,4 +283,4 @@ finally {
235
283
  if (failedCleanup?.status === 'rejected')
236
284
  throw failedCleanup.reason;
237
285
  }
238
- console.log('BOOKING COPILOT DSH READINESS PROOF: real initialize, provider stall, cold timeout and process cleanup OK');
286
+ console.log('BOOKING COPILOT DSH READINESS PROOF: real initialize, provider stall, cold timeout, boot retry and process cleanup OK');
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Proof tests for the gateway chat-completions LLM plugin
3
+ * (dsh-llm-gateway-chat.js): wire dialect against a mock OpenAI-style
4
+ * chat/completions server, patch wiring, and provider selection.
5
+ *
6
+ * Run: npx tsx scripts/booking-copilot-gateway-chat-adapter-tests.ts
7
+ */
8
+ import assert from 'node:assert/strict';
9
+ import { createServer } from 'node:http';
10
+ // A string-typed specifier keeps TypeScript from resolving the undeclared JS
11
+ // module; GatewayChatModule above is the surface this proof relies on.
12
+ const GATEWAY_CHAT_MODULE = '../src/booking-surface/dsh-llm-gateway-chat.js';
13
+ const { GatewayChatAdapter } = await import(GATEWAY_CHAT_MODULE);
14
+ const { buildDshEmbeddedBookingPatch, resolveRealRunPortConfig } = await import('../src/booking-surface/dsh-planner.js');
15
+ function chunksOf(chunks, type) {
16
+ return chunks.filter((chunk) => chunk.type === type);
17
+ }
18
+ function firstChunk(chunks, type) {
19
+ const [chunk] = chunksOf(chunks, type);
20
+ assert.ok(chunk, `expected a ${type} chunk`);
21
+ return chunk;
22
+ }
23
+ function errorCode(error) {
24
+ return typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined;
25
+ }
26
+ function sse(res, frames) {
27
+ res.writeHead(200, { 'content-type': 'text/event-stream' });
28
+ for (const frame of frames)
29
+ res.write(frame);
30
+ res.end('data: [DONE]\n\n');
31
+ }
32
+ async function withMockServer(handler, run) {
33
+ const server = createServer(handler);
34
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve()));
35
+ try {
36
+ const address = server.address();
37
+ assert.ok(address !== null && typeof address === 'object', 'mock server listens on a TCP port');
38
+ return await run(address.port);
39
+ }
40
+ finally {
41
+ server.close();
42
+ }
43
+ }
44
+ function adapterFor(port, captured) {
45
+ return new GatewayChatAdapter({
46
+ resolveConnection: () => ({ baseURL: `http://127.0.0.1:${port}/v1`, models: [] }),
47
+ resolveApiKey: async () => 'test-only-key',
48
+ providerName: 'Gateway Chat',
49
+ ...(captured !== undefined ? { __captured: captured } : {}),
50
+ });
51
+ }
52
+ // 1. A tool-calling stream translates to harness chunks: text block, tool-call
53
+ // accumulation across deltas, deferred block-ends, usage, and tool-calls finish.
54
+ {
55
+ const requests = [];
56
+ const chunks = await withMockServer((req, res) => {
57
+ let body = '';
58
+ req.on('data', (c) => { body += c; });
59
+ req.on('end', () => {
60
+ requests.push(JSON.parse(body));
61
+ sse(res, [
62
+ 'data: {"choices":[{"delta":{"role":"assistant","content":"Let me search"}}]}\n\n',
63
+ 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"booking_search_hotels","arguments":"{\\"input\\":"}}]}}]}\n\n',
64
+ 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"destination\\":\\"Osaka\\"}"}}]}}]}\n\n',
65
+ 'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":100,"completion_tokens":20,"total_tokens":120,"prompt_tokens_details":{"cached_tokens":40},"completion_tokens_details":{"reasoning_tokens":5}}}\n\n',
66
+ ]);
67
+ });
68
+ }, async (port) => {
69
+ const out = [];
70
+ for await (const chunk of adapterFor(port).stream({
71
+ provider: 'gateway-chat',
72
+ model: 'deepseek-flash',
73
+ messages: [
74
+ { role: 'system', content: [{ type: 'text', text: 'planner system prompt' }] },
75
+ { role: 'user', content: [{ type: 'text', text: 'hotels in Osaka' }] },
76
+ ],
77
+ tools: [{ name: 'booking_search_hotels', description: 'search hotels', parameters: { type: 'object', properties: {} } }],
78
+ maxTokens: 16_384,
79
+ }))
80
+ out.push(chunk);
81
+ return out;
82
+ });
83
+ assert.equal(requests.length, 1);
84
+ assert.equal(requests[0].model, 'deepseek-flash');
85
+ assert.deepEqual(requests[0].messages.map((m) => m.role), ['system', 'user']);
86
+ assert.equal(requests[0].tools?.[0].type, 'function');
87
+ assert.equal(requests[0].tools?.[0].function.name, 'booking_search_hotels');
88
+ assert.equal(requests[0].stream, true);
89
+ assert.deepEqual(requests[0].stream_options, { include_usage: true });
90
+ // The harness profile runs the planner with thinking enabled at high effort
91
+ // (rc.1 wire parity); dropping these fields measurably degrades the model's
92
+ // adherence to the planner's intent rules.
93
+ assert.deepEqual(requests[0].thinking, { type: 'enabled' });
94
+ assert.equal(requests[0].reasoning_effort, 'high');
95
+ const kinds = chunks.map((c) => c.type);
96
+ assert.deepEqual(kinds, ['block-start', 'text-delta', 'block-start', 'tool-call-delta', 'tool-call-delta', 'block-end', 'block-end', 'usage', 'finish']);
97
+ const text = firstChunk(chunks, 'text-delta');
98
+ assert.equal(text.text, 'Let me search');
99
+ const toolDelta = chunksOf(chunks, 'tool-call-delta');
100
+ assert.equal(toolDelta[0].id, 'call_1');
101
+ assert.equal(toolDelta[0].name, 'booking_search_hotels');
102
+ assert.equal(toolDelta.map((c) => c.argumentsDelta).join(''), '{"input":{"destination":"Osaka"}');
103
+ const ends = chunksOf(chunks, 'block-end');
104
+ assert.equal(ends[0].block.type, 'text');
105
+ const toolBlock = ends[1].block;
106
+ assert.ok(toolBlock.type === 'tool-call', 'the second block closes the tool call');
107
+ assert.equal(toolBlock.arguments, '{"input":{"destination":"Osaka"}');
108
+ const usage = firstChunk(chunks, 'usage');
109
+ assert.equal(usage.usage.inputTokens, 60, 'cache reads subtract from prompt tokens');
110
+ assert.equal(usage.usage.outputTokens, 20);
111
+ assert.equal(usage.usage.totalTokens, 120);
112
+ assert.equal(usage.usage.cacheReadTokens, 40);
113
+ assert.equal(usage.usage.reasoningTokens, 5);
114
+ const finish = firstChunk(chunks, 'finish');
115
+ assert.deepEqual(finish.reason, { kind: 'tool-calls' });
116
+ }
117
+ // 2. Tool-result history round-trips as `role:"tool"` wire messages.
118
+ {
119
+ const requests = [];
120
+ await withMockServer((req, res) => {
121
+ let body = '';
122
+ req.on('data', (c) => { body += c; });
123
+ req.on('end', () => {
124
+ requests.push(JSON.parse(body));
125
+ sse(res, [
126
+ 'data: {"choices":[{"delta":{"content":"done"}}]}\n\n',
127
+ 'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n',
128
+ ]);
129
+ });
130
+ }, async (port) => {
131
+ const out = [];
132
+ for await (const chunk of adapterFor(port).stream({
133
+ provider: 'gateway-chat',
134
+ model: 'deepseek-flash',
135
+ reasoningEffort: 'off',
136
+ messages: [
137
+ { role: 'user', content: [{ type: 'text', text: 'hotels in Osaka' }] },
138
+ { role: 'assistant', content: [{ type: 'tool-call', id: 'call_9', name: 'booking_search_hotels', arguments: '{"input":{}}' }] },
139
+ { role: 'user', content: [{ type: 'tool-result', toolCallId: 'call_9', content: [{ type: 'text', text: '598 hotels' }] }] },
140
+ ],
141
+ }))
142
+ out.push(chunk);
143
+ return out;
144
+ });
145
+ assert.deepEqual(requests[0].thinking, { type: 'disabled' }, 'reasoningEffort=off maps to thinking disabled');
146
+ assert.equal(requests[0].reasoning_effort, undefined, 'off never crosses the wire as an effort');
147
+ const roles = requests[0].messages.map((m) => m.role);
148
+ assert.deepEqual(roles, ['user', 'assistant', 'tool']);
149
+ assert.equal(requests[0].messages[1].tool_calls?.[0].id, 'call_9');
150
+ assert.equal(requests[0].messages[2].tool_call_id, 'call_9');
151
+ assert.equal(requests[0].messages[2].content, '598 hotels');
152
+ }
153
+ // 3. HTTP failure statuses map to typed LlmError codes.
154
+ {
155
+ await withMockServer((req, res) => {
156
+ res.writeHead(401, { 'content-type': 'application/json' });
157
+ res.end('{"error":"bad key"}');
158
+ }, async (port) => {
159
+ await assert.rejects(() => adapterFor(port).stream({ provider: 'gateway-chat', model: 'm', messages: [{ role: 'user', content: [{ type: 'text', text: 'x' }] }] }).next(), (error) => errorCode(error) === 'AUTH');
160
+ });
161
+ }
162
+ // 4. A stream that dies before [DONE] is STREAM_CLOSED, never a silent success.
163
+ {
164
+ await withMockServer((req, res) => {
165
+ res.writeHead(200, { 'content-type': 'text/event-stream' });
166
+ res.write('data: {"choices":[{"delta":{"content":"partial"}}]}\n\n');
167
+ res.end();
168
+ }, async (port) => {
169
+ await assert.rejects(() => adapterFor(port).stream({ provider: 'gateway-chat', model: 'm', messages: [{ role: 'user', content: [{ type: 'text', text: 'x' }] }] }).next(), () => true).catch((error) => {
170
+ // first next() may surface the text-delta fine; the failure lands on the
171
+ // following tick — drain to the throw.
172
+ });
173
+ let saw = false;
174
+ try {
175
+ for await (const _chunk of adapterFor(port).stream({ provider: 'gateway-chat', model: 'm', messages: [{ role: 'user', content: [{ type: 'text', text: 'x' }] }] })) {
176
+ void _chunk;
177
+ }
178
+ }
179
+ catch (error) {
180
+ saw = errorCode(error) === 'STREAM_CLOSED';
181
+ }
182
+ assert.ok(saw, 'a truncated stream must surface STREAM_CLOSED');
183
+ });
184
+ }
185
+ // 4b. Exact model metadata satisfies the harness INVALID_MODEL_INFO gate
186
+ // (provider/id/name required) and prepareCall returns the { model } shape.
187
+ {
188
+ const adapter = adapterFor(1);
189
+ const info = await adapter.resolveModel('gateway-chat', 'deepseek-flash');
190
+ assert.equal(info.provider, 'gateway-chat');
191
+ assert.equal(info.id, 'deepseek-flash');
192
+ assert.equal(info.name, 'deepseek-flash');
193
+ assert.ok(info.context.contextWindow > 0);
194
+ assert.ok(info.reasoning.efforts.some((e) => e.id === 'off'));
195
+ const prepared = await adapter.prepareCall('gateway-chat', 'deepseek-flash');
196
+ assert.equal(prepared.model.id, 'deepseek-flash');
197
+ assert.equal(typeof prepared.stream, 'function');
198
+ }
199
+ // 5. The patch renders both plugin inserts: the typed tools plugin and the
200
+ // gateway chat-completions LLM plugin.
201
+ {
202
+ const patch = buildDshEmbeddedBookingPatch('/tools/plugin.js', '/llm/gateway.js');
203
+ assert.match(patch, /- id: gotry-embedded-booking\n/);
204
+ assert.match(patch, /name: '\/tools\/plugin\.js'/);
205
+ assert.match(patch, /- id: llm-gateway-chat\n/);
206
+ assert.match(patch, /name: '\/llm\/gateway\.js'/);
207
+ const minimal = buildDshEmbeddedBookingPatch('/tools/plugin.js');
208
+ assert.match(minimal, /gotry-embedded-booking/);
209
+ assert.doesNotMatch(minimal, /llm-gateway-chat/);
210
+ }
211
+ // 6. Provider selection: DEEPSEEK_PROVIDER rides the child env and selects the
212
+ // gateway route without an explicit option.
213
+ {
214
+ const resolved = resolveRealRunPortConfig({
215
+ env: {
216
+ DEEPSEEK_API_KEY: 'k',
217
+ DEEPSEEK_BASE_URL: 'http://gw/v1',
218
+ DEEPSEEK_MODEL: 'deepseek-flash',
219
+ DEEPSEEK_PROVIDER: 'gateway-chat',
220
+ },
221
+ });
222
+ assert.equal(resolved.provider, 'gateway-chat');
223
+ assert.equal(resolved.childEnv.DEEPSEEK_PROVIDER, 'gateway-chat');
224
+ const fallback = resolveRealRunPortConfig({
225
+ env: {
226
+ DEEPSEEK_API_KEY: 'k',
227
+ DEEPSEEK_MODEL: 'deepseek-flash',
228
+ },
229
+ });
230
+ assert.equal(fallback.provider, 'deepseek-official');
231
+ assert.equal(fallback.childEnv.DEEPSEEK_PROVIDER, undefined);
232
+ }
233
+ console.log('BOOKING COPILOT GATEWAY CHAT ADAPTER PROOF: wire dialect/tool round-trip/status mapping/patch wiring/provider selection OK');
@@ -67,6 +67,7 @@ try {
67
67
  DEEPSEEK_API_KEY: apiKey,
68
68
  DEEPSEEK_BASE_URL: baseUrl,
69
69
  DEEPSEEK_MODEL: model,
70
+ ...(process.env.DEEPSEEK_PROVIDER ? { DEEPSEEK_PROVIDER: process.env.DEEPSEEK_PROVIDER } : {}),
70
71
  },
71
72
  // Match the UAT deployment deadline (planner-model-plane.yaml turnTimeoutMs).
72
73
  turnTimeoutMs: 45_000,
@@ -68,7 +68,7 @@ const started = await startBookingCopilotFromEnvironment(trustedEnv, {
68
68
  ingressBinding: trustedBinding,
69
69
  principal: { subject: 'startup-bff', scope: 'booking:read' },
70
70
  async startServer(options) {
71
- serverApiKey = options.apiKey;
71
+ serverApiKey = options.apiKey ?? '';
72
72
  serverArtifactId = options.artifactId ?? '';
73
73
  assert.equal(options.ingressMode, 'bff-ingress-binding');
74
74
  return {
@@ -103,12 +103,22 @@ assert.ok(!c7b.out.includes('下载通道'), '非法值不应进入 github 通
103
103
  console.log('7. 扩展分发 github 通道(不可达基址即时降级 + 非法值回落 bundled)OK');
104
104
  // 8. doctor 子命令(2026-09-02 迪拜 session 复盘:可选依赖不撒手——doctor 统一
105
105
  // 显示状态 + 精确补装指引;LLM key 显式让渡;报告落 gotry-state/doctor-report.md)
106
- const c8 = runBootstrap(['doctor'], {});
106
+ const doctorHome = mkdtempSync(join(tmpdir(), 'gotry-doctor-home-'));
107
+ let c8;
108
+ try {
109
+ c8 = runBootstrap(['doctor'], { HOME: doctorHome, FLYAI_API_KEY: '', DEBUG_FLYAI_API_KEY: '', STAICLI_HOME: join(doctorHome, '.staicli'), HOTELBYTE_TOKEN: '' });
110
+ }
111
+ finally {
112
+ rmSync(doctorHome, { recursive: true, force: true });
113
+ }
107
114
  assert.ok(c8.out.includes('[gotry-doctor]'), '应输出 [gotry-doctor] 标签');
108
115
  assert.ok(c8.out.includes('Agent Reach'), '体检应含 agent-reach 项');
109
116
  assert.ok(c8.out.includes('hbcli'), '体检应含 hbcli 项');
110
117
  assert.ok(c8.out.includes('FlyAI(飞猪官方检索'), '体检应含 flyai 项');
111
- assert.ok(c8.out.includes('匿名试用') || c8.out.includes('已配置,未验证'), '体检应明确 FlyAI 当前未验证/匿名降级面');
118
+ // FlyAI 面三合法态(doctor.ts #521:configured≠verified):匿名试用 / 已配置,未验证 / 已验证。
119
+ // 机器态依赖断言:本机配置并验证过 key 的环境(如 founder 机)doctor 输出「已验证 …」,
120
+ // 两态断言会把健康态误判为红(2026-10-14 发版门禁实测)。
121
+ assert.ok(c8.out.includes('匿名试用') || c8.out.includes('已配置,未验证') || c8.out.includes('已验证'), '体检应明确 FlyAI 当前状态(匿名试用/已配置,未验证/已验证)');
112
122
  assert.ok(c8.out.includes('LLM key'), '体检应含 LLM key 让渡说明(doctor 不管 key)');
113
123
  assert.ok(c8.out.includes('dsh-map-tools'), '体检应含 dsh-map-tools 项(#139;与 ts/capabilities/doctor.ts 两面成对)');
114
124
  assert.ok(c8.out.includes('dsh-tool-ask-user'), '体检应含 dsh-tool-ask-user 项(与 ts/capabilities/doctor.ts 两面成对)');
@@ -396,6 +406,7 @@ console.log('18. onboarding CLI 跳过(非 TTY / CI / GOTRY_SETUP_SKIP / GOTRY_O
396
406
  copyFileSync(join(repoRoot, 'bin', 'gotry-flyai-verification.js'), join(fixture, 'bin', 'gotry-flyai-verification.js'));
397
407
  copyFileSync(join(repoRoot, 'bin', 'gotry-process-liveness.js'), join(fixture, 'bin', 'gotry-process-liveness.js'));
398
408
  copyFileSync(join(repoRoot, 'bin', 'gotry-extension-status.js'), join(fixture, 'bin', 'gotry-extension-status.js'));
409
+ copyFileSync(join(repoRoot, 'bin', 'gotry-hbcli-release.js'), join(fixture, 'bin', 'gotry-hbcli-release.js'));
399
410
  copyFileSync(join(repoRoot, 'package.json'), join(fixture, 'package.json'));
400
411
  const vendorDir = join(fixture, 'ts', 'dsh-runtime', 'vendor');
401
412
  mkdirSync(vendorDir, { recursive: true });
@@ -647,6 +658,7 @@ function buildPkgFixture(tmpRoot) {
647
658
  copyFileSync(join(repoRoot, 'bin', 'gotry-bootstrap.js'), join(pkgBin, 'gotry-bootstrap.js'));
648
659
  copyFileSync(join(repoRoot, 'bin', 'gotry-flyai-verification.js'), join(pkgBin, 'gotry-flyai-verification.js'));
649
660
  copyFileSync(join(repoRoot, 'bin', 'gotry-extension-status.js'), join(pkgBin, 'gotry-extension-status.js'));
661
+ copyFileSync(join(repoRoot, 'bin', 'gotry-hbcli-release.js'), join(pkgBin, 'gotry-hbcli-release.js'));
650
662
  copyFileSync(join(repoRoot, 'bin', 'gotry-runtime-resolution.js'), join(pkgBin, 'gotry-runtime-resolution.js'));
651
663
  copyFileSync(join(repoRoot, 'bin', 'gotry-process-liveness.js'), join(pkgBin, 'gotry-process-liveness.js'));
652
664
  copyFileSync(join(repoRoot, 'cordis.gotry-patch.yml'), join(pkgRoot, 'cordis.gotry-patch.yml'));
@@ -1508,8 +1520,9 @@ console.log('23. result 通道排他写入(预存文件不被覆盖 + symlink
1508
1520
  assert.deepEqual(unit('not json'), { ok: true, customer: false }, '非 JSON(超旧版/异常)保守视为有效,与工具层同语义');
1509
1521
  assert.deepEqual(unit(''), { ok: true, customer: false }, '空输出同属不可解析,保守视为有效');
1510
1522
  assert.deepEqual(unit(null), { ok: false, customer: false }, '取不到输出(非零退出/超时/spawn 失败)→ 未确认');
1511
- const fakeHbcli = (whoamiArm) => `#!/bin/sh\ncase "$*" in\n *--version*) echo '0.0.4';;\n *version*) echo '0.0.4';;\n *whoami*) ${whoamiArm};;\nesac\n`;
1523
+ const fakeHbcli = (whoamiArm, version = '0.0.4') => `#!/bin/sh\ncase "$*" in\n *--version*) echo '${version}';;\n *version*) echo '${version}';;\n *whoami*) ${whoamiArm};;\nesac\n`;
1512
1524
  const cases = [
1525
+ { name: 'old-version', version: '0.0.3', arm: `echo '${allFalse.replace('"api_key":{"configured":false}', '"api_key":{"configured":true}')}'`, expect: 'missing' },
1513
1526
  { name: 'allfalse', arm: `echo '${allFalse}'`, expect: 'degraded' },
1514
1527
  { name: 'customer', arm: `echo '${allFalse.replace('"customer":{"configured":false}', '"customer":{"configured":true}')}'`, expect: 'ok', customer: true },
1515
1528
  { name: 'apikey', arm: `echo '${allFalse.replace('"api_key":{"configured":false}', '"api_key":{"configured":true}')}'`, expect: 'ok' },
@@ -1527,12 +1540,12 @@ console.log('23. result 通道排他写入(预存文件不被覆盖 + symlink
1527
1540
  mkdirSync(binDir);
1528
1541
  mkdirSync(home);
1529
1542
  symlinkSync(process.execPath, join(binDir, 'node'));
1530
- writeFileSync(join(binDir, 'hbcli'), fakeHbcli(c.arm), { mode: 0o755 });
1543
+ writeFileSync(join(binDir, 'hbcli'), fakeHbcli(c.arm, c.version), { mode: 0o755 });
1531
1544
  // CLI 面
1532
1545
  const cli = runBootstrap(['doctor'], { HOME: home, PATH: binDir, FLYAI_API_KEY: '' });
1533
1546
  const line = cli.out.split('\n').find((l) => l.includes('hbcli(酒店实时源)'));
1534
1547
  assert.ok(line, `${c.name}: CLI doctor 应输出 hbcli 行\n${cli.out}`);
1535
- const cliLevel = line.includes('已安装且凭证有效') ? 'ok' : line.includes('二进制在,但凭证未配置/失效') ? 'degraded' : 'other';
1548
+ const cliLevel = line.includes('已安装且凭证有效') ? 'ok' : line.includes('二进制在,但凭证未配置/失效') ? 'degraded' : line.includes('版本过旧') ? 'missing' : 'other';
1536
1549
  assert.equal(cliLevel, c.expect, `${c.name}: CLI doctor 的 hbcli 结论应为 ${c.expect}\n${line}`);
1537
1550
  if (c.customer)
1538
1551
  assert.ok(line.includes('customer 档在用'), `${c.name}: CLI 应标注 customer 档在用\n${line}`);
@@ -1542,6 +1555,10 @@ console.log('23. result 通道排他写入(预存文件不被覆盖 + symlink
1542
1555
  process.env.PATH = prevPath;
1543
1556
  assert.equal(tool.status, c.expect, `${c.name}: 工具层 doctor 的 hbcli 结论应为 ${c.expect},实际 ${tool.status}`);
1544
1557
  assert.equal(cliLevel, tool.status, `${c.name}: CLI 与工具层必须同结论(两份实现不得漂移)`);
1558
+ if (c.expect === 'missing') {
1559
+ assert.match(cli.out, /staicli-v0\.0\.4\/install\.sh/, 'CLI 应提供当前官方版本的安装入口');
1560
+ assert.match(tool.fix ?? '', /staicli-v0\.0\.4\/install\.sh/, '工具层应提供相同升级入口');
1561
+ }
1545
1562
  }
1546
1563
  finally {
1547
1564
  process.env.PATH = prevPath;
@@ -1560,5 +1577,5 @@ console.log('23. result 通道排他写入(预存文件不被覆盖 + symlink
1560
1577
  catch { /* ignore */ }
1561
1578
  }
1562
1579
  }
1563
- console.log('24. hbcli 凭证判定(CLI doctor 按 whoami 三档 JSON 判,不看退出码;与工具层 doctor 对拍 5 夹具同结论:全 false=degraded / customer·api_key=ok / 非 JSON=保守 ok / 非零退出=degraded)OK');
1580
+ console.log('24. hbcli 版本与凭证判定(CLI↔工具层 6 夹具同结论:旧版=missing / 全 false=degraded / customer·api_key=ok / 非 JSON=保守 ok / 非零退出=degraded)OK');
1564
1581
  console.log('BOOTSTRAP TESTS: 26/26 OK(扩展就位 + 跳过开关 / wizard --dry-run / wizard 真实 / 扩展分发通道 / doctor 体检面 / calendar setup 状态面 / 显式跳过 + auto 跳过 + 单项跳过 / 启动摘要 / sidebar 落盘状态复核 / onboarding 分类+计划+env opt-out+跳过原因+yes+partial-failure+幂等+prompt+CLI 跳过+--scan / 编排缝 orchestrateWebLaunch × 6 / prompt waiter failure deterministic process-group reap / waiter 原始错误 + cleanup timeout/failure handled / 临时安装包 spawned inner 21a TTY-eligible 真实 prompt→n→web + 摘要抑制 + 无残留 / 21b non-TTY 零 prompt / 21c POSIX 信号清理 SIGTERM→清 result+patch 目录 + 无残留子进程 / 21f yes-path installer 子树信号清理 / 21g yes-path timeout installer 子树清理 / 21d timeout 诊断 + 无残留 / 21e dsh-lifecycle 信号转发 / win32 平台边界 / result 通道排他写入 / hbcli 凭证判定 CLI↔工具层对拍)');