@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
@@ -31,6 +31,8 @@ import Database from 'better-sqlite3';
31
31
  import { EXTENSION_ORIGIN, createBridgeJobQueue, } from '../capabilities/session/extension-bridge.js';
32
32
  import { BRIDGE_SITE_COOLDOWN_MS, BridgeLedgerStore, bridgeLedgerDbPath, openBridgeLedgerStore, } from '../capabilities/session/bridge-ledger.js';
33
33
  import { startSessionSearchModule } from '../src/backend/modules/session-search.js';
34
+ // 账本方法的组织参数必填(漏传是编译错误):本套件验证的是缺省的单车道,统一传 local
35
+ const LOCAL = 'local';
34
36
  let passed = 0;
35
37
  async function check(label, assertion) {
36
38
  await assertion();
@@ -180,7 +182,7 @@ async function main() {
180
182
  assert.equal(row?.claimed_by_client, CLAIM_CLIENT);
181
183
  assert.equal(row?.attempts, 1);
182
184
  assert.ok(row?.claimed_at !== null, 'claimed_at 落账');
183
- const pacing = storeC.pacingOf('dida-portal');
185
+ const pacing = storeC.pacingOf(LOCAL, 'dida-portal');
184
186
  assert.ok(pacing && pacing.cooldownUntil > Date.now(), '导航类作业领取即记节律冷却');
185
187
  // 正常善果回包 → resolved + result 保留
186
188
  const r = await postResult(server.port, polled.job.jobId, { ok: true, kind: 'search', body: '{"rates":[]}', title: 'ok' }, { clientId: CLAIM_CLIENT });
@@ -213,7 +215,7 @@ async function main() {
213
215
  const row = storeC.getJob(polled.job.jobId);
214
216
  assert.equal(row?.status, 'unresolved', '曾被领取的超时终态 = unresolved');
215
217
  assert.ok(row?.result_json?.includes('timeout'), '超时结算留 timeout 备注');
216
- const pacing = storeC.pacingOf('train-12306');
218
+ const pacing = storeC.pacingOf(LOCAL, 'train-12306');
217
219
  assert.ok(pacing && pacing.cooldownUntil >= Date.now() + BRIDGE_SITE_COOLDOWN_MS - 2_000, '超时结算把站点冷却推满');
218
220
  }
219
221
  finally {
@@ -224,7 +226,7 @@ async function main() {
224
226
  const server = await serveQueue(queueC);
225
227
  try {
226
228
  const pending = queueC.submit({ kind: 'search', site: 'ctrip-hotel', url: 'https://hotels.ctrip.com/hotels/list' }, { timeoutMs: 300, extensionWaitMs: 30_000 });
227
- const pacingAtSubmit = storeC.pacingOf('ctrip-hotel');
229
+ const pacingAtSubmit = storeC.pacingOf(LOCAL, 'ctrip-hotel');
228
230
  assert.ok(pacingAtSubmit && pacingAtSubmit.cooldownUntil > Date.now(), '导航类作业提交即记节律(与 sessionDidaSearch 内存闸同语义:提交即承诺一次导航)');
229
231
  const outcome = await pending;
230
232
  assert.equal(outcome.ok, false);
@@ -238,7 +240,7 @@ async function main() {
238
240
  raw.close();
239
241
  }
240
242
  assert.equal(voided.length, 1, '未被领取的超时作业 = void');
241
- const pacingAfter = storeC.pacingOf('ctrip-hotel');
243
+ const pacingAfter = storeC.pacingOf(LOCAL, 'ctrip-hotel');
242
244
  assert.equal(pacingAfter?.cooldownUntil, pacingAtSubmit?.cooldownUntil, '从未被领取的超时结算不追加冷却(站点未被打)');
243
245
  }
244
246
  finally {
@@ -273,12 +275,12 @@ async function main() {
273
275
  let server;
274
276
  try {
275
277
  // 直接落账模拟上一进程崩溃后的持久状态(不经过队列 close——close 会把未领取作业落 void)
276
- storeE.insertQueuedJob({ jobId: 'rec-queued-fresh', kind: 'search', site: 'dida-portal', url: 'https://portal.dida.com/find' }, t0 + 60_000);
277
- storeE.insertQueuedJob({ jobId: 'rec-queued-stale', kind: 'search', site: 'dida-portal', url: 'https://portal.dida.com/find' }, t0 - 1_000);
278
- storeE.insertQueuedJob({ jobId: 'rec-claimed-live', kind: 'search', site: 'ctrip-flight', url: 'https://flights.ctrip.com/online/list' }, t0 + 60_000);
279
- storeE.markClaimed('rec-claimed-live', { origin: EXTENSION_ORIGIN, clientId: LIVE_CLIENT });
280
- storeE.insertQueuedJob({ jobId: 'rec-claimed-dead', kind: 'search', site: 'ctrip-hotel', url: 'https://hotels.ctrip.com/hotels/list' }, t0 - 1_000);
281
- storeE.markClaimed('rec-claimed-dead', { origin: EXTENSION_ORIGIN });
278
+ storeE.insertQueuedJob(LOCAL, { jobId: 'rec-queued-fresh', kind: 'search', site: 'dida-portal', url: 'https://portal.dida.com/find' }, t0 + 60_000);
279
+ storeE.insertQueuedJob(LOCAL, { jobId: 'rec-queued-stale', kind: 'search', site: 'dida-portal', url: 'https://portal.dida.com/find' }, t0 - 1_000);
280
+ storeE.insertQueuedJob(LOCAL, { jobId: 'rec-claimed-live', kind: 'search', site: 'ctrip-flight', url: 'https://flights.ctrip.com/online/list' }, t0 + 60_000);
281
+ storeE.markClaimed(LOCAL, 'rec-claimed-live', { origin: EXTENSION_ORIGIN, clientId: LIVE_CLIENT });
282
+ storeE.insertQueuedJob(LOCAL, { jobId: 'rec-claimed-dead', kind: 'search', site: 'ctrip-hotel', url: 'https://hotels.ctrip.com/hotels/list' }, t0 - 1_000);
283
+ storeE.markClaimed(LOCAL, 'rec-claimed-dead', { origin: EXTENSION_ORIGIN });
282
284
  q = createBridgeJobQueue({ store: storeE });
283
285
  server = await serveQueue(q);
284
286
  // 恢复先于任何轮询执行(等价挂载路径:模块启动即 recoverOnBoot)
@@ -289,7 +291,7 @@ async function main() {
289
291
  assert.equal(summary.settledUnresolved, 1);
290
292
  assert.equal(storeE.getJob('rec-queued-stale')?.status, 'void', '未被领取即过期 → void,不再派发(零花费)');
291
293
  assert.equal(storeE.getJob('rec-claimed-dead')?.status, 'unresolved', '领取后超时 → unresolved');
292
- assert.ok(storeE.pacingOf('ctrip-hotel') && storeE.pacingOf('ctrip-hotel').cooldownUntil > Date.now(), '超时结算记节律冷却');
294
+ assert.ok(storeE.pacingOf(LOCAL, 'ctrip-hotel') && storeE.pacingOf(LOCAL, 'ctrip-hotel').cooldownUntil > Date.now(), '超时结算记节律冷却');
293
295
  // 重排作业可被新进程的扩展领取并善果落账
294
296
  const polled = await pollJobs(server.port, { clientId: LIVE_CLIENT, capabilities: ['dida-portal'] });
295
297
  assert.equal(polled.job?.jobId, 'rec-queued-fresh', '恢复的 queued 作业重排进队列');
@@ -314,13 +316,13 @@ async function main() {
314
316
  const server = await serveQueue(queueC);
315
317
  try {
316
318
  const CLIENT = randomUUID();
317
- const pacingBefore = storeC.pacingOf('dida-portal');
319
+ const pacingBefore = storeC.pacingOf(LOCAL, 'dida-portal');
318
320
  const first = queueC.submit({ kind: 'cookie-names', site: 'dida-portal', timeoutMs: 3_000 }, { timeoutMs: 3_000 });
319
321
  const polled = await pollJobs(server.port, { clientId: CLIENT, capabilities: ['dida-portal'] });
320
322
  assert.ok(polled.job, 'cookie-names 作业应被领取');
321
323
  await postResult(server.port, polled.job.jobId, { ok: true, kind: 'cookie-names', names: ['dida_session_sig'] }, { clientId: CLIENT });
322
324
  assert.equal((await first).ok, true);
323
- const row1 = storeC.getClient(CLIENT);
325
+ const row1 = storeC.getClient(LOCAL, CLIENT);
324
326
  assert.ok(row1, '轮询即注册客户端');
325
327
  assert.equal(row1?.origin, EXTENSION_ORIGIN);
326
328
  assert.deepEqual(JSON.parse(row1?.capabilities_json ?? '[]'), ['dida-portal']);
@@ -332,11 +334,11 @@ async function main() {
332
334
  assert.ok(polled2.job, '第二次轮询仍可领取');
333
335
  await postResult(server.port, polled2.job.jobId, { ok: true, kind: 'cookie-names', names: [] }, { clientId: CLIENT });
334
336
  assert.equal((await second).ok, true);
335
- const row2 = storeC.getClient(CLIENT);
337
+ const row2 = storeC.getClient(LOCAL, CLIENT);
336
338
  assert.deepEqual(JSON.parse(row2?.capabilities_json ?? '[]'), ['dida-portal', 'train-12306'], 'upsert 覆盖能力清单');
337
339
  assert.ok((row2?.last_seen_at ?? 0) >= seen1, 'last_seen_at 单调刷新');
338
340
  assert.deepEqual(JSON.parse(row2?.login_sites_json ?? '[]'), ['dida-portal'], 'upsert 不回退 login_sites');
339
- const pacingAfter = storeC.pacingOf('dida-portal');
341
+ const pacingAfter = storeC.pacingOf(LOCAL, 'dida-portal');
340
342
  assert.deepEqual(pacingAfter, pacingBefore, 'cookie-names 非导航作业,不推动节律');
341
343
  }
342
344
  finally {
@@ -352,8 +354,8 @@ async function main() {
352
354
  try {
353
355
  // 预置一条上一进程遗留的已超时 claimed 作业:模块启动即应结算 unresolved
354
356
  const seed = openBridgeLedgerStore(rootG);
355
- seed.insertQueuedJob({ jobId: 'mount-recover-dead', kind: 'search', site: 'dida-portal', url: 'https://portal.dida.com/find' }, Date.now() - 1_000);
356
- seed.markClaimed('mount-recover-dead', { origin: EXTENSION_ORIGIN });
357
+ seed.insertQueuedJob(LOCAL, { jobId: 'mount-recover-dead', kind: 'search', site: 'dida-portal', url: 'https://portal.dida.com/find' }, Date.now() - 1_000);
358
+ seed.markClaimed(LOCAL, 'mount-recover-dead', { origin: EXTENSION_ORIGIN });
357
359
  seed.close();
358
360
  const mod = startSessionSearchModule({
359
361
  apiKey: () => 'ledger-test-key',
@@ -369,7 +371,7 @@ async function main() {
369
371
  // recoverOnBoot 已在模块启动路径跑过:遗留 claimed 超时作业被结算
370
372
  assert.equal(probe.getJob('mount-recover-dead')?.status, 'unresolved', '启动即恢复结算遗留超时作业');
371
373
  // 账本节律读判定:冷却内 429(带 retry-after)
372
- probe.applyCooldown('dida-portal');
374
+ probe.applyCooldown(LOCAL, 'dida-portal');
373
375
  const hot = await driveModuleSearch(mod, 'Bearer ledger-test-key');
374
376
  assert.equal(hot.statusCode, 429, '账本冷却内检索被节律闸拒绝');
375
377
  assert.ok(String(hot.headers['retry-after'] ?? '').length > 0, '429 携带 retry-after');
@@ -0,0 +1,80 @@
1
+ /** Supplier metadata fixtures; no vendor requests, credentials or browser session. */
2
+ import assert from 'node:assert/strict';
3
+ import { parseCtripHotelCities, resolveCtripHotelCity, parseCtripCityCatalog, parseCtripCitySuggestions, matchCtripCity, resolveCtripFlightCity } from '../capabilities/session/ctrip-city.js';
4
+ import { resolveFlightEntryUrl } from '../capabilities/session/adapters/ctrip-flight.js';
5
+ const catalog = { ResponseStatus: { Ack: 'Success' }, responseHead: { errorCode: '0' }, recommendGroupList: [{ indexedCity: { cityList: [
6
+ { id: 2, code: 'SHA', name: '上海', eName: 'Shanghai', airportlst: [{ code: 'PVG', name: '上海浦东国际机场' }] },
7
+ { id: 58, code: 'HKG', name: '中国香港', nameEn: 'Hong Kong' },
8
+ ] }, geographicalRegionList: [{ hotCityList: [{ cityList: [{ id: 220, code: 'DXB', name: '迪拜', eName: 'Dubai', specName: '迪拜(阿联酋)', sameSpecCity: 1 }] }] }] }] };
9
+ const cities = parseCtripCityCatalog(catalog);
10
+ for (const [name, code] of [['迪拜(阿联酋)', 'DXB'], ['DXB', 'DXB'], ['香港', 'HKG'], ['Hong Kong', 'HKG'], ['Shanghai', 'SHA'], ['上海浦东国际机场', 'PVG']]) {
11
+ const r = matchCtripCity(name, cities);
12
+ assert.ok(r.ok, name);
13
+ assert.equal(r.city.code, code);
14
+ }
15
+ assert.equal(matchCtripCity('Dubai', cities).ok, false, 'supplier-marked same-name cities require qualification');
16
+ assert.equal(matchCtripCity('迪', cities).ok, false, 'no prefix or nearby-city substitution');
17
+ assert.equal(matchCtripCity('同名城', [{ name: '同名城', code: 'AAA', aliases: ['同名城'], context: '甲国' }, { name: '同名城', code: 'BBB', aliases: ['同名城'], context: '乙国' }]).ok, false);
18
+ assert.throws(() => parseCtripCityCatalog({ ...catalog, responseHead: { errorCode: '1' } }));
19
+ const suggestions = { Data: [{ Type: 4, Name: '未缓存城市', Code: 'NEW', EName: 'New City', Country: '示例国', Nearby: [{ Type: 4, Name: '邻近城市', Code: 'NEA' }] }] };
20
+ assert.equal(parseCtripCitySuggestions(suggestions).length, 1);
21
+ let requests = [];
22
+ const fakeFetch = async (url) => {
23
+ requests.push(String(url));
24
+ return Response.json(requests.length === 1 ? catalog : suggestions);
25
+ };
26
+ const resolved = await resolveCtripFlightCity('未缓存城市', fakeFetch);
27
+ assert.ok(resolved.ok);
28
+ assert.equal(resolved.city.code, 'NEW');
29
+ assert.equal(requests.length, 2, 'catalog miss queries supplier autocomplete');
30
+ const blocked = await resolveCtripFlightCity('未知城市', async () => new Response('whaleguard block'));
31
+ assert.ok(!blocked.ok && blocked.reason === 'unavailable', 'challenge is unavailable, not unsupported/no inventory');
32
+ const oversized = await resolveCtripFlightCity('未知城市', async () => new Response('x'.repeat(512001)));
33
+ assert.ok(!oversized.ok && oversized.reason === 'unavailable');
34
+ const entry = await resolveFlightEntryUrl('迪拜(阿联酋)', '香港', '2026-12-01', {}, async (q) => matchCtripCity(q, cities));
35
+ assert.ok(entry.ok && entry.url?.includes('oneway-dxb-hkg'));
36
+ let lookups = 0;
37
+ assert.ok(!(await resolveFlightEntryUrl('Dubai', '香港', 'bad-date', {}, async () => { lookups++; throw Error('should not run'); })).ok);
38
+ assert.ok(!(await resolveFlightEntryUrl('Dubai', '香港', '2026-12-01', { fromCode: '../dxb' }, async () => { lookups++; throw Error('should not run'); })).ok);
39
+ assert.equal(lookups, 0);
40
+ console.log('Ctrip city metadata: international names/airports, ambiguity, autocomplete fallback, challenge/bounds and exact URL PASS');
41
+ const hotelRecord = (id, name, english, country) => ({ keyword: { keywordContentInfo: { tripType: 'CT', keywordCode: String(id), displayTexts: [{ key: 'MAIN_TITLE', value: english }] } }, controlInfo: { regionInfo: { basicCityModel: { cityId: id }, displayCityModel: { cityName: name, countryName: country, destinationNameEN: english } } } });
42
+ const hotels = { ResponseStatus: { Ack: 'Success' }, data: { mainKeywordList: { keywords: [hotelRecord(220, '迪拜', 'Dubai', '阿联酋'), hotelRecord(596578, '迪拜', 'Dubai', '印度')] } } };
43
+ const hotelFetch = async () => Response.json(hotels);
44
+ const ambiguous = await resolveCtripHotelCity('迪拜', undefined, hotelFetch);
45
+ assert.ok(!ambiguous.ok && ambiguous.reason === 'ambiguous');
46
+ assert.equal(ambiguous.candidates.length, 2);
47
+ const qualified = await resolveCtripHotelCity('迪拜', '阿联酋', hotelFetch);
48
+ assert.ok(qualified.ok && qualified.city.id === '220');
49
+ const rejected = await resolveCtripHotelCity('迪拜', undefined, async () => Response.json({ ...hotels, data: { ...hotels.data, htlSpiderActionErrorCode: 'captcha' } }));
50
+ assert.ok(!rejected.ok && rejected.reason === 'unavailable');
51
+ assert.throws(() => parseCtripHotelCities({ ...hotels, data: { mainKeywordList: { keywords: [{ ...hotelRecord(220, '迪拜', 'Dubai', '阿联酋'), keyword: { keywordContentInfo: { tripType: 'CT', keywordCode: '58' } } }] } } }));
52
+ console.log('Hotel destination namespace: same-name city ambiguity, country confirmation, challenge and ID conflict PASS');
53
+ // Exercise the real registered schema and tool dispatch, using an isolated state root.
54
+ const { apply } = await import('../src/index.js');
55
+ const { mkdtempSync, rmSync } = await import('node:fs');
56
+ const { tmpdir } = await import('node:os');
57
+ const { join } = await import('node:path');
58
+ const root = mkdtempSync(join(tmpdir(), 'gotry-city-tool-'));
59
+ const tools = [];
60
+ const effects = [];
61
+ try {
62
+ apply({ tools: { register: (t) => tools.push(t) }, systemPrompt: { variable: () => { } }, get: () => undefined, on: () => () => { } }, { stateRoot: root, timeoutMs: 1000, hbcliBin: 'unused', sessionAccess: 'allow' }, { effect: (async (fx) => { effects.push(fx); return { result: { ok: false, verdict: 'error', error: 'fixture only', evidence: '[fixture:city-tool]' }, trace: { effect: fx.effect, channel: 'browser', attempts: 1, backoffMs: 0, breaker: 'off', evidence: [] } }; }) });
63
+ const session = tools.find(t => t.name === 'gotry_session_search');
64
+ const hotel = tools.find(t => t.name === 'gotry_hotel_search');
65
+ const parameters = session.parameters?.properties ?? session.parameters;
66
+ assert.ok(parameters.country && parameters.fromCityCode && parameters.toCityCode, 'model must see disambiguation parameters on session search');
67
+ const hotelParameters = hotel.parameters?.properties ?? hotel.parameters;
68
+ assert.ok(!hotelParameters.country, 'HBCLI hotel tool must not advertise an ignored session parameter');
69
+ await session.execute({ kind: 'hotel', to: '迪拜', country: '阿联酋' }, {});
70
+ assert.equal(effects.at(-1).effect, 'SESSION_HOTEL_SEARCH');
71
+ assert.equal(effects.at(-1).params.country, '阿联酋');
72
+ await session.execute({ kind: 'flight', from: '迪拜', to: '香港', date: '2099-12-01', fromCityCode: 'DXB', toCityCode: 'HKG' }, {});
73
+ assert.equal(effects.at(-1).effect, 'SESSION_FLIGHT_SEARCH');
74
+ assert.equal(effects.at(-1).params.fromCityCode, 'DXB');
75
+ assert.equal(effects.at(-1).params.toCityCode, 'HKG');
76
+ console.log('Registered session tool: country/code schema and effect dispatch PASS');
77
+ }
78
+ finally {
79
+ rmSync(root, { recursive: true, force: true });
80
+ }