@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
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Mutation check runner (AaaS A2, docs/rfc/aaas-rfc.md §8.3: "falsification tests that must fail against a
3
+ * deliberately broken guard").
4
+ *
5
+ * A mutation list names a suite and a set of source edits. For each edit the runner clones the source trees into a
6
+ * scratch directory, breaks exactly one guard there, runs the suite, and requires it to fail. A suite that stays
7
+ * green under a broken guard proves nothing about that guard, so a surviving mutation fails the run. The list's
8
+ * unmutated baseline must pass first, otherwise "red" would only mean the harness is broken.
9
+ *
10
+ * The working tree is never edited: mutations happen in the clone, so a run can sit beside other work. A mutation file must
11
+ * name a path inside the clone (no absolute path, no `..`, nothing under node_modules, which is shared with the real tree).
12
+ *
13
+ * A mutation is "killed" only when the suite fails for a reason about behaviour. A suite that times out, and an edit that
14
+ * does not even load (syntax or module errors), prove nothing about the guard, so both fail the run as well.
15
+ *
16
+ * cd ts && npx tsx scripts/mutation-check.ts scripts/tenant-seam-mutations.json [--only <id>] [--keep] [--scratch <dir>]
17
+ *
18
+ * List format: { "suite": "scripts/x-tests.ts", "timeoutMs"?: number,
19
+ * "mutations": [{ "id", "file" (relative to ts/), "find" (must occur exactly once), "replace", "note"? }] }
20
+ */
21
+ import { spawnSync } from 'node:child_process';
22
+ import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
23
+ import { tmpdir } from 'node:os';
24
+ import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+ const TS_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
27
+ const REPO_ROOT = resolve(TS_ROOT, '..');
28
+ const SKIP = new Set(['node_modules', '.git', 'dsh-runtime', 'gotry-state']);
29
+ function argValue(args, flag) {
30
+ const at = args.indexOf(flag);
31
+ return at >= 0 ? args[at + 1] : undefined;
32
+ }
33
+ function cloneTrees(scratch) {
34
+ const copy = (from, to) => {
35
+ cpSync(from, to, { recursive: true, filter: (source) => !SKIP.has(basename(source)) });
36
+ };
37
+ const tsClone = join(scratch, 'ts');
38
+ copy(TS_ROOT, tsClone);
39
+ for (const dir of ['schemas', 'data']) {
40
+ if (existsSync(join(REPO_ROOT, dir)))
41
+ copy(join(REPO_ROOT, dir), join(scratch, dir));
42
+ }
43
+ // Installed packages are shared, not copied: the suites only read them.
44
+ symlinkSync(join(TS_ROOT, 'node_modules'), join(tsClone, 'node_modules'));
45
+ if (existsSync(join(REPO_ROOT, 'node_modules')))
46
+ symlinkSync(join(REPO_ROOT, 'node_modules'), join(scratch, 'node_modules'));
47
+ return tsClone;
48
+ }
49
+ /** An edit that does not load says nothing about a guard: `tsx` reports these, not an assertion. */
50
+ const BROKEN_EDIT = /SyntaxError|TransformError|Transform failed|ERR_MODULE_NOT_FOUND|ERR_UNKNOWN_FILE_EXTENSION|Unexpected token/;
51
+ class ListError extends Error {
52
+ }
53
+ /** The file a mutation edits, which must be inside the scratch clone and never in the shared node_modules. */
54
+ function mutationTarget(tsClone, file) {
55
+ const parts = file.split(/[\\/]/);
56
+ if (isAbsolute(file) || parts.includes('..') || parts.includes('node_modules'))
57
+ throw new ListError(`mutation file "${file}" must be a relative path inside ts/ and not under node_modules`);
58
+ const path = resolve(tsClone, file);
59
+ if (!path.startsWith(tsClone + sep))
60
+ throw new ListError(`mutation file "${file}" leaves the clone`);
61
+ if (!existsSync(path))
62
+ throw new ListError(`mutation file "${file}" does not exist`);
63
+ return path;
64
+ }
65
+ function runSuite(tsClone, suite, timeoutMs) {
66
+ const result = spawnSync(process.execPath, [join(tsClone, 'node_modules', 'tsx', 'dist', 'cli.mjs'), suite], {
67
+ cwd: tsClone,
68
+ encoding: 'utf8',
69
+ timeout: timeoutMs,
70
+ env: { ...process.env, GOTRY_SESSION_LIVE: '0', GOTRY_HBCLI_LIVE: '0', GOTRY_HOTELBYTE_SKILLS_LIVE: '0' },
71
+ maxBuffer: 64 * 1024 * 1024,
72
+ });
73
+ const timedOut = result.error?.code === 'ETIMEDOUT';
74
+ return { status: result.status, output: `${result.stdout ?? ''}${result.stderr ?? ''}`, timedOut };
75
+ }
76
+ function lastLines(text, count) {
77
+ return text.trim().split('\n').slice(-count).join('\n');
78
+ }
79
+ const args = process.argv.slice(2);
80
+ const listPath = args.find((arg) => !arg.startsWith('--') && arg !== argValue(args, '--only') && arg !== argValue(args, '--scratch'));
81
+ if (!listPath) {
82
+ console.error('usage: mutation-check.ts <mutation-list.json> [--only <id>] [--keep] [--scratch <dir>]');
83
+ process.exit(2);
84
+ }
85
+ const list = JSON.parse(readFileSync(resolve(TS_ROOT, listPath), 'utf8'));
86
+ const only = argValue(args, '--only');
87
+ const timeoutMs = list.timeoutMs ?? 180_000;
88
+ function run() {
89
+ const scratch = mkdtempSync(join(argValue(args, '--scratch') ?? tmpdir(), 'gotry-mutation-'));
90
+ try {
91
+ const tsClone = cloneTrees(scratch);
92
+ // Every target is checked before anything runs, so a bad list fails at once and leaves no half-run behind.
93
+ const targets = list.mutations.map((mutation) => ({ mutation, path: mutationTarget(tsClone, mutation.file) }));
94
+ const baseline = runSuite(tsClone, list.suite, timeoutMs);
95
+ if (baseline.status !== 0) {
96
+ console.error(`baseline FAILED (exit ${baseline.status}${baseline.timedOut ? ', timed out' : ''}) — mutation results would mean nothing\n${lastLines(baseline.output, 20)}`);
97
+ return 2;
98
+ }
99
+ console.log(`baseline ok: ${list.suite}`);
100
+ let killed = 0;
101
+ let failed = 0;
102
+ for (const { mutation, path } of targets) {
103
+ if (only && mutation.id !== only)
104
+ continue;
105
+ const original = readFileSync(path, 'utf8');
106
+ const occurrences = original.split(mutation.find).length - 1;
107
+ if (occurrences !== 1)
108
+ throw new ListError(`${mutation.id}: "find" occurs ${occurrences} times in ${mutation.file} (need exactly 1)`);
109
+ writeFileSync(path, original.replace(mutation.find, () => mutation.replace));
110
+ let outcome;
111
+ try {
112
+ outcome = runSuite(tsClone, list.suite, timeoutMs);
113
+ }
114
+ finally {
115
+ writeFileSync(path, original);
116
+ }
117
+ const label = `${mutation.id} ${mutation.file}${mutation.note ? ` — ${mutation.note}` : ''}`;
118
+ if (outcome.status === 0) {
119
+ failed += 1;
120
+ console.log(`SURVIVED ${label}`);
121
+ }
122
+ else if (outcome.timedOut) {
123
+ failed += 1;
124
+ console.log(`TIMEOUT ${label}\n the suite hung under this edit (${timeoutMs} ms): that is not a kill`);
125
+ }
126
+ else if (BROKEN_EDIT.test(outcome.output)) {
127
+ failed += 1;
128
+ console.log(`BROKEN ${label}\n the edited file does not load: ${(outcome.output.split('\n').find((line) => BROKEN_EDIT.test(line)) ?? '').trim().slice(0, 140)}`);
129
+ }
130
+ else {
131
+ killed += 1;
132
+ const reason = outcome.output.split('\n').find((line) => /AssertionError|Error:|FAIL|not ok/.test(line)) ?? `exit ${outcome.status}`;
133
+ console.log(`killed ${label}\n ${reason.trim().slice(0, 160)}`);
134
+ }
135
+ }
136
+ console.log(`${killed} killed, ${failed} not killed (survived, hung or did not load)`);
137
+ return failed === 0 ? 0 : 1;
138
+ }
139
+ catch (error) {
140
+ if (error instanceof ListError) {
141
+ console.error(error.message);
142
+ return 2;
143
+ }
144
+ throw error;
145
+ }
146
+ finally {
147
+ if (args.includes('--keep'))
148
+ console.log(`scratch kept: ${scratch}`);
149
+ else
150
+ rmSync(scratch, { recursive: true, force: true });
151
+ }
152
+ }
153
+ process.exit(run());
@@ -32,6 +32,7 @@ import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
32
32
  import { join } from 'node:path';
33
33
  import { createMockLlm } from '../src/mock-llm.js';
34
34
  import { createOpenAICompatLlm } from '../src/dsh-llm.js';
35
+ import { loadPriceTable, priceRunCost } from '../src/llm-price-table.js';
35
36
  import { newState, runTurn } from '../src/loop.js';
36
37
  import { solveUnified } from '../src/unified.js';
37
38
  import { realtimeSolvePort } from '../src/realtime-pricing.js';
@@ -50,9 +51,6 @@ function canonical(value) {
50
51
  function sha256Of(value) {
51
52
  return createHash('sha256').update(canonical(value)).digest('hex');
52
53
  }
53
- function round6(value) {
54
- return Number(value.toFixed(6));
55
- }
56
54
  export function loadPromptSet(path) {
57
55
  const raw = JSON.parse(readFileSync(path, 'utf8'));
58
56
  if (raw['schema_version'] !== 'gotry_m3_nightly_prompt_set_v1') {
@@ -64,114 +62,9 @@ export function loadPromptSet(path) {
64
62
  }
65
63
  return { schema_version: 'gotry_m3_nightly_prompt_set_v1', description: raw['description'], turns: turns };
66
64
  }
67
- function assertPriceEntry(label, value) {
68
- const entry = typeof value === 'object' && value !== null ? value : null;
69
- if (!entry)
70
- throw new Error(`${label} must be an object`);
71
- for (const field of ['input_cache_miss_usd_per_1m_peak', 'input_cache_hit_usd_per_1m_peak', 'output_usd_per_1m_peak']) {
72
- const num = entry[field];
73
- if (typeof num !== 'number' || !Number.isFinite(num) || num < 0) {
74
- throw new Error(`${label}.${field} must be a non-negative finite number`);
75
- }
76
- }
77
- return value;
78
- }
79
- export function loadPriceTable(path) {
80
- const raw = JSON.parse(readFileSync(path, 'utf8'));
81
- const schemaVersion = raw['schema_version'];
82
- if (schemaVersion === 'gotry_llm_price_table_v2') {
83
- return loadPriceTableV2(raw);
84
- }
85
- if (schemaVersion === 'gotry_llm_price_table_v1') {
86
- return loadPriceTableV1(raw);
87
- }
88
- throw new Error(`price table schema_version must be gotry_llm_price_table_v1 or _v2, got: ${String(schemaVersion)}`);
89
- }
90
- function loadPriceTableV1(raw) {
91
- const aliases = typeof raw['aliases'] === 'object' && raw['aliases'] !== null ? raw['aliases'] : {};
92
- for (const [from, to] of Object.entries(aliases)) {
93
- if (typeof to !== 'string')
94
- throw new Error(`price table alias ${from} must map to a model name`);
95
- }
96
- const prices = {};
97
- for (const [name, entry] of Object.entries(typeof raw['prices'] === 'object' && raw['prices'] !== null ? raw['prices'] : {})) {
98
- prices[name] = assertPriceEntry(`price table entry ${name}`, entry);
99
- }
100
- return { schema_version: 'gotry_llm_price_table_v1', aliases, prices };
101
- }
102
- /** v2 reader:providers.<id>.models.<model> 平铺为统一的 aliases/prices 双表,外暴露 API 不变。
103
- * lookup 规则:给定 model 名 →
104
- * 1. 若在 aliases 表中,跳到别名目标(qualified `providerId:model`);
105
- * 2. 否则直接以 qualified `providerId:model` 在 prices 中查找;
106
- * 3. 否则尝试裸 `model` 键(单 provider 时透明,如 MiniMax-M3 在 minimax:models 下唯一);
107
- * 4. 仍找不到 → fail-closed。 */
108
- function loadPriceTableV2(raw) {
109
- const providersObj = typeof raw['providers'] === 'object' && raw['providers'] !== null ? raw['providers'] : null;
110
- if (!providersObj)
111
- throw new Error('price table v2 requires providers object');
112
- const aliases = {};
113
- const prices = {};
114
- for (const [providerId, rawProvider] of Object.entries(providersObj)) {
115
- const provider = assertProviderEntry(`price table provider ${providerId}`, rawProvider);
116
- for (const [aliasName, target] of Object.entries(provider.aliases)) {
117
- if (typeof target !== 'string')
118
- throw new Error(`provider ${providerId} alias ${aliasName} must map to a model name`);
119
- if (!provider.models[target]) {
120
- throw new Error(`provider ${providerId} alias ${aliasName} -> ${target} has no entry; update data/llm-price-table.json via PR`);
121
- }
122
- aliases[aliasName] = target;
123
- }
124
- for (const [modelName, entry] of Object.entries(provider.models)) {
125
- const qualified = `${providerId}:${modelName}`;
126
- const validated = assertPriceEntry(`price table provider ${providerId} model ${modelName}`, entry);
127
- if (prices[qualified])
128
- throw new Error(`duplicate price table key ${qualified}`);
129
- prices[qualified] = validated;
130
- if (prices[modelName])
131
- throw new Error(`duplicate price table key ${modelName} (across providers, disambiguate via aliases or qualified lookup)`);
132
- prices[modelName] = validated;
133
- }
134
- }
135
- return { schema_version: 'gotry_llm_price_table_v2', aliases, prices };
136
- }
137
- function assertProviderEntry(label, value) {
138
- const entry = typeof value === 'object' && value !== null ? value : null;
139
- if (!entry)
140
- throw new Error(`${label} must be an object`);
141
- const family = entry['family'];
142
- if (typeof family !== 'string' || family.length === 0)
143
- throw new Error(`${label}.family must be a non-empty string`);
144
- const strategy = entry['price_strategy'];
145
- if (strategy !== 'tiered_peak_offpeak' && strategy !== 'flat_no_offpeak') {
146
- throw new Error(`${label}.price_strategy must be 'tiered_peak_offpeak' or 'flat_no_offpeak', got: ${String(strategy)}`);
147
- }
148
- const aliases = typeof entry['aliases'] === 'object' && entry['aliases'] !== null ? entry['aliases'] : {};
149
- const models = typeof entry['models'] === 'object' && entry['models'] !== null ? entry['models'] : {};
150
- return {
151
- family,
152
- price_strategy: strategy,
153
- api_base: typeof entry['api_base'] === 'string' ? entry['api_base'] : undefined,
154
- source_url: typeof entry['source_url'] === 'string' ? entry['source_url'] : undefined,
155
- aliases,
156
- models,
157
- };
158
- }
159
- /** 换算单次运行成本(peak 保守上界:全部输入按 cache miss 峰价计,只高不低)。 */
160
- export function priceRunCost(model, usage, table) {
161
- if (usage.responsesMissingUsage > 0) {
162
- throw new Error(`usage provider omitted usage on ${usage.responsesMissingUsage} response(s) — cost unprovable, refusing to price (fail-closed)`);
163
- }
164
- const alias = table.aliases[model];
165
- if (alias && !table.prices[alias])
166
- throw new Error(`price table alias ${model} -> ${alias} has no entry; update data/llm-price-table.json via PR`);
167
- const entry = table.prices[alias ?? model];
168
- if (!entry)
169
- throw new Error(`price table has no entry for model ${model}; update data/llm-price-table.json via PR (fail-closed, no guessed price)`);
170
- const cost = (usage.inputCacheMissTokens * entry.input_cache_miss_usd_per_1m_peak
171
- + usage.inputCacheHitTokens * entry.input_cache_hit_usd_per_1m_peak
172
- + usage.outputTokens * entry.output_usd_per_1m_peak) / 1_000_000;
173
- return round6(cost);
174
- }
65
+ // The frozen price table (ADR-20) and its pricing function live in src/llm-price-table.ts so the AaaS quota meter prices
66
+ // through the same code; they are re-exported here for the nightly producer and its callers.
67
+ export { loadPriceTable, priceRunCost };
175
68
  /** 记录构建(确定性:同内容同 executed_at => 同 run_key)。写入前由消费方 parseNightlyRun 校验。 */
176
69
  export function buildNightlyRunRecord(input) {
177
70
  const { schema_version, executed_at, real_llm, prompt_set_sha256, output_sha256, cost_usd } = input;
@@ -20,7 +20,9 @@ import { buildEntryUrl, parseBatchSearch, parseBatchSearchResult } from '../capa
20
20
  import { buildHotelEntryUrl, parseCtripHotelList, looksLikeHotelListBody } from '../capabilities/session/adapters/ctrip-hotel.js';
21
21
  import { buildTrainEntryUrl, hasRecognizedAvailableSeat, parseLeftTicketQuery, parseLeftTicketQueryResult, resolveTrainQueryTelecodes, STATION_TELECODES, validateTrainQueryResponseUrl } from '../capabilities/session/adapters/rail-12306.js';
22
22
  import { buildDidaEntryUrl, parseDidaRates, parseDidaSearchCache, looksLikeDidaRatesBody } from '../capabilities/session/adapters/dida-portal.js';
23
- import { sessionFlightSearch, sessionHotelSearch, sessionTrainSearch, sessionDidaSearch, trainStationUnresolvedHint, hotelCityUnresolvedHint, didaLoginHint, __resetRateLimiterForTest, __setTrainSessionTransportForTest, classifyTransportFailure } from '../capabilities/session-search.js';
23
+ import { sessionFlightSearch, sessionFlightGuiSearch, sessionHotelSearch, sessionTrainSearch, sessionDidaSearch, trainStationUnresolvedHint, hotelCityUnresolvedHint, flightCityUnresolvedHint, didaLoginHint, __resetRateLimiterForTest, __setTrainSessionTransportForTest, classifyTransportFailure } from '../capabilities/session-search.js';
24
+ import { planFlightGuiActions, assertGuiPlanSafe, runFlightGuiSearch, parseLowestPriceCalendar, __setGuiDriverForTest } from '../capabilities/session/gui-flight.js';
25
+ import { planHotelGuiActions, assertHotelPlanSafe, runHotelGuiSearch, __setHotelDriverForTest } from '../capabilities/session/gui-hotel.js';
24
26
  import { flyaiSearch } from '../capabilities/flyai.js';
25
27
  import { createConsentGate } from '../capabilities/session-consent.js';
26
28
  import { sessionLogin, pollTicketNames, LOGIN_TARGETS } from '../capabilities/session-login.js';
@@ -145,6 +147,16 @@ assert(e1.ok === true && e1.url === 'https://flights.ctrip.com/online/list/onewa
145
147
  const e2 = buildEntryUrl('乌兰巴托', '丽江', '2026-10-01');
146
148
  assert(e2.ok === false && e2.unresolved?.[0] === '乌兰巴托', '词表外城市 unresolved 逐字保留(不猜)');
147
149
  assert(buildEntryUrl('上海', '丽江', '十月一号').ok === false, '非法日期拒绝');
150
+ const internationalEntry = buildEntryUrl('迪拜', '香港', '2026-12-01', { fromCode: 'DXB', toCode: 'HKG' });
151
+ assert(internationalEntry.ok && internationalEntry.url?.includes('oneway-dxb-hkg') === true, '显式供应商城市码不受内置表限制');
152
+ assert(!buildEntryUrl('迪拜', '香港', '2026-12-01', { fromCode: '../dxb', toCode: 'HKG' }).ok, '显式城市码拒绝 URL 注入');
153
+ // 实测校准抽查(2026-10-14 list 页实证:合法组合 200+title 航线名,非法组合 302)
154
+ const e3 = buildEntryUrl('香港', '迪拜', '2026-12-06');
155
+ assert(e3.ok === true && e3.url === 'https://flights.ctrip.com/online/list/oneway-hkg-dxb?depdate=2026-12-06', '香港/迪拜=实测校准入缓存表(hkg/dxb)', e3);
156
+ const e4 = buildEntryUrl('乌兰巴托', '丽江', '2026-10-01', { fromCode: 'ubn' });
157
+ assert(e4.ok === true && e4.url === 'https://flights.ctrip.com/online/list/oneway-ubn-ljg?depdate=2026-10-01', '显式三字码逃生口(形状合格即成行)', e4);
158
+ assert(buildEntryUrl('乌兰巴托', '丽江', '2026-10-01', { fromCode: 'UBN!' }).ok === false, '显式码形状不合格不得进 URL');
159
+ assert(flightCityUnresolvedHint(['乌兰巴托']).includes('fromCityCode'), '机票 unresolved 指引带显式码逃生口');
148
160
  // D. DOM 提交件过滤
149
161
  console.log('D. isSubmitText(DOM 提交件黑名单)');
150
162
  assert(isSubmitText('立即下单') && isSubmitText('去支付') && isSubmitText('提交订单'), '下单/支付/提交 命中');
@@ -153,9 +165,9 @@ assert(!isSubmitText('搜索') && !isSubmitText('筛选'), '搜索/筛选 不误
153
165
  console.log('E. 节律闸(≥30s 间隔)');
154
166
  __resetRateLimiterForTest();
155
167
  const r1 = await sessionFlightSearch({ from: '乌兰巴托', to: '丽江', date: '2026-10-01' });
156
- const r2 = await sessionFlightSearch({ from: '上海', to: '丽江', date: '2026-10-01' });
168
+ const r2 = await sessionFlightSearch({ from: '另一个未知城市', to: '丽江', date: '2026-10-01' });
157
169
  assert(r1.verdict === 'error' && /unresolved/.test(r1.error ?? ''), '首次调用:词表外 → error(unresolved)');
158
- assert(r2.verdict === 'cooldown', '30s 内二次调用 → cooldown,不发起导航');
170
+ assert(r2.verdict === 'error', '城市解析失败不占用库存检索节律额度,不误报 cooldown');
159
171
  __resetRateLimiterForTest();
160
172
  // F. transport 失败分类 + live FlyAI 官方通道
161
173
  console.log('F. transport verdict + flyaiSearch(live,飞猪官方,无 key;GOTRY_SESSION_LIVE=0 跳过)');
@@ -167,7 +179,10 @@ if (process.env.GOTRY_SESSION_LIVE === '0') {
167
179
  console.log(' SKIP - GOTRY_SESSION_LIVE=0(离线门禁不调用 FlyAI 外部实时端点)');
168
180
  }
169
181
  else {
170
- fr = await flyaiSearch({ kind: 'flight', origin: '上海', destination: '丽江', depDate: '2026-10-01' });
182
+ // 滚动未来日期(硬编码日期跨日假红的教训:过去日期会被上游「出发日期非法」业务失败拒绝)
183
+ const roll = new Date(Date.now() + 30 * 24 * 3600 * 1000);
184
+ const rollingDepDate = `${roll.getFullYear()}-${String(roll.getMonth() + 1).padStart(2, '0')}-${String(roll.getDate()).padStart(2, '0')}`;
185
+ fr = await flyaiSearch({ kind: 'flight', origin: '上海', destination: '丽江', depDate: rollingDepDate });
171
186
  const sentinelBlocked = fr.verdict === 'error' && /sentinel|block|trial limit/i.test(fr.error ?? '');
172
187
  if (sentinelBlocked) {
173
188
  console.log(' WARN - 飞猪上游限流(Sentinel 2026-08-28 / trial-limit 429 2026-08-31 实测,配额未文档化)——降级合同验证通过,跳过 hit 断言');
@@ -540,8 +555,8 @@ console.log('K. 酒店适配器(buildHotelEntryUrl/走形解析/形状嗅探/城
540
555
  __resetRateLimiterForTest();
541
556
  const e1 = await sessionHotelSearch({ to: '不在码表的城市' });
542
557
  assert(e1.ok === false && e1.verdict === 'error' && /city=/.test(e1.error ?? ''), '未收录城市 → error + cityId 指引', e1);
543
- const e2 = await sessionHotelSearch({ to: '上海' });
544
- assert(e2.ok === false && e2.verdict === 'cooldown', '节律闸:同站点 30s 内第二调 → cooldown(不发起导航)', e2);
558
+ const e2 = await sessionHotelSearch({ to: '另一个未知酒店城市' });
559
+ assert(e2.ok === false && e2.verdict === 'error', '酒店城市解析失败不占用库存节律额度', e2);
545
560
  }
546
561
  // L. 火车会话面(2026-09-03 实装,12306 公开查询面)纯函数:entry URL/管道行解析/电报码指引/闸面
547
562
  console.log('L. 火车适配器(buildTrainEntryUrl/parseLeftTicketQuery/电报码指引/节律闸)');
@@ -854,6 +869,196 @@ console.log('M. Dida 门户(entry 守域 + 信封走形 + 闸面)');
854
869
  assert(q2.ok === false && q2.verdict === 'cooldown', '节律闸:30s 内二调 → cooldown', q2);
855
870
  assert(typeof didaLoginHint() === 'string' && /自动登录/.test(didaLoginHint()), '登录指引指向 hotel-be portal 跳板(账密永不经 gotry)');
856
871
  }
872
+ // L. 携程机票 GUI 车道(2026-10-14):规划器/安全白名单/假 driver 全流程/readback 硬闸
873
+ {
874
+ console.log('L. GUI 车道(规划器 + 安全闸 + 假 driver)');
875
+ const planOw = planFlightGuiActions({ from: '上海', to: '丽江', date: '2027-05-02' });
876
+ assert(planOw.some(a => a.t === 'clickTripTab' && a.trip === 'oneway') && !planOw.some(a => a.t === 'setDate' && a.which === 'back'), '单程规划:显式点单程 tab(页面默认往返态)/无回程日期');
877
+ const planRt = planFlightGuiActions({ from: '香港', to: '迪拜', date: '2026-12-06', backDate: '2026-12-13' });
878
+ assert(planRt.some(a => a.t === 'clickTripTab' && a.trip === 'roundtrip') && planRt.some(a => a.t === 'setDate' && a.which === 'back'), '往返规划:往返 tab + 回程日期(不知 URL 形状也能搜)');
879
+ const rb = planRt.find(a => a.t === 'readback');
880
+ assert(!!rb && rb.t === 'readback' && rb.expect.legs[0]?.back === '2026-12-13', 'readback 期望含回程日期(legs 形状)');
881
+ // 安全白名单:闭集外/提交件文本/非固定入口/坏日期/坏按键全拒
882
+ assert(!assertGuiPlanSafe([{ t: 'goto', url: 'https://evil.example.com/' }]).ok, '安全闸:非固定入口 URL 拒绝');
883
+ assert(!assertGuiPlanSafe([{ t: 'typeCity', which: 'from', text: '立即下单' }]).ok, '安全闸:城市位提交件文本拒绝(写面红线)');
884
+ assert(!assertGuiPlanSafe([{ t: 'setDate', which: 'dep', ymd: '十二月六号' }]).ok, '安全闸:非 YMD 日期拒绝');
885
+ assert(!assertGuiPlanSafe([{ t: 'press', key: 'Meta' }]).ok, '安全闸:按键白名单外拒绝');
886
+ assert(assertGuiPlanSafe(planRt).ok, '合法规划全过');
887
+ // 假 driver:表单内存模型 + 可编程回包
888
+ const FIXTURE = JSON.stringify({ data: { flightItineraryList: [{
889
+ flightSegments: [{ airlineName: 'Spring Airlines', duration: 215, flightList: [{ flightNo: '9C6617', departureAirportName: '浦东', arrivalAirportName: '三义', departureDateTime: '2027-05-02 17:05:00', arrivalDateTime: '2027-05-02 20:40:00' }] }],
890
+ priceList: [{ adultPrice: 1790 }],
891
+ }] } });
892
+ // live 实测形状(2026-10-14 GUI 捕获,382 条之一):顶层 priceList,/Date(ms+0800)/ 日期,price>0 才可售
893
+ const CAL_FIXTURE = JSON.stringify({ responseStatus: { Ack: 'Success' }, priceList: [
894
+ { departDate: '/Date(1791993600000+0800)/', price: 0 }, // 无价日(过滤)
895
+ { departDate: '/Date(1792080000000+0800)/', price: 880 }, // 2027-05-02? 由断言锚定实际换算
896
+ { departDate: '/Date(1792166400000+0800)/', price: 1020 },
897
+ { departDate: '/Date(1792252800000+0800)/', price: 760 },
898
+ ] });
899
+ const makeFakeDriver = (opts = {}) => {
900
+ const state = { clickedSearch: false, drives: 0, form: { from: '', to: '', dep: '', back: '' } };
901
+ const page = {
902
+ async goto() { },
903
+ async clickTripTab() { },
904
+ async typeCity(which, text) { state.form[which] = text; },
905
+ async pickSuggest(which, text) { state.form[which] = `${text}(SHA)`; /* 站点补全归一化形态 */ },
906
+ async setDate(which, ymd) { state.form[which === 'dep' ? 'dep' : 'back'] = ymd; },
907
+ async press() { },
908
+ async clickSearch() { state.clickedSearch = true; },
909
+ async visibleFormText() { return Object.values({ ...state.form, ...opts.formOverride }).concat(['上海', '丽江']).join(' | '); },
910
+ async cityFieldValues() { const f = { ...state.form, ...opts.formOverride }; return { from: f.from ?? '', to: f.to ?? '' }; },
911
+ async pageText() { return opts.challenge ? '请输入验证码 拖动滑块' : '机票预订'; },
912
+ async currentUrl() { return opts.listUrl ?? 'https://flights.ctrip.com/online/channel/domestic'; },
913
+ async waitForHumanLogin() { return opts.loginResult ?? false; },
914
+ async waitForApiCapture() {
915
+ state.drives += 1;
916
+ const first = state.drives === 1;
917
+ const itinerary = first
918
+ ? (opts.apiBody === undefined ? { url: 'https://flights.ctrip.com/international/search/api/search/batchSearch', body: FIXTURE } : opts.apiBody ? { url: 'x', body: opts.apiBody } : null)
919
+ : (opts.afterLoginBody ? { url: 'x', body: opts.afterLoginBody } : null);
920
+ const calendar = first && opts.calendarBody ? { url: 'https://m.ctrip.com/restapi/soa2/15380/bjjson/FlightIntlAndInlandLowestPriceSearch', body: opts.calendarBody } : null;
921
+ const probe = first && opts.loginProbe ? { url: 'x', body: '{"data":null,"needUserLogin":true}' } : undefined;
922
+ return (itinerary || calendar || probe) ? { ...(itinerary ? { itinerary } : {}), ...(calendar ? { calendar } : {}), ...(probe ? { loginProbe: probe } : {}) } : null;
923
+ },
924
+ };
925
+ return { driver: { open: async () => page, close: async () => { } }, state };
926
+ };
927
+ __setGuiDriverForTest(() => makeFakeDriver().driver);
928
+ const g1 = await runFlightGuiSearch({ from: '上海', to: '丽江', date: '2027-05-02' });
929
+ assert(g1.ok === true && g1.verdict === 'hit' && g1.anonymous === true && (g1.options?.length ?? 0) === 1, '假 driver 全流程 → hit(anonymous 标注)', g1);
930
+ assert(/\[会话:ctrip-flight-gui@/.test(g1.evidence) && /transport=gui/.test(g1.evidence), 'GUI 证据链 [会话:ctrip-flight-gui@ts]');
931
+ assert(g1.options?.[0]?.flightNo === '9C6617' && g1.options?.[0]?.price === 1790, '既有 batchSearch 解析器复用(结构化选项)');
932
+ // #661/#662:匿名绑定面——无行程回包、仅有低价日历 → hit + 日历数据 + listUrl 透传
933
+ const calDriver = makeFakeDriver({ apiBody: null, calendarBody: CAL_FIXTURE, listUrl: 'https://flights.ctrip.com/online/list/roundtrip-sha-ljg?depdate=2027-05-02&retdate=2027-05-09' });
934
+ __setGuiDriverForTest(() => calDriver.driver);
935
+ const g5 = await runFlightGuiSearch({ from: '上海', to: '丽江', date: '2027-05-02', backDate: '2027-05-09' });
936
+ assert(g5.ok === true && g5.verdict === 'hit' && (g5.options?.length ?? 0) === 0, '日历绑定:无行程回包时日历即 hit(#661)', g5);
937
+ assert((g5.calendar?.length ?? 0) === 3 && g5.calendar?.[0]?.date === '2026-10-16' && g5.calendar?.[0]?.price === 880 && g5.calendar?.[2]?.price === 760, '日历数据 /Date(ms)/→date 与 price>0 解析(#662,无价日过滤)', g5.calendar);
938
+ const calDup = parseLowestPriceCalendar(JSON.stringify({ priceList: [
939
+ { departDate: '/Date(1792080000000+0800)/', price: 900 }, { departDate: '/Date(1792080000000+0800)/', price: 700 },
940
+ ] }));
941
+ assert(calDup.length === 1 && calDup[0]?.price === 700, '往返双程同日双价 → 按日聚合取最低', calDup);
942
+ assert(g5.listUrl === 'https://flights.ctrip.com/online/list/roundtrip-sha-ljg?depdate=2027-05-02&retdate=2027-05-09', 'listUrl 回传(#663 URL 教师)', g5.listUrl);
943
+ assert(/低价日历/.test(g5.evidence), '日历证据链标注');
944
+ assert(parseLowestPriceCalendar('not json').length === 0 && parseLowestPriceCalendar('{"data":{"unknownShape":1}}').length === 0, '日历 parser 形状闸:未知形状空数组(不发明价格)');
945
+ const mixed = makeFakeDriver({ calendarBody: CAL_FIXTURE });
946
+ __setGuiDriverForTest(() => mixed.driver);
947
+ const g6 = await runFlightGuiSearch({ from: '上海', to: '丽江', date: '2027-05-02' });
948
+ assert((g6.options?.length ?? 0) === 1 && (g6.calendar?.length ?? 0) === 3 && /calendar=3天/.test(g6.evidence), '行程+日历双槽并存(行程优先结算,日历随行)', g6);
949
+ const rbDriver = makeFakeDriver({ formOverride: { dep: '2027-05-03' } }); // 日期被「改回」→ readback 必须拦
950
+ __setGuiDriverForTest(() => rbDriver.driver);
951
+ const g2 = await runFlightGuiSearch({ from: '上海', to: '丽江', date: '2027-05-02' });
952
+ assert(g2.ok === false && g2.verdict === 'error' && /readback 硬闸/.test(g2.error ?? ''), 'readback 硬闸:日期不符 → 终止,不发起搜索', g2);
953
+ assert(rbDriver.state.clickedSearch === false, 'readback 拦截后搜索按钮零点击(绝不搜错日期)');
954
+ const cityDriver = makeFakeDriver({ formOverride: { from: '北京(BJS)' } }); // 城市打进别的框(live 实测形态)
955
+ __setGuiDriverForTest(() => cityDriver.driver);
956
+ const g2b = await runFlightGuiSearch({ from: '上海', to: '丽江', date: '2027-05-02' });
957
+ assert(g2b.ok === false && /城市框未确认/.test(g2b.error ?? '') && cityDriver.state.clickedSearch === false, 'readback 逐字段:城市框错位(全局框假阳)→ 终止', g2b);
958
+ __setGuiDriverForTest(() => makeFakeDriver({ challenge: true }).driver);
959
+ const g3 = await runFlightGuiSearch({ from: '上海', to: '丽江', date: '2027-05-02' });
960
+ assert(g3.ok === false && g3.verdict === 'challenged', '风控页 → challenged(不重试不绕过)', g3);
961
+ __setGuiDriverForTest(() => makeFakeDriver({ apiBody: null }).driver);
962
+ const g4 = await runFlightGuiSearch({ from: '上海', to: '丽江', date: '2027-05-02' });
963
+ assert(g4.ok === false && g4.verdict === 'error' && /嗅探超时/.test(g4.error ?? ''), '回包超时 → 结构化 error', g4);
964
+ // 编排面:节律闸同站同桶 + 回程日期校验
965
+ __resetRateLimiterForTest();
966
+ __setGuiDriverForTest(() => makeFakeDriver().driver);
967
+ const s1 = await sessionFlightGuiSearch({ from: '上海', to: '丽江', date: '2027-05-02' });
968
+ assert(s1.ok === true && s1.verdict === 'hit', 'sessionFlightGuiSearch 编排 → hit');
969
+ const s2 = await sessionFlightGuiSearch({ from: '上海', to: '丽江', date: '2027-05-02' });
970
+ assert(s2.ok === false && s2.verdict === 'cooldown', 'GUI 与 bridge 同站同桶:30s 内二调 → cooldown');
971
+ __resetRateLimiterForTest();
972
+ const s3 = await sessionFlightGuiSearch({ from: '上海', to: '丽江', date: '2027-05-02', backDate: '2027-05-01' });
973
+ assert(s3.ok === false && s3.verdict === 'error' && /回程日期/.test(s3.error ?? ''), '回程早于去程 → 参数闸拒绝');
974
+ // bridge 往返(#663 解锁):URL 形状由 GUI 教师实测定稿;回程≤去程仍参数闸拒绝
975
+ __resetRateLimiterForTest();
976
+ const s4 = await sessionFlightSearch({ from: '上海', to: '丽江', date: '2027-05-02', backDate: '2027-05-01' });
977
+ assert(s4.ok === false && s4.verdict === 'error' && /回程日期/.test(s4.error ?? ''), 'bridge 回程早于去程 → 参数闸拒绝', s4);
978
+ const rtUrl = buildEntryUrl('香港', '迪拜', '2026-11-13', {}, '2026-11-17');
979
+ assert(rtUrl.ok === true && rtUrl.url === 'https://flights.ctrip.com/online/list/round-hkg0-dxb0?depdate=2026-11-13_2026-11-17&cabin=y_s&adult=1&child=0&infant=0', '往返 URL=GUI 教师实证形状(round-{code}0)', rtUrl);
980
+ __setGuiDriverForTest(null);
981
+ // N. GUI 登录态配对 + 多程(2026-10-15,#661/#663/#664 余尾收口)
982
+ {
983
+ console.log('N. 登录态配对(interactiveLogin)+ 多程规划');
984
+ // 多程规划:multi tab + 逐段(城市×2+日期)动作 + legs readback
985
+ const planMc = planFlightGuiActions({ from: '', to: '', date: '', legs: [
986
+ { from: '香港', to: '迪拜', date: '2026-12-06' },
987
+ { from: '迪拜', to: '上海', date: '2026-12-10' },
988
+ ] });
989
+ assert(planMc.some(a => a.t === 'clickTripTab' && a.trip === 'multi'), '多程规划:点 multi tab');
990
+ assert(planMc.filter(a => a.t === 'setDate').length === 2 && planMc.some(a => a.t === 'setDate' && a.seg === 2), '多程规划:逐段日期(seg 维度)');
991
+ assert(!assertGuiPlanSafe([{ t: 'typeCity', which: 'from', text: '香港', seg: 4 }]).ok, '安全闸:seg 闭集(1–3)外拒绝');
992
+ // 登录配对(机票):首驾只有日历+needUserLogin 探测 → 等人登录 → 重驾拿行程 → anonymous=false
993
+ const loginDriver = makeFakeDriver({ apiBody: null, calendarBody: CAL_FIXTURE, loginProbe: true, loginResult: true, afterLoginBody: FIXTURE });
994
+ __setGuiDriverForTest(() => loginDriver.driver);
995
+ const n1 = await runFlightGuiSearch({ from: '上海', to: '丽江', date: '2026-11-13', interactiveLoginMs: 60_000 });
996
+ assert(n1.ok === true && (n1.options?.length ?? 0) === 1 && n1.anonymous === false, '登录配对(机票):探测→等人登录→重驾→行程明细 hit,anonymous=false', n1);
997
+ assert(/本人已在本窗口官方页登录/.test(n1.evidence), '登录态证据链标注');
998
+ // 登录等待超时:needs-login 终态(不冒充成功)
999
+ const timeoutDriver = makeFakeDriver({ apiBody: null, calendarBody: CAL_FIXTURE, loginProbe: true, loginResult: false });
1000
+ __setGuiDriverForTest(() => timeoutDriver.driver);
1001
+ const n2 = await runFlightGuiSearch({ from: '上海', to: '丽江', date: '2026-11-13', interactiveLoginMs: 60_000 });
1002
+ assert(n2.ok === false && n2.verdict === 'needs-login' && /登录等待超时/.test(n2.error ?? ''), '登录等待超时 → needs-login 终态', n2);
1003
+ // 未授权等待(interactiveLoginMs 缺省):保持匿名日历面,不弹登录
1004
+ const anonDriver = makeFakeDriver({ apiBody: null, calendarBody: CAL_FIXTURE, loginProbe: true });
1005
+ __setGuiDriverForTest(() => anonDriver.driver);
1006
+ const n3 = await runFlightGuiSearch({ from: '上海', to: '丽江', date: '2026-11-13' });
1007
+ assert(n3.ok === true && (n3.calendar?.length ?? 0) === 3 && n3.anonymous === true, '未授权等待:保持匿名日历面(默认行为不变)', n3);
1008
+ __setGuiDriverForTest(null);
1009
+ }
1010
+ }
1011
+ // M. 携程酒店 GUI 车道(2026-10-14,#664 首片):规划/安全闸/readback/needs-login 边界
1012
+ {
1013
+ console.log('M. 酒店 GUI 车道(规划器 + 安全闸 + 假 driver + 登录墙判语)');
1014
+ const hp = planHotelGuiActions({ to: '迪拜', checkIn: '2026-12-06', checkOut: '2026-12-08' });
1015
+ assert(hp.some(a => a.t === 'setDate' && a.which === 'in') && hp.some(a => a.t === 'setDate' && a.which === 'out'), '酒店规划:入住/退房成对');
1016
+ assert(!assertHotelPlanSafe([{ t: 'typeCity', text: '立即下单' }]).ok, '安全闸:目的地位提交件文本拒绝');
1017
+ assert(!assertHotelPlanSafe([{ t: 'goto', url: 'https://evil.example.com/' }]).ok, '安全闸:非固定入口拒绝');
1018
+ const HOTEL_FIXTURE = JSON.stringify({ ResponseStatus: { Ack: 'Success' }, hotelList: [
1019
+ { hotelName: '测试酒店', minPrice: 500, star: 4 },
1020
+ ] });
1021
+ const mkHotelDriver = (opts = {}) => {
1022
+ const state = { clickedSearch: false, form: { to: '', in: '', out: '' }, url: 'https://hotels.ctrip.com/' };
1023
+ const page = {
1024
+ async goto() { },
1025
+ async typeCity(text) { state.form.to = text; },
1026
+ async pickSuggest(text) { state.form.to = `${text}(测试)`; },
1027
+ async setDate(which, ymd) { state.form[which] = ymd; },
1028
+ async press() { },
1029
+ async clickSearch() { state.clickedSearch = true; if (opts.passportAfterSearch)
1030
+ state.url = 'https://passport.ctrip.com/user/login?backurl=x'; },
1031
+ async visibleFormText() {
1032
+ const f = { ...state.form, ...opts.formOverride };
1033
+ const cn = (ymd) => ymd ? `${Number(ymd.slice(5, 7))}月${Number(ymd.slice(8, 10))}日` : '';
1034
+ return Object.values(f).concat(['上海', cn(f.in ?? ''), cn(f.out ?? '')]).join(' | ');
1035
+ },
1036
+ async destFieldValue() { return (opts.formOverride?.to ?? state.form.to) + '(测试)'; },
1037
+ async pageText() { return '酒店预订'; },
1038
+ async currentUrl() { return state.url; },
1039
+ async waitForHumanLogin() { return false; },
1040
+ async waitForApiBody() {
1041
+ if (opts.passportAfterSearch)
1042
+ return null;
1043
+ return opts.body === undefined ? { url: 'https://hotels.ctrip.com/hotels/api/list', body: HOTEL_FIXTURE } : opts.body ? { url: 'x', body: opts.body } : null;
1044
+ },
1045
+ };
1046
+ return { driver: { open: async () => page, close: async () => { } }, state };
1047
+ };
1048
+ __setHotelDriverForTest(() => mkHotelDriver().driver);
1049
+ const h1 = await runHotelGuiSearch({ to: '上海', checkIn: '2026-11-13', checkOut: '2026-11-15' });
1050
+ assert(h1.ok === true && h1.verdict === 'hit' && h1.anonymous === true && (h1.hotels?.length ?? 0) >= 1, '酒店假 driver 全流程 → hit(匿名标注;本地化日期格式核对)', h1);
1051
+ const hLogin = mkHotelDriver({ passportAfterSearch: true });
1052
+ __setHotelDriverForTest(() => hLogin.driver);
1053
+ const h2 = await runHotelGuiSearch({ to: '上海', checkIn: '2026-11-13', checkOut: '2026-11-15' });
1054
+ assert(h2.ok === false && h2.verdict === 'needs-login' && /gotry_session_login/.test(h2.error ?? ''), '登录墙(passport 跳转)→ needs-login 判语(live 实证边界)', h2);
1055
+ const hBadDate = mkHotelDriver({ formOverride: { in: '2026-11-20' } });
1056
+ __setHotelDriverForTest(() => hBadDate.driver);
1057
+ const h3 = await runHotelGuiSearch({ to: '上海', checkIn: '2026-11-13', checkOut: '2026-11-15' });
1058
+ assert(h3.ok === false && /readback 硬闸/.test(h3.error ?? '') && hBadDate.state.clickedSearch === false, 'readback 硬闸:入住日期错位 → 零搜索点击', h3);
1059
+ assert((await runHotelGuiSearch({ to: '上海', checkIn: '2026-11-15', checkOut: '2026-11-13' })).verdict === 'error', '退房早于入住 → 参数闸拒绝');
1060
+ __setHotelDriverForTest(null);
1061
+ }
857
1062
  if (process.env.GOTRY_SESSION_TEST_FORCE_FAILURE === '1') {
858
1063
  assert(false, 'forced session failure propagation proof');
859
1064
  }