@danceiny/gotry 0.0.1-rc.21 → 0.0.1-rc.24
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.
- package/README.md +102 -194
- package/README.zh-CN.md +115 -210
- package/bin/gotry-backend.js +53 -0
- package/bin/gotry-bootstrap.js +466 -35
- package/bin/gotry-inner.js +255 -36
- package/bin/gotry-process-liveness.d.ts +27 -0
- package/bin/gotry-process-liveness.js +97 -0
- package/bin/gotry-runtime-resolution.d.ts +1 -1
- package/bin/gotry-runtime-resolution.js +2 -2
- package/client/client.js +174 -0
- package/cordis.gotry-patch.yml +70 -93
- package/dist/capabilities/agent-reach-deep.js +45 -122
- package/dist/capabilities/agent-reach.js +63 -148
- package/dist/capabilities/anything.js +89 -75
- package/dist/capabilities/artifacts.js +123 -104
- package/dist/capabilities/channel-health.js +65 -45
- package/dist/capabilities/channel-registry.js +108 -87
- package/dist/capabilities/doctor.js +357 -235
- package/dist/capabilities/effect.js +223 -309
- package/dist/capabilities/fact-log.js +28 -14
- package/dist/capabilities/flyai.js +245 -156
- package/dist/capabilities/ground-transfer.js +509 -0
- package/dist/capabilities/hbcli.js +144 -148
- package/dist/capabilities/hotelbyte-transaction.js +194 -0
- package/dist/capabilities/incident-log.js +131 -61
- package/dist/capabilities/model-override.js +30 -10
- package/dist/capabilities/opensky.js +54 -43
- package/dist/capabilities/resilience.js +50 -32
- package/dist/capabilities/session/action-cache.js +50 -44
- package/dist/capabilities/session/adapters/ctrip-flight.js +119 -62
- package/dist/capabilities/session/adapters/ctrip-hotel.js +125 -110
- package/dist/capabilities/session/adapters/dida-portal.js +206 -0
- package/dist/capabilities/session/adapters/meituan-local.js +35 -54
- package/dist/capabilities/session/adapters/rail-12306.js +169 -112
- package/dist/capabilities/session/benchmark.js +85 -149
- package/dist/capabilities/session/extension-bridge.js +320 -259
- package/dist/capabilities/session/extension-channel.js +57 -76
- package/dist/capabilities/session/extension-distribution.js +115 -119
- package/dist/capabilities/session/extract.js +31 -21
- package/dist/capabilities/session/golden-score.js +55 -33
- package/dist/capabilities/session/health-watch.js +146 -154
- package/dist/capabilities/session/read-guard.js +55 -47
- package/dist/capabilities/session/static-flight-golden.js +49 -36
- package/dist/capabilities/session/transport.js +54 -61
- package/dist/capabilities/session/wizard.js +63 -93
- package/dist/capabilities/session-consent.js +143 -47
- package/dist/capabilities/session-login.js +95 -126
- package/dist/capabilities/session-search.js +391 -253
- package/dist/capabilities/sponsor-plugin.js +341 -0
- package/dist/capabilities/visa-policy.js +87 -73
- package/dist/capabilities/weather.js +163 -235
- package/dist/scripts/action-cache-tests.js +24 -59
- package/dist/scripts/agent-planning-turn-deadline-e2e.js +91 -142
- package/dist/scripts/agent-planning-turn-deadline-tests.js +80 -176
- package/dist/scripts/agent-reach-deep-tests.js +30 -40
- package/dist/scripts/agent-reach-tests.js +26 -18
- package/dist/scripts/agent-reach-wrapper-tests.js +46 -50
- package/dist/scripts/anything-tests.js +58 -62
- package/dist/scripts/artifact-client-contract-tests.js +69 -0
- package/dist/scripts/artifacts-capability-tests.js +235 -0
- package/dist/scripts/async-collect.js +26 -15
- package/dist/scripts/benchmark-environment-bridge-e2e.js +287 -684
- package/dist/scripts/benchmark-environment-bridge-tests.js +818 -3123
- package/dist/scripts/booking-copilot-availability-ledger-binding-tests.js +66 -279
- package/dist/scripts/booking-copilot-availability-policy-tests.js +153 -1257
- package/dist/scripts/booking-copilot-bin-proof-tests.js +22 -45
- package/dist/scripts/booking-copilot-crossrepo-fixture-server.js +64 -76
- package/dist/scripts/booking-copilot-dsh-core-proof-tests.js +69 -199
- package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +393 -759
- package/dist/scripts/booking-copilot-dsh-plugin-proof-tests.js +33 -75
- package/dist/scripts/booking-copilot-event-sequence-concurrency-proof-tests.js +60 -110
- package/dist/scripts/booking-copilot-gap-code-contract-proof-tests.js +28 -66
- package/dist/scripts/booking-copilot-operation-ledger-concurrency-proof-tests.js +72 -271
- package/dist/scripts/booking-copilot-receipt-ledger-concurrency-proof-tests.js +73 -275
- package/dist/scripts/booking-copilot-runtime-proof-tests.js +784 -3398
- package/dist/scripts/booking-copilot-server-proof-tests.js +63 -168
- package/dist/scripts/booking-copilot-startup-proof-tests.js +38 -124
- package/dist/scripts/booking-executor-tests.js +80 -0
- package/dist/scripts/booking-recovery-chain-tests.js +182 -0
- package/dist/scripts/booking-saga-tests.js +61 -123
- package/dist/scripts/booking-surface-contract-proof-tests.js +152 -1167
- package/dist/scripts/bootstrap-tests.js +1383 -128
- package/dist/scripts/build-changelog.js +89 -130
- package/dist/scripts/build-metrics-report.js +135 -143
- package/dist/scripts/changelog-tests.js +35 -82
- package/dist/scripts/channel-probe-tests.js +44 -116
- package/dist/scripts/channel-probe.js +84 -167
- package/dist/scripts/channel-registry-tests.js +97 -185
- package/dist/scripts/companion-tests.js +25 -70
- package/dist/scripts/dida-cookie-probe.js +87 -0
- package/dist/scripts/dida-sw-probe.js +114 -0
- package/dist/scripts/diff-test.js +17 -12
- package/dist/scripts/doctor-tests.js +428 -120
- package/dist/scripts/dsh-artifact-e2e.js +150 -0
- package/dist/scripts/dsh-artifact-web-e2e.js +443 -0
- package/dist/scripts/dsh-http-proxy-sse-proof.js +90 -0
- package/dist/scripts/dsh-runtime-closure-tests.js +110 -153
- package/dist/scripts/dsh-runtime-closure.js +75 -63
- package/dist/scripts/dsh-session-v3-migration-proof.js +106 -0
- package/dist/scripts/dsh-subprocess-local-proof.js +149 -0
- package/dist/scripts/dsh-target-closure-proof.js +178 -0
- package/dist/scripts/effect-tests.js +165 -552
- package/dist/scripts/engine-run.js +1 -3
- package/dist/scripts/engine-tests.js +16 -10
- package/dist/scripts/evaluation-cadence-tests.js +69 -242
- package/dist/scripts/evaluation-contract-tests.js +193 -499
- package/dist/scripts/extension-distribution-cli.js +19 -15
- package/dist/scripts/extension-distribution-tests.js +98 -211
- package/dist/scripts/extension-tests.js +366 -389
- package/dist/scripts/fact-gate-tests.js +725 -263
- package/dist/scripts/fixtures/extension-bridge-unref-child.mjs +21 -0
- package/dist/scripts/flight-hotel-anchor-363-registered-e2e.js +169 -0
- package/dist/scripts/flight-malformed-tests.js +159 -0
- package/dist/scripts/flyai-tests.js +325 -68
- package/dist/scripts/g5-guard-tests.js +131 -0
- package/dist/scripts/g5-guard.js +189 -0
- package/dist/scripts/gen-lunar-anchors.js +106 -0
- package/dist/scripts/gotry-backend-kernel-tests.js +79 -0
- package/dist/scripts/ground-transfer-tests.js +956 -0
- package/dist/scripts/hbcli-e2e-tests.js +71 -90
- package/dist/scripts/hbcli-live-optin-tests.js +96 -0
- package/dist/scripts/hbcli-release-contract-tests.js +122 -0
- package/dist/scripts/hbcli-tests.js +41 -115
- package/dist/scripts/health-watch-cli.js +42 -28
- package/dist/scripts/hotel-date-gate-tests.js +496 -0
- package/dist/scripts/hotelbyte-reconcile-tests.js +141 -0
- package/dist/scripts/hotelbyte-spawn-e2e-tests.js +373 -0
- package/dist/scripts/i18n-tests.js +16 -9
- package/dist/scripts/incident-tests.js +281 -87
- package/dist/scripts/issue-194-job-id-guard-tests.js +106 -0
- package/dist/scripts/issue-233-cancel-refund-commission-tests.js +392 -0
- package/dist/scripts/issue-271-liveness-tests.js +547 -0
- package/dist/scripts/issue-289-model-retry-real-tests.js +296 -0
- package/dist/scripts/issue-289-model-retry-tests.js +441 -0
- package/dist/scripts/issue-289-web-retry-e2e.js +630 -0
- package/dist/scripts/issue-338-home-city-e2e.js +577 -0
- package/dist/scripts/issue343-real-entry-e2e.js +160 -0
- package/dist/scripts/journey-tests.js +22 -27
- package/dist/scripts/kernel-manifest-evidence.js +109 -0
- package/dist/scripts/kernel-manifest-gate.js +126 -0
- package/dist/scripts/kernel-manifest-tests.js +212 -0
- package/dist/scripts/kernel-manifest-trace-entry.js +7 -0
- package/dist/scripts/kernel-manifest-trace-hook.mjs +28 -0
- package/dist/scripts/kernel-manifest.js +369 -0
- package/dist/scripts/ledger-repair-apply-tests.js +316 -0
- package/dist/scripts/ledger-repair-plan-tests.js +375 -0
- package/dist/scripts/ledger-tests.js +325 -340
- package/dist/scripts/ledger-workflow-crash.js +10 -20
- package/dist/scripts/map-tools-vendor-package-proof.js +206 -134
- package/dist/scripts/memory-capture-tests.js +242 -61
- package/dist/scripts/memory-decay-tests.js +18 -52
- package/dist/scripts/memory-lifecycle-tests.js +657 -0
- package/dist/scripts/memory-lifecycle.js +196 -0
- package/dist/scripts/memory-metrics.js +24 -12
- package/dist/scripts/memory-value-report.js +474 -222
- package/dist/scripts/memory-value-tests.js +328 -0
- package/dist/scripts/metrics-report-tests.js +62 -199
- package/dist/scripts/model-override-e2e.js +66 -105
- package/dist/scripts/nightly-evidence-tests.js +64 -89
- package/dist/scripts/nightly-evidence.js +108 -111
- package/dist/scripts/nudge-digest.js +39 -28
- package/dist/scripts/onboarding-tests.js +63 -88
- package/dist/scripts/opensky-check.js +18 -13
- package/dist/scripts/opensky-tests.js +28 -28
- package/dist/scripts/persona-surface-guard-tests.js +37 -12
- package/dist/scripts/pnpm-dsh-closure-proof.js +2 -5
- package/dist/scripts/policy-anchor-359-registered-e2e.js +214 -0
- package/dist/scripts/price-drift-tests.js +126 -227
- package/dist/scripts/price-drift-watch.js +209 -253
- package/dist/scripts/probe-poi-tests.js +52 -45
- package/dist/scripts/product-metrics.js +187 -273
- package/dist/scripts/publish-preverify.js +47 -41
- package/dist/scripts/realtime-pricing-tests.js +37 -76
- package/dist/scripts/replay-async.js +20 -18
- package/dist/scripts/replay-real.js +16 -18
- package/dist/scripts/replay.js +52 -82
- package/dist/scripts/session-attach-diagnose.js +16 -12
- package/dist/scripts/session-attach-poc.js +34 -37
- package/dist/scripts/session-benchmark.js +66 -147
- package/dist/scripts/session-dida-live-e2e.js +151 -0
- package/dist/scripts/session-extract-tests.js +14 -35
- package/dist/scripts/session-login.js +14 -13
- package/dist/scripts/session-tests.js +545 -583
- package/dist/scripts/sf-live-benchmark.js +92 -110
- package/dist/scripts/sf-live-cli-tests.js +2 -9
- package/dist/scripts/sf-soft-score-tests.js +19 -60
- package/dist/scripts/sf-summary-tests.js +149 -0
- package/dist/scripts/sf-summary.js +466 -84
- package/dist/scripts/skeleton-check.js +16 -14
- package/dist/scripts/skeleton-integration-test.js +11 -10
- package/dist/scripts/skills-contract-tests.js +51 -46
- package/dist/scripts/smoke-session-gate-tests.js +12 -20
- package/dist/scripts/smoke.js +541 -422
- package/dist/scripts/sponsor-reuse-tests.js +262 -0
- package/dist/scripts/state-cli-tests.js +226 -101
- package/dist/scripts/state-cli.js +549 -212
- package/dist/scripts/static-golden-tests.js +46 -180
- package/dist/scripts/time-eval-tests.js +210 -192
- package/dist/scripts/travel-timeline-tests.js +29 -93
- package/dist/scripts/turn-handoff-collect-tests.js +46 -93
- package/dist/scripts/turn-handoff-collect.js +71 -99
- package/dist/scripts/turn-policy-tests.js +28 -29
- package/dist/scripts/typed-contract-canary.js +77 -162
- package/dist/scripts/tz-resolver-sanity.js +50 -0
- package/dist/scripts/tz-resolver-tests.js +584 -0
- package/dist/scripts/unified-tests.js +23 -19
- package/dist/scripts/visa-policy-tests.js +31 -44
- package/dist/scripts/weather-tests.js +121 -543
- package/dist/scripts/wish-channel-gate-tests.js +29 -86
- package/dist/scripts/write-gate-tests.js +522 -0
- package/dist/scripts/z3-lifecycle-fault-tests.js +188 -0
- package/dist/scripts/z3-lifecycle-tests.js +170 -0
- package/dist/scripts/z3-race-repeat-tests.js +39 -0
- package/dist/scripts/z3-race-tests.js +18 -12
- package/dist/src/artifact-gate.js +652 -221
- package/dist/src/backend/kernel.js +91 -0
- package/dist/src/backend/modules/booking-copilot.js +55 -0
- package/dist/src/backend/modules/booking-executor.js +124 -0
- package/dist/src/backend/modules/session-search.js +194 -0
- package/dist/src/benchmark-agent-conformance.js +296 -262
- package/dist/src/benchmark-environment-bridge.js +298 -413
- package/dist/src/benchmark-headless-child-diagnostics.js +93 -100
- package/dist/src/benchmark-tool-isolation.js +94 -67
- package/dist/src/bookable-facts.js +299 -174
- package/dist/src/booking-saga.js +64 -120
- package/dist/src/booking-surface/availability-policy.js +443 -576
- package/dist/src/booking-surface/cancel-refund-commission.js +379 -0
- package/dist/src/booking-surface/contracts.js +24 -80
- package/dist/src/booking-surface/dsh-planner.js +510 -281
- package/dist/src/booking-surface/dsh-plugin.js +17 -1
- package/dist/src/booking-surface/error-codes.js +82 -10
- package/dist/src/booking-surface/index.js +0 -3
- package/dist/src/booking-surface/profile.js +10 -54
- package/dist/src/booking-surface/recovery-chain.js +367 -0
- package/dist/src/booking-surface/runtime.js +765 -891
- package/dist/src/booking-surface/server.js +200 -251
- package/dist/src/booking-surface/startup.js +58 -54
- package/dist/src/booking-surface/validation.js +150 -218
- package/dist/src/bridge.js +20 -13
- package/dist/src/companions.js +48 -74
- package/dist/src/contracts.js +21 -43
- package/dist/src/dsh-llm.js +82 -120
- package/dist/src/engine.js +253 -239
- package/dist/src/evaluation-cadence.js +72 -209
- package/dist/src/evaluation-contracts.js +220 -843
- package/dist/src/flight-pack-adapter.js +54 -0
- package/dist/src/flight-pack-contract.js +2 -0
- package/dist/src/gotry-backend.js +70 -0
- package/dist/src/hotel-date-gate.js +118 -0
- package/dist/src/i18n.js +18 -7
- package/dist/src/index.js +1425 -1629
- package/dist/src/journey.js +132 -81
- package/dist/src/ledger-repair-apply.js +538 -0
- package/dist/src/ledger-repair-plan.js +463 -0
- package/dist/src/loop.js +257 -166
- package/dist/src/memory-capture.js +180 -32
- package/dist/src/memory-decay.js +30 -14
- package/dist/src/memory-lifecycle.js +1310 -0
- package/dist/src/memory-utility.js +27 -40
- package/dist/src/mock-llm.js +36 -61
- package/dist/src/model.js +81 -36
- package/dist/src/realtime-pricing.js +63 -72
- package/dist/src/slot-spec.js +84 -106
- package/dist/src/state-ledger.js +310 -471
- package/dist/src/subagent-job-id-guard.js +58 -0
- package/dist/src/time-anchor.js +231 -33
- package/dist/src/tool-packet.js +20 -8
- package/dist/src/travel-slots.js +58 -35
- package/dist/src/travel-timeline.js +44 -63
- package/dist/src/turn-deadline.js +121 -127
- package/dist/src/turn-policy.js +61 -89
- package/dist/src/tz-resolver.js +143 -0
- package/dist/src/unified.js +504 -381
- package/dist/src/wish-pool.js +39 -14
- package/dist/src/write-gate.js +495 -0
- package/dist/src/z3-shared.js +273 -7
- package/extension/README.md +4 -3
- package/extension/background.js +125 -14
- package/extension/content-bridge.js +23 -1
- package/extension/content-main.js +15 -4
- package/extension/manifest.json +16 -8
- package/package.json +251 -220
- package/ts/capabilities/agent-reach.ts +4 -4
- package/ts/capabilities/anything.ts +1 -1
- package/ts/capabilities/artifacts.ts +46 -9
- package/ts/capabilities/flyai.ts +156 -37
- package/ts/capabilities/hbcli.ts +1 -1
- package/ts/capabilities/incident-log.ts +88 -59
- package/ts/capabilities/session/adapters/ctrip-flight.ts +99 -36
- package/ts/capabilities/session/adapters/dida-portal.ts +250 -0
- package/ts/capabilities/session/adapters/rail-12306.ts +123 -24
- package/ts/capabilities/session/extension-bridge.ts +226 -101
- package/ts/capabilities/session/extension-channel.ts +22 -9
- package/ts/capabilities/session/wizard.ts +2 -2
- package/ts/capabilities/session-consent.ts +98 -14
- package/ts/capabilities/session-login.ts +1 -0
- package/ts/capabilities/session-search.ts +315 -31
- package/ts/dsh-runtime/vendor/README.md +8 -4
- package/ts/dsh-runtime/vendor/dsh-map-tools/README.en.md +7 -1
- package/ts/dsh-runtime/vendor/dsh-map-tools/README.md +5 -3
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/settings-ns.js +1 -1
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/settings-ns.d.ts +1 -1
- package/ts/dsh-runtime/vendor/dsh-map-tools/package.json +6 -6
- package/ts/package.json +236 -16
- package/ts/scripts/map-tools-vendor-package-proof.ts +152 -31
- package/ts/scripts/state-cli.ts +418 -51
- package/ts/src/artifact-gate.ts +561 -15
- package/ts/src/benchmark-environment-bridge.ts +37 -20
- package/ts/src/bookable-facts.ts +183 -7
- package/ts/src/booking-surface/cancel-refund-commission.ts +488 -0
- package/ts/src/booking-surface/dsh-planner.ts +240 -32
- package/ts/src/booking-surface/dsh-plugin.js +17 -1
- package/ts/src/booking-surface/error-codes.ts +75 -0
- package/ts/src/booking-surface/recovery-chain.ts +409 -0
- package/ts/src/booking-surface/server.ts +84 -51
- package/ts/src/booking-surface/validation.ts +2 -0
- package/ts/src/contracts.ts +12 -2
- package/ts/src/dsh-llm.ts +4 -8
- package/ts/src/index.ts +609 -90
- package/ts/src/loop.ts +116 -7
- package/ts/src/memory-capture.ts +227 -3
- package/ts/src/mock-llm.ts +4 -8
- package/ts/src/model.ts +88 -2
- package/ts/src/state-ledger.ts +47 -29
- package/ts/src/subagent-job-id-guard.ts +72 -0
- package/ts/src/time-anchor.ts +217 -13
- package/ts/src/unified.ts +316 -54
|
@@ -0,0 +1,956 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #341 focused proof, Issue #364 direction-binding additions, and the
|
|
3
|
+
* 2026-09-11 wider D-39 boundary freeze.
|
|
4
|
+
*
|
|
5
|
+
* The product path is exercised through the registered gotry_feasibility_check
|
|
6
|
+
* tool. Map results come from an isolated public-tool-shaped fixture; the
|
|
7
|
+
* separate ToolRuntime case verifies the production nested-dispatch seam.
|
|
8
|
+
* No shared state or live session is used.
|
|
9
|
+
*
|
|
10
|
+
* Each `ground_transfer` request resolves both directions (outbound = origin
|
|
11
|
+
* → destination, return = destination → origin) with isolated cache,
|
|
12
|
+
* freshness, and fallback. A legacy single-pair shape is normalized to
|
|
13
|
+
* outbound + the swapped pair as return.
|
|
14
|
+
*
|
|
15
|
+
* The boundary-freeze section pins the offline remainder of the wider D-39
|
|
16
|
+
* transfer boundary: mode/position vocabulary closure, fallback-completeness
|
|
17
|
+
* counterexamples, the static↔dynamic switch contract, evidence-annotation
|
|
18
|
+
* completeness, and cache boundedness. Everything beyond (live traffic,
|
|
19
|
+
* transit/rail, fares, address resolution, broader transfer combinations) is
|
|
20
|
+
* gated real-data work and deliberately absent here.
|
|
21
|
+
*/
|
|
22
|
+
import assert from 'node:assert/strict';
|
|
23
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
import { tmpdir } from 'node:os';
|
|
26
|
+
import { readFile } from 'node:fs/promises';
|
|
27
|
+
import { Context } from '@deepseek-ai/cordis';
|
|
28
|
+
import { defineTool, ToolRuntime } from '@deepseek-ai/dsh-tools';
|
|
29
|
+
import { SystemPrompt } from '@deepseek-ai/dsh-system-prompt';
|
|
30
|
+
import { apply } from '../src/index.js';
|
|
31
|
+
import { evaluateChoice, parseCandidate, parseRequest } from '../src/model.js';
|
|
32
|
+
import { segmentsFromCandidate, solveChoiceSegment } from '../src/unified.js';
|
|
33
|
+
import { createGroundTransferResolver, resolveGroundTransferPayload, GROUND_TRANSFER_DEFAULT_MAX_AGE_S, GROUND_TRANSFER_STATIC_MODE_MISMATCH, GROUND_TRANSFER_STATIC_PRICE_EVIDENCE, } from '../capabilities/ground-transfer.js';
|
|
34
|
+
const FIXED_NOW = new Date('2026-09-10T12:00:00.000Z');
|
|
35
|
+
const GROUND_TRANSFER = {
|
|
36
|
+
candidate_id: 'qiandao',
|
|
37
|
+
transfer_index: 0,
|
|
38
|
+
position: 'destination',
|
|
39
|
+
mode: 'driving',
|
|
40
|
+
origin: { longitude: 120.1234, latitude: 30.2345 },
|
|
41
|
+
destination: { longitude: 119.4567, latitude: 29.6789 },
|
|
42
|
+
};
|
|
43
|
+
function clone(value) {
|
|
44
|
+
return JSON.parse(JSON.stringify(value));
|
|
45
|
+
}
|
|
46
|
+
function asRecord(value, label) {
|
|
47
|
+
assert.ok(value !== null && typeof value === 'object' && !Array.isArray(value), `${label} must be an object`);
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
function withoutLatency(value) {
|
|
51
|
+
const record = asRecord(value, 'tool result');
|
|
52
|
+
const { latency_ms: _latency, ...stable } = record;
|
|
53
|
+
return stable;
|
|
54
|
+
}
|
|
55
|
+
function resultOf(value) {
|
|
56
|
+
return asRecord(value, 'feasibility result');
|
|
57
|
+
}
|
|
58
|
+
function route(durationS, provider = 'fixture-osrm') {
|
|
59
|
+
return {
|
|
60
|
+
provider,
|
|
61
|
+
distanceM: 42_500,
|
|
62
|
+
durationS,
|
|
63
|
+
polyline: 'fixture-polyline',
|
|
64
|
+
steps: [],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function fakeHost(provider, clock = () => FIXED_NOW) {
|
|
68
|
+
const root = mkdtempSync(join(tmpdir(), 'gotry-341-ground-transfer-'));
|
|
69
|
+
const registered = [];
|
|
70
|
+
const ctx = {
|
|
71
|
+
tools: {
|
|
72
|
+
register(tool) {
|
|
73
|
+
registered.push(tool);
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
systemPrompt: {
|
|
77
|
+
variable() { },
|
|
78
|
+
},
|
|
79
|
+
get() {
|
|
80
|
+
return undefined;
|
|
81
|
+
},
|
|
82
|
+
on() {
|
|
83
|
+
return () => { };
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
apply(ctx, {
|
|
87
|
+
stateRoot: root,
|
|
88
|
+
timeoutMs: 30_000,
|
|
89
|
+
hbcliBin: 'hbcli-not-on-path',
|
|
90
|
+
sessionAccess: 'off',
|
|
91
|
+
}, {
|
|
92
|
+
clock,
|
|
93
|
+
groundTransfer: { provider },
|
|
94
|
+
});
|
|
95
|
+
const feasibility = registered.find(tool => tool.name === 'gotry_feasibility_check');
|
|
96
|
+
assert.ok(feasibility, 'gotry_feasibility_check must be registered');
|
|
97
|
+
return { root, feasibility };
|
|
98
|
+
}
|
|
99
|
+
async function call(tool, payload) {
|
|
100
|
+
return resultOf(await tool.execute({ payload: clone(payload) }, null));
|
|
101
|
+
}
|
|
102
|
+
async function publicToolExecution(payload) {
|
|
103
|
+
const root = mkdtempSync(join(tmpdir(), 'gotry-341-ground-transfer-runtime-'));
|
|
104
|
+
const mapCalls = [];
|
|
105
|
+
const ctx = new Context();
|
|
106
|
+
await ctx.plugin(SystemPrompt);
|
|
107
|
+
await ctx.plugin(ToolRuntime, {});
|
|
108
|
+
const unregisterMap = ctx.tools.register(defineTool({
|
|
109
|
+
name: 'map_driving_route',
|
|
110
|
+
description: 'isolated public map tool fixture',
|
|
111
|
+
parameters: {
|
|
112
|
+
origin: { type: 'string', required: true },
|
|
113
|
+
destination: { type: 'string', required: true },
|
|
114
|
+
},
|
|
115
|
+
output: {
|
|
116
|
+
schema: { type: 'json' },
|
|
117
|
+
render: () => [{ type: 'text', text: 'fixture map route' }],
|
|
118
|
+
},
|
|
119
|
+
async execute(args, _execution) {
|
|
120
|
+
mapCalls.push({ ...args });
|
|
121
|
+
return route(780, 'fixture-public-map-tool');
|
|
122
|
+
},
|
|
123
|
+
}));
|
|
124
|
+
apply(ctx, {
|
|
125
|
+
stateRoot: root,
|
|
126
|
+
timeoutMs: 30_000,
|
|
127
|
+
hbcliBin: 'hbcli-not-on-path',
|
|
128
|
+
sessionAccess: 'off',
|
|
129
|
+
}, { clock: () => FIXED_NOW });
|
|
130
|
+
const execution = await ctx.tools.execute({
|
|
131
|
+
callId: 'issue-341-ground-transfer-root',
|
|
132
|
+
name: 'gotry_feasibility_check',
|
|
133
|
+
arguments: { payload: clone(payload) },
|
|
134
|
+
signal: new AbortController().signal,
|
|
135
|
+
});
|
|
136
|
+
return {
|
|
137
|
+
execution,
|
|
138
|
+
mapCalls,
|
|
139
|
+
dispose: async () => {
|
|
140
|
+
unregisterMap();
|
|
141
|
+
await ctx.fiber.dispose();
|
|
142
|
+
rmSync(root, { recursive: true, force: true });
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
async function publicToolHost(payload) {
|
|
147
|
+
const host = await publicToolExecution(payload);
|
|
148
|
+
if (host.execution.isError)
|
|
149
|
+
throw new Error(`nested host execution failed: ${host.execution.error.message}`);
|
|
150
|
+
return { result: resultOf(host.execution.value), mapCalls: host.mapCalls, dispose: host.dispose };
|
|
151
|
+
}
|
|
152
|
+
function directionOf(value, label) {
|
|
153
|
+
const record = asRecord(value, label);
|
|
154
|
+
assert.ok(record['direction'] === 'outbound' || record['direction'] === 'return', `${label}.direction must be outbound or return`);
|
|
155
|
+
return record;
|
|
156
|
+
}
|
|
157
|
+
/** Isolated host lifecycle for one boundary-freeze case. */
|
|
158
|
+
async function withFeasibilityHost(provider, run) {
|
|
159
|
+
const host = fakeHost(provider);
|
|
160
|
+
try {
|
|
161
|
+
await run(host.feasibility);
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
rmSync(host.root, { recursive: true, force: true });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
async function main() {
|
|
168
|
+
const payload = JSON.parse(await readFile(join(import.meta.dirname, '..', '..', 'data', 'golden_erhai.json'), 'utf8'));
|
|
169
|
+
const fixtureCalls = [];
|
|
170
|
+
let durationS = 900;
|
|
171
|
+
const provider = async (request) => {
|
|
172
|
+
fixtureCalls.push({ ...request });
|
|
173
|
+
return route(durationS);
|
|
174
|
+
};
|
|
175
|
+
let issueNowMs = FIXED_NOW.getTime();
|
|
176
|
+
const host = fakeHost(provider, () => new Date(issueNowMs));
|
|
177
|
+
try {
|
|
178
|
+
const baseline = await call(host.feasibility, payload);
|
|
179
|
+
const legacyRepeat = await call(host.feasibility, payload);
|
|
180
|
+
assert.deepEqual(withoutLatency(legacyRepeat), withoutLatency(baseline), 'payloads without ground_transfer must keep their legacy result shape');
|
|
181
|
+
assert.equal('ground_transfer' in baseline, false);
|
|
182
|
+
const parsed = payload.candidates;
|
|
183
|
+
const parsedCandidates = parsed.map(parseCandidate);
|
|
184
|
+
const originalCandidates = clone(parsedCandidates);
|
|
185
|
+
const directResolver = createGroundTransferResolver({
|
|
186
|
+
provider: async () => route(900),
|
|
187
|
+
clock: () => FIXED_NOW,
|
|
188
|
+
});
|
|
189
|
+
const directPrepared = await resolveGroundTransferPayload(GROUND_TRANSFER, parsedCandidates, directResolver);
|
|
190
|
+
assert.equal(directPrepared.resolution.applied, true);
|
|
191
|
+
// Legacy single-pair shape: outbound = origin→destination, return = swapped
|
|
192
|
+
// pair. Both directions resolve to 15 min (route(900) = 15 min).
|
|
193
|
+
assert.equal(directPrepared.resolution.outboundMinutes, 15);
|
|
194
|
+
assert.equal(directPrepared.resolution.returnMinutes, 15);
|
|
195
|
+
assert.equal(directionOf(directPrepared.resolution.outbound, 'outbound')['direction'], 'outbound');
|
|
196
|
+
assert.equal(directionOf(directPrepared.resolution.return, 'return')['direction'], 'return');
|
|
197
|
+
assert.equal(directPrepared.resolution.outbound.staticMinutes, 25);
|
|
198
|
+
assert.equal(directPrepared.resolution.return.staticMinutes, 25);
|
|
199
|
+
const patchedDirect = directPrepared.candidates[1].destTransfers[0];
|
|
200
|
+
assert.equal(patchedDirect.minutes, 25);
|
|
201
|
+
assert.equal(patchedDirect.minutesOut, 15);
|
|
202
|
+
assert.equal(patchedDirect.minutesRet, 15);
|
|
203
|
+
assert.equal(patchedDirect.priceCny, originalCandidates[1].destTransfers[0].priceCny);
|
|
204
|
+
assert.equal(patchedDirect.mode, originalCandidates[1].destTransfers[0].mode);
|
|
205
|
+
assert.deepEqual(JSON.parse(JSON.stringify(directPrepared.candidates[0])), JSON.parse(JSON.stringify(originalCandidates[0])), 'unrelated candidates must not be patched');
|
|
206
|
+
assert.deepEqual(directPrepared.candidates[1].destTransfers[1], originalCandidates[1].destTransfers[1], 'unrelated transfers must not be patched');
|
|
207
|
+
// explicit-mode mismatch: bus / bus_plus_taxi → static fallback, driver never called.
|
|
208
|
+
let staticModeProviderCalls = 0;
|
|
209
|
+
const staticModeResolver = createGroundTransferResolver({
|
|
210
|
+
provider: async () => {
|
|
211
|
+
staticModeProviderCalls += 1;
|
|
212
|
+
return route(900);
|
|
213
|
+
},
|
|
214
|
+
clock: () => FIXED_NOW,
|
|
215
|
+
});
|
|
216
|
+
for (const staticModeRequest of [
|
|
217
|
+
{ ...GROUND_TRANSFER, candidate_id: 'qiandao', transfer_index: 1 },
|
|
218
|
+
{ ...GROUND_TRANSFER, candidate_id: 'dali', transfer_index: 1 },
|
|
219
|
+
]) {
|
|
220
|
+
const before = JSON.stringify(parsedCandidates);
|
|
221
|
+
const prepared = await resolveGroundTransferPayload(staticModeRequest, parsedCandidates, staticModeResolver);
|
|
222
|
+
assert.equal(prepared.resolution.applied, false);
|
|
223
|
+
assert.equal(prepared.resolution.provenance, 'static-transfer-pack');
|
|
224
|
+
assert.equal(prepared.resolution.evidenceClass, 'static_transfer_estimate');
|
|
225
|
+
assert.match(prepared.resolution.fallbackReason ?? '', new RegExp(`^${GROUND_TRANSFER_STATIC_MODE_MISMATCH}`));
|
|
226
|
+
assert.equal(directionOf(prepared.resolution.outbound, 'mismatch.outbound').provenance, 'static-transfer-pack');
|
|
227
|
+
assert.equal(directionOf(prepared.resolution.return, 'mismatch.return').provenance, 'static-transfer-pack');
|
|
228
|
+
assert.equal(JSON.stringify(prepared.candidates), before, 'non-taxi static transfer must stay structurally unchanged');
|
|
229
|
+
}
|
|
230
|
+
assert.equal(staticModeProviderCalls, 0, 'bus and bus_plus_taxi must not call the driving provider');
|
|
231
|
+
const fresh = await call(host.feasibility, { ...payload, ground_transfer: GROUND_TRANSFER });
|
|
232
|
+
assert.equal(fresh.ok, true);
|
|
233
|
+
// Both directions are queried through the same provider with different origin/destination pairs.
|
|
234
|
+
assert.equal(fixtureCalls.length, 2);
|
|
235
|
+
const outboundCall = fixtureCalls[0];
|
|
236
|
+
const returnCall = fixtureCalls[1];
|
|
237
|
+
assert.equal(outboundCall['origin'], '120.1234,30.2345');
|
|
238
|
+
assert.equal(outboundCall['destination'], '119.4567,29.6789');
|
|
239
|
+
assert.equal(returnCall['origin'], '119.4567,29.6789');
|
|
240
|
+
assert.equal(returnCall['destination'], '120.1234,30.2345');
|
|
241
|
+
const freshEvidence = asRecord(fresh.ground_transfer, 'fresh ground_transfer');
|
|
242
|
+
assert.equal(freshEvidence['applied'], true);
|
|
243
|
+
assert.equal(freshEvidence['candidateId'], 'qiandao');
|
|
244
|
+
assert.equal(freshEvidence['transferIndex'], 0);
|
|
245
|
+
assert.equal(freshEvidence['outboundMinutes'], 15);
|
|
246
|
+
assert.equal(freshEvidence['returnMinutes'], 15);
|
|
247
|
+
assert.equal(freshEvidence['staticMinutes'], 25);
|
|
248
|
+
assert.equal(freshEvidence['priceCny'], 30);
|
|
249
|
+
assert.equal(freshEvidence['priceEvidence'], GROUND_TRANSFER_STATIC_PRICE_EVIDENCE);
|
|
250
|
+
assert.equal(freshEvidence['provenance'], 'map_driving_route');
|
|
251
|
+
assert.equal(freshEvidence['asOf'], FIXED_NOW.toISOString());
|
|
252
|
+
assert.equal(freshEvidence['freshness'], 'fresh');
|
|
253
|
+
assert.equal(asRecord(freshEvidence['cache'], 'fresh cache')['status'], 'miss');
|
|
254
|
+
const freshOutbound = directionOf(freshEvidence['outbound'], 'fresh.outbound');
|
|
255
|
+
const freshReturn = directionOf(freshEvidence['return'], 'fresh.return');
|
|
256
|
+
assert.equal(freshOutbound.minutes, 15);
|
|
257
|
+
assert.equal(freshOutbound.freshness, 'fresh');
|
|
258
|
+
assert.equal(freshOutbound.provenance, 'map_driving_route');
|
|
259
|
+
assert.equal(asRecord(freshOutbound['cache'], 'fresh.outbound.cache')['status'], 'miss');
|
|
260
|
+
assert.deepEqual(asRecord(freshOutbound['routeFact'], 'fresh.outbound.routeFact')['origin'], GROUND_TRANSFER.origin);
|
|
261
|
+
assert.deepEqual(asRecord(freshOutbound['routeFact'], 'fresh.outbound.routeFact')['destination'], GROUND_TRANSFER.destination);
|
|
262
|
+
assert.equal(asRecord(freshOutbound['routeFact'], 'fresh.outbound.routeFact')['direction'], 'outbound');
|
|
263
|
+
assert.equal(asRecord(freshOutbound['routeFact'], 'fresh.outbound.routeFact')['provider'], 'fixture-osrm');
|
|
264
|
+
assert.equal(asRecord(freshOutbound['routeFact'], 'fresh.outbound.routeFact')['durationMin'], 15);
|
|
265
|
+
assert.equal(asRecord(freshOutbound['routeFact'], 'fresh.outbound.routeFact')['evidenceClass'], 'public_map_route_estimate');
|
|
266
|
+
assert.equal(asRecord(freshOutbound['routeFact'], 'fresh.outbound.routeFact')['trafficStatus'], 'not-live-route-estimate');
|
|
267
|
+
assert.equal('priceCny' in asRecord(freshOutbound['routeFact'], 'fresh.outbound.routeFact'), false, 'route result must not invent a price');
|
|
268
|
+
assert.equal(freshReturn.minutes, 15);
|
|
269
|
+
assert.equal(freshReturn.freshness, 'fresh');
|
|
270
|
+
assert.deepEqual(asRecord(freshReturn['routeFact'], 'fresh.return.routeFact')['origin'], GROUND_TRANSFER.destination);
|
|
271
|
+
assert.deepEqual(asRecord(freshReturn['routeFact'], 'fresh.return.routeFact')['destination'], GROUND_TRANSFER.origin);
|
|
272
|
+
assert.equal(asRecord(freshReturn['routeFact'], 'fresh.return.routeFact')['direction'], 'return');
|
|
273
|
+
const freshEvidenceVerdicts = fresh.verdicts ?? [];
|
|
274
|
+
assert.equal(freshEvidenceVerdicts.filter(v => 'transfer_evidence' in v).length, 1);
|
|
275
|
+
const verdictEvidence = asRecord(freshEvidenceVerdicts.find(v => v['candidate_id'] === 'qiandao')?.['transfer_evidence'], 'bound transfer evidence');
|
|
276
|
+
assert.equal(verdictEvidence['outboundMinutes'], 15);
|
|
277
|
+
assert.equal(verdictEvidence['returnMinutes'], 15);
|
|
278
|
+
const cacheHit = await call(host.feasibility, { ...payload, ground_transfer: GROUND_TRANSFER });
|
|
279
|
+
assert.equal(fixtureCalls.length, 2, 'fresh cache hit must avoid a second provider call');
|
|
280
|
+
const hitEvidence = asRecord(cacheHit.ground_transfer, 'cache-hit ground_transfer');
|
|
281
|
+
assert.equal(hitEvidence['outboundMinutes'], 15);
|
|
282
|
+
assert.equal(hitEvidence['returnMinutes'], 15);
|
|
283
|
+
// Full cache hit keeps the legacy aggregate freshness semantics;
|
|
284
|
+
// per-direction freshness is the authoritative per-direction record.
|
|
285
|
+
assert.equal(hitEvidence['freshness'], 'cache_hit');
|
|
286
|
+
assert.equal(asRecord(hitEvidence['cache'], 'cache-hit aggregate cache')['status'], 'hit');
|
|
287
|
+
assert.equal(asRecord(directionOf(hitEvidence['outbound'], 'hit.outbound')['cache'], 'cache-hit outbound cache')['status'], 'hit');
|
|
288
|
+
assert.equal(asRecord(directionOf(hitEvidence['return'], 'hit.return')['cache'], 'cache-hit return cache')['status'], 'hit');
|
|
289
|
+
assert.equal(asRecord(directionOf(hitEvidence['outbound'], 'hit.outbound')['cache'], 'cache-hit outbound cache')['ageS'], 0);
|
|
290
|
+
assert.equal(asRecord(directionOf(hitEvidence['return'], 'hit.return')['cache'], 'cache-hit return cache')['ageS'], 0);
|
|
291
|
+
assert.equal(directionOf(hitEvidence['outbound'], 'hit.outbound')['freshness'], 'cache_hit');
|
|
292
|
+
assert.equal(directionOf(hitEvidence['return'], 'hit.return')['freshness'], 'cache_hit');
|
|
293
|
+
issueNowMs += (GROUND_TRANSFER_DEFAULT_MAX_AGE_S + 1) * 1_000;
|
|
294
|
+
durationS = 1_200;
|
|
295
|
+
const stale = await call(host.feasibility, {
|
|
296
|
+
...payload,
|
|
297
|
+
ground_transfer: GROUND_TRANSFER,
|
|
298
|
+
});
|
|
299
|
+
assert.equal(fixtureCalls.length, 4, 'stale cache entries must requery the provider for both directions');
|
|
300
|
+
const staleEvidence = asRecord(stale.ground_transfer, 'stale ground_transfer');
|
|
301
|
+
assert.equal(staleEvidence['outboundMinutes'], 20);
|
|
302
|
+
assert.equal(staleEvidence['returnMinutes'], 20);
|
|
303
|
+
assert.equal(staleEvidence['freshness'], 'fresh');
|
|
304
|
+
assert.equal(asRecord(staleEvidence['cache'], 'stale aggregate cache')['status'], 'requery', 'stale aggregate cache keeps the legacy requery semantics');
|
|
305
|
+
assert.equal(directionOf(staleEvidence['outbound'], 'stale.outbound')['freshness'], 'fresh');
|
|
306
|
+
assert.equal(directionOf(staleEvidence['return'], 'stale.return')['freshness'], 'fresh');
|
|
307
|
+
assert.equal(asRecord(directionOf(staleEvidence['outbound'], 'stale.outbound')['cache'], 'stale.outbound.cache')['status'], 'requery');
|
|
308
|
+
assert.equal(asRecord(directionOf(staleEvidence['return'], 'stale.return')['cache'], 'stale.return.cache')['status'], 'requery');
|
|
309
|
+
const failingHost = fakeHost(async () => {
|
|
310
|
+
throw new Error('fixture provider unavailable');
|
|
311
|
+
});
|
|
312
|
+
try {
|
|
313
|
+
const fallback = await call(failingHost.feasibility, {
|
|
314
|
+
...payload,
|
|
315
|
+
ground_transfer: { ...GROUND_TRANSFER, origin: { longitude: 120.5, latitude: 30.5 } },
|
|
316
|
+
});
|
|
317
|
+
const fallbackEvidence = asRecord(fallback.ground_transfer, 'provider fallback ground_transfer');
|
|
318
|
+
assert.equal(fallbackEvidence['applied'], false);
|
|
319
|
+
assert.equal(fallbackEvidence['provenance'], 'static-transfer-pack');
|
|
320
|
+
assert.equal(fallbackEvidence['evidenceClass'], 'static_transfer_estimate');
|
|
321
|
+
assert.equal(fallbackEvidence['asOf'], null);
|
|
322
|
+
assert.equal(asRecord(fallbackEvidence['cache'], 'provider fallback cache')['status'], 'bypass');
|
|
323
|
+
assert.match(String(fallbackEvidence['fallbackReason']), /provider unavailable/);
|
|
324
|
+
const fallbackOutbound = directionOf(fallbackEvidence['outbound'], 'fallback.outbound');
|
|
325
|
+
const fallbackReturn = directionOf(fallbackEvidence['return'], 'fallback.return');
|
|
326
|
+
assert.equal(fallbackOutbound.applied, false);
|
|
327
|
+
assert.equal(fallbackOutbound.staticMinutes, 25);
|
|
328
|
+
assert.equal(fallbackReturn.applied, false);
|
|
329
|
+
assert.equal(fallbackReturn.staticMinutes, 25);
|
|
330
|
+
assert.match(String(fallbackOutbound['fallbackReason']), /provider unavailable/);
|
|
331
|
+
assert.match(String(fallbackReturn['fallbackReason']), /provider unavailable/);
|
|
332
|
+
}
|
|
333
|
+
finally {
|
|
334
|
+
rmSync(failingHost.root, { recursive: true, force: true });
|
|
335
|
+
}
|
|
336
|
+
const missingHost = fakeHost(async () => undefined);
|
|
337
|
+
try {
|
|
338
|
+
const missing = await call(missingHost.feasibility, {
|
|
339
|
+
...payload,
|
|
340
|
+
ground_transfer: { ...GROUND_TRANSFER, origin: { longitude: 120.6, latitude: 30.6 } },
|
|
341
|
+
});
|
|
342
|
+
const missingEvidence = asRecord(missing.ground_transfer, 'provider-miss ground_transfer');
|
|
343
|
+
assert.equal(missingEvidence['applied'], false);
|
|
344
|
+
assert.equal(missingEvidence['provenance'], 'static-transfer-pack');
|
|
345
|
+
assert.match(String(missingEvidence['fallbackReason']), /invalid public result/);
|
|
346
|
+
const missingOutbound = directionOf(missingEvidence['outbound'], 'missing.outbound');
|
|
347
|
+
const missingReturn = directionOf(missingEvidence['return'], 'missing.return');
|
|
348
|
+
assert.equal(missingOutbound.applied, false);
|
|
349
|
+
assert.equal(missingOutbound.staticMinutes, 25);
|
|
350
|
+
assert.equal(missingReturn.applied, false);
|
|
351
|
+
assert.equal(missingReturn.staticMinutes, 25);
|
|
352
|
+
}
|
|
353
|
+
finally {
|
|
354
|
+
rmSync(missingHost.root, { recursive: true, force: true });
|
|
355
|
+
}
|
|
356
|
+
const invalidCoordinates = await call(host.feasibility, {
|
|
357
|
+
...payload,
|
|
358
|
+
ground_transfer: { ...GROUND_TRANSFER, destination: { longitude: 181, latitude: 29 } },
|
|
359
|
+
});
|
|
360
|
+
const invalidEvidence = asRecord(invalidCoordinates.ground_transfer, 'invalid-coordinate ground_transfer');
|
|
361
|
+
assert.equal(invalidEvidence['applied'], false);
|
|
362
|
+
assert.equal(invalidEvidence['provenance'], 'static-transfer-pack');
|
|
363
|
+
assert.match(String(invalidEvidence['fallbackReason']), /invalid_ground_transfer_coordinates/);
|
|
364
|
+
assert.equal(fixtureCalls.length, 4, 'invalid coordinates must not call the provider');
|
|
365
|
+
const unsupportedMode = await call(host.feasibility, {
|
|
366
|
+
...payload,
|
|
367
|
+
ground_transfer: { ...GROUND_TRANSFER, mode: 'walking' },
|
|
368
|
+
});
|
|
369
|
+
const unsupportedEvidence = asRecord(unsupportedMode.ground_transfer, 'unsupported-mode ground_transfer');
|
|
370
|
+
assert.equal(unsupportedEvidence['applied'], false);
|
|
371
|
+
assert.equal(unsupportedEvidence['provenance'], 'static-transfer-pack');
|
|
372
|
+
assert.match(String(unsupportedEvidence['fallbackReason']), /unsupported_ground_transfer_mode:walking/);
|
|
373
|
+
assert.equal(fixtureCalls.length, 4, 'unsupported mode must not call the provider');
|
|
374
|
+
assert.doesNotMatch(JSON.stringify(unsupportedMode), /public transit|rail|transit/i);
|
|
375
|
+
const unknownMode = await call(host.feasibility, {
|
|
376
|
+
...payload,
|
|
377
|
+
ground_transfer: { ...GROUND_TRANSFER, mode: 'unknown' },
|
|
378
|
+
});
|
|
379
|
+
const unknownEvidence = asRecord(unknownMode.ground_transfer, 'unknown-mode ground_transfer');
|
|
380
|
+
assert.equal(unknownEvidence['applied'], false);
|
|
381
|
+
assert.match(String(unknownEvidence['fallbackReason']), /unsupported_ground_transfer_mode:unknown/);
|
|
382
|
+
assert.equal(fixtureCalls.length, 4, 'unknown mode must not call the provider');
|
|
383
|
+
const busTransfer = await call(host.feasibility, {
|
|
384
|
+
...payload,
|
|
385
|
+
ground_transfer: { ...GROUND_TRANSFER, candidate_id: 'qiandao', transfer_index: 1 },
|
|
386
|
+
});
|
|
387
|
+
const busEvidence = asRecord(busTransfer.ground_transfer, 'bus ground_transfer');
|
|
388
|
+
assert.equal(busEvidence['applied'], false);
|
|
389
|
+
assert.equal(busEvidence['staticMode'], 'bus');
|
|
390
|
+
assert.equal(busEvidence['staticMinutes'], 40);
|
|
391
|
+
assert.equal(busEvidence['priceCny'], 8);
|
|
392
|
+
assert.equal(busEvidence['provenance'], 'static-transfer-pack');
|
|
393
|
+
assert.equal(busEvidence['freshness'], 'not_attempted');
|
|
394
|
+
assert.match(String(busEvidence['fallbackReason']), /^ground_transfer_static_mode_mismatch/);
|
|
395
|
+
assert.equal(fixtureCalls.length, 4, 'bus transfer must not call the provider');
|
|
396
|
+
assert.equal(asRecord(busTransfer.verdicts?.find(v => v['candidate_id'] === 'qiandao')?.['transfer_evidence'], 'bus transfer evidence')['applied'], false);
|
|
397
|
+
const busPlusTaxiTransfer = await call(host.feasibility, {
|
|
398
|
+
...payload,
|
|
399
|
+
ground_transfer: { ...GROUND_TRANSFER, candidate_id: 'dali', transfer_index: 1 },
|
|
400
|
+
});
|
|
401
|
+
const busPlusTaxiEvidence = asRecord(busPlusTaxiTransfer.ground_transfer, 'bus_plus_taxi ground_transfer');
|
|
402
|
+
assert.equal(busPlusTaxiEvidence['applied'], false);
|
|
403
|
+
assert.equal(busPlusTaxiEvidence['staticMode'], 'bus_plus_taxi');
|
|
404
|
+
assert.equal(busPlusTaxiEvidence['staticMinutes'], 105);
|
|
405
|
+
assert.equal(busPlusTaxiEvidence['priceCny'], 40);
|
|
406
|
+
assert.equal(busPlusTaxiEvidence['provenance'], 'static-transfer-pack');
|
|
407
|
+
assert.match(String(busPlusTaxiEvidence['fallbackReason']), /^ground_transfer_static_mode_mismatch/);
|
|
408
|
+
assert.equal(fixtureCalls.length, 4, 'bus_plus_taxi transfer must not call the provider');
|
|
409
|
+
const bindingMismatch = await call(host.feasibility, {
|
|
410
|
+
...payload,
|
|
411
|
+
ground_transfer: { ...GROUND_TRANSFER, candidate_id: 'not-a-candidate' },
|
|
412
|
+
});
|
|
413
|
+
const mismatchEvidence = asRecord(bindingMismatch.ground_transfer, 'binding-mismatch ground_transfer');
|
|
414
|
+
assert.equal(mismatchEvidence['applied'], false);
|
|
415
|
+
assert.equal(mismatchEvidence['provenance'], 'none');
|
|
416
|
+
assert.equal(mismatchEvidence['freshness'], 'not_attempted');
|
|
417
|
+
assert.match(String(mismatchEvidence['fallbackReason']), /ground_transfer_binding_mismatch/);
|
|
418
|
+
assert.equal(fixtureCalls.length, 4, 'binding mismatch must not call the provider');
|
|
419
|
+
const transferMismatch = await call(host.feasibility, {
|
|
420
|
+
...payload,
|
|
421
|
+
ground_transfer: { ...GROUND_TRANSFER, transfer_index: 99 },
|
|
422
|
+
});
|
|
423
|
+
const transferMismatchEvidence = asRecord(transferMismatch.ground_transfer, 'transfer-index-mismatch ground_transfer');
|
|
424
|
+
assert.equal(transferMismatchEvidence['applied'], false);
|
|
425
|
+
assert.match(String(transferMismatchEvidence['fallbackReason']), /ground_transfer_binding_mismatch/);
|
|
426
|
+
assert.equal(fixtureCalls.length, 4, 'transfer binding mismatch must not call the provider');
|
|
427
|
+
// Real ToolRuntime nested map_driving_route: both directions dispatched,
|
|
428
|
+
// both return 13 min from the same public map fixture.
|
|
429
|
+
const runtimeHost = await publicToolHost({ ...payload, ground_transfer: GROUND_TRANSFER });
|
|
430
|
+
try {
|
|
431
|
+
const runtimeEvidence = asRecord(runtimeHost.result.ground_transfer, 'real ToolRuntime ground_transfer');
|
|
432
|
+
assert.equal(runtimeEvidence['provenance'], 'map_driving_route');
|
|
433
|
+
assert.equal(runtimeEvidence['outboundMinutes'], 13);
|
|
434
|
+
assert.equal(runtimeEvidence['returnMinutes'], 13);
|
|
435
|
+
assert.equal(runtimeEvidence['asOf'], FIXED_NOW.toISOString());
|
|
436
|
+
assert.equal(runtimeHost.mapCalls.length, 2);
|
|
437
|
+
assert.deepEqual(runtimeHost.mapCalls[0], {
|
|
438
|
+
origin: '120.1234,30.2345',
|
|
439
|
+
destination: '119.4567,29.6789',
|
|
440
|
+
});
|
|
441
|
+
assert.deepEqual(runtimeHost.mapCalls[1], {
|
|
442
|
+
origin: '119.4567,29.6789',
|
|
443
|
+
destination: '120.1234,30.2345',
|
|
444
|
+
});
|
|
445
|
+
assert.equal('mode' in runtimeHost.mapCalls[0], false, 'mode is implied by the public map tool name');
|
|
446
|
+
}
|
|
447
|
+
finally {
|
|
448
|
+
await runtimeHost.dispose();
|
|
449
|
+
}
|
|
450
|
+
const unknownAgeHost = await publicToolExecution({
|
|
451
|
+
...payload,
|
|
452
|
+
ground_transfer: { ...GROUND_TRANSFER, max_age_s: 1 },
|
|
453
|
+
});
|
|
454
|
+
try {
|
|
455
|
+
assert.equal(unknownAgeHost.execution.isError, false, 'guarded ToolRuntime schema rejection is returned as a structured tool failure');
|
|
456
|
+
if (!unknownAgeHost.execution.isError) {
|
|
457
|
+
const rejection = asRecord(unknownAgeHost.execution.value, 'unknown max_age_s rejection');
|
|
458
|
+
assert.equal(rejection['ok'], false, 'closed ToolRuntime schema must reject caller TTL');
|
|
459
|
+
assert.match(`${rejection['summary'] ?? ''} ${rejection['evidence'] ?? ''}`, /max_age_s|additional|unknown/i);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
finally {
|
|
463
|
+
await unknownAgeHost.dispose();
|
|
464
|
+
}
|
|
465
|
+
// ─── Issue #364 per-direction binding additions ──────────────────────────
|
|
466
|
+
// taxi-only payload: constrains the solver to the patched transfer so the
|
|
467
|
+
// asymmetric route minutes flow into `evaluateChoice` without being
|
|
468
|
+
// shadowed by a cheaper unpatched sibling transfer.
|
|
469
|
+
const taxiOnlyPayload = {
|
|
470
|
+
...payload,
|
|
471
|
+
candidates: payload.candidates.map((candidate) => {
|
|
472
|
+
if (candidate['id'] !== 'qiandao')
|
|
473
|
+
return candidate;
|
|
474
|
+
const transfers = candidate['dest_transfers'];
|
|
475
|
+
return { ...candidate, dest_transfers: [transfers[0]] };
|
|
476
|
+
}),
|
|
477
|
+
};
|
|
478
|
+
// 1. Asymmetric fake: A→B = 13 min (780s), B→A = 31 min (1860s). The solver
|
|
479
|
+
// must consume each direction separately and reflect 13 for arrival
|
|
480
|
+
// and 31 for return through the actual `solveChoiceSegment` path.
|
|
481
|
+
const asymmetricCalls = [];
|
|
482
|
+
const asymmetricHost = fakeHost(async (request) => {
|
|
483
|
+
asymmetricCalls.push({ ...request });
|
|
484
|
+
const isReturn = request.origin === '119.4567,29.6789';
|
|
485
|
+
return route(isReturn ? 1_860 : 780, isReturn ? 'asymmetric-return' : 'asymmetric-outbound');
|
|
486
|
+
});
|
|
487
|
+
try {
|
|
488
|
+
const asymmetric = await call(asymmetricHost.feasibility, { ...taxiOnlyPayload, ground_transfer: GROUND_TRANSFER });
|
|
489
|
+
assert.equal(asymmetric.ok, true);
|
|
490
|
+
assert.equal(asymmetricCalls.length, 2);
|
|
491
|
+
const asymEvidence = asRecord(asymmetric.ground_transfer, 'asymmetric ground_transfer');
|
|
492
|
+
assert.equal(asymEvidence['applied'], true);
|
|
493
|
+
assert.equal(asymEvidence['outboundMinutes'], 13);
|
|
494
|
+
assert.equal(asymEvidence['returnMinutes'], 31);
|
|
495
|
+
const asymVerdict = (asymmetric.verdicts ?? []).find(v => v['candidate_id'] === 'qiandao');
|
|
496
|
+
const asymTc = asRecord(asymVerdict['true_cost'], 'asymmetric true_cost');
|
|
497
|
+
assert.equal(asymTc['arrive_stay'], '10:00', 'outbound 13 min ⇒ G7315 9:47 + 13 = 10:00');
|
|
498
|
+
assert.equal(asymTc['leave_stay_return'], '17:49', 'return 31 min ⇒ G7316 18:40 − 20 − 31 = 17:49');
|
|
499
|
+
const asymChosen = asRecord(asymVerdict['chosen'], 'asymmetric chosen');
|
|
500
|
+
assert.equal(asymChosen['out_service'], 'G7315');
|
|
501
|
+
assert.equal(asymChosen['ret_service'], 'G7316');
|
|
502
|
+
assert.equal(asymChosen['out_transfer'], 'taxi');
|
|
503
|
+
assert.equal(asymChosen['ret_transfer'], 'taxi');
|
|
504
|
+
// End-to-end through `resolveGroundTransferPayload` → `segmentsFromCandidate`
|
|
505
|
+
// → `solveChoiceSegment` → `evaluateChoice`: the same path the registered
|
|
506
|
+
// tool executes. The arrival/return minutes are read per direction from the
|
|
507
|
+
// patched transfer, so swapping them in the route must not change the verdict.
|
|
508
|
+
const asymCandidates = parsedCandidates.map(candidate => {
|
|
509
|
+
if (candidate.id !== 'qiandao')
|
|
510
|
+
return candidate;
|
|
511
|
+
return {
|
|
512
|
+
...candidate,
|
|
513
|
+
destTransfers: [{
|
|
514
|
+
...candidate.destTransfers[0],
|
|
515
|
+
minutesOut: 13,
|
|
516
|
+
minutesRet: 31,
|
|
517
|
+
}],
|
|
518
|
+
};
|
|
519
|
+
});
|
|
520
|
+
const asymReq = parseRequest(payload['request']);
|
|
521
|
+
const asymSpec = segmentsFromCandidate(asymReq, asymCandidates);
|
|
522
|
+
const asymSolved = solveChoiceSegment(asymSpec, asymReq);
|
|
523
|
+
const asymPathVerdict = asymSolved['verdicts'].find(v => v['candidate_id'] === 'qiandao');
|
|
524
|
+
const asymPathTc = asRecord(asymPathVerdict['true_cost'], 'asymmetric path true_cost');
|
|
525
|
+
assert.equal(asymPathTc['arrive_stay'], '10:00');
|
|
526
|
+
assert.equal(asymPathTc['leave_stay_return'], '17:49');
|
|
527
|
+
// Direct evaluateChoice sanity: with both overrides on the same transfer,
|
|
528
|
+
// arrival uses minutesOut and return uses minutesRet independently.
|
|
529
|
+
const asymTaxi = asymCandidates[1].destTransfers[0];
|
|
530
|
+
const asymCost = evaluateChoice(asymCandidates[1], asymReq, {
|
|
531
|
+
outService: asymCandidates[1].servicesOut[0],
|
|
532
|
+
outTransfer: asymTaxi,
|
|
533
|
+
retService: asymCandidates[1].servicesRet[0],
|
|
534
|
+
retTransfer: asymTaxi,
|
|
535
|
+
days: asymReq.windowDays,
|
|
536
|
+
});
|
|
537
|
+
assert.equal(asymCost.arriveStayMin, 600, 'outbound 9:47 + minutesOut(13) = 10:00');
|
|
538
|
+
assert.equal(asymCost.departHomeRetMin, 945, 'return G7482 16:36 − 20 − minutesRet(31) = 15:45');
|
|
539
|
+
// Counter-direction sanity: if we swap the overrides, the arrival uses 31
|
|
540
|
+
// and the return uses 13 — proves the binding is genuinely per-direction
|
|
541
|
+
// and not just "use the route minutes for both".
|
|
542
|
+
const swappedTaxi = { ...asymTaxi, minutesOut: 31, minutesRet: 13 };
|
|
543
|
+
const swappedCost = evaluateChoice(asymCandidates[1], asymReq, {
|
|
544
|
+
outService: asymCandidates[1].servicesOut[0],
|
|
545
|
+
outTransfer: swappedTaxi,
|
|
546
|
+
retService: asymCandidates[1].servicesRet[0],
|
|
547
|
+
retTransfer: swappedTaxi,
|
|
548
|
+
days: asymReq.windowDays,
|
|
549
|
+
});
|
|
550
|
+
assert.equal(swappedCost.arriveStayMin, 618, 'arrival 9:47 + 31 = 10:18');
|
|
551
|
+
assert.equal(swappedCost.departHomeRetMin, 963, 'return 16:36 − 20 − 13 = 16:03 = 963');
|
|
552
|
+
// Direct `evaluateChoice` on the same scenario but with G7316 ret (the
|
|
553
|
+
// service the production solver actually picks) gives the 17:49 figure
|
|
554
|
+
// asserted through the full registered-tool pipeline above.
|
|
555
|
+
const asymCostLateRet = evaluateChoice(asymCandidates[1], asymReq, {
|
|
556
|
+
outService: asymCandidates[1].servicesOut[0],
|
|
557
|
+
outTransfer: asymTaxi,
|
|
558
|
+
retService: asymCandidates[1].servicesRet[1],
|
|
559
|
+
retTransfer: asymTaxi,
|
|
560
|
+
days: asymReq.windowDays,
|
|
561
|
+
});
|
|
562
|
+
assert.equal(asymCostLateRet.departHomeRetMin, 1069, 'G7316 18:40 − 20 − 31 = 17:49 = 1069 (matches verdict)');
|
|
563
|
+
}
|
|
564
|
+
finally {
|
|
565
|
+
rmSync(asymmetricHost.root, { recursive: true, force: true });
|
|
566
|
+
}
|
|
567
|
+
// 2. Per-direction cache: A→B and B→A are separate cache entries.
|
|
568
|
+
const isolatedCalls = [];
|
|
569
|
+
const isolatedHost = fakeHost(async (request) => {
|
|
570
|
+
isolatedCalls.push({ ...request });
|
|
571
|
+
return route(720, 'isolated-fixture');
|
|
572
|
+
});
|
|
573
|
+
try {
|
|
574
|
+
await call(isolatedHost.feasibility, { ...payload, ground_transfer: GROUND_TRANSFER });
|
|
575
|
+
assert.equal(isolatedCalls.length, 2);
|
|
576
|
+
const firstOutbound = isolatedCalls[0];
|
|
577
|
+
const firstReturn = isolatedCalls[1];
|
|
578
|
+
assert.deepEqual(firstOutbound, { origin: '120.1234,30.2345', destination: '119.4567,29.6789', mode: 'driving' });
|
|
579
|
+
assert.deepEqual(firstReturn, { origin: '119.4567,29.6789', destination: '120.1234,30.2345', mode: 'driving' });
|
|
580
|
+
// Second call: both directions are cache hits, no new provider call.
|
|
581
|
+
await call(isolatedHost.feasibility, { ...payload, ground_transfer: GROUND_TRANSFER });
|
|
582
|
+
assert.equal(isolatedCalls.length, 2, 'second call must reuse both direction caches independently');
|
|
583
|
+
// Explicit outbound/return shape: cache for those exact coordinate pairs.
|
|
584
|
+
const explicitOutbound = {
|
|
585
|
+
longitude: 121.0,
|
|
586
|
+
latitude: 31.0,
|
|
587
|
+
};
|
|
588
|
+
const explicitReturn = {
|
|
589
|
+
longitude: 122.0,
|
|
590
|
+
latitude: 32.0,
|
|
591
|
+
};
|
|
592
|
+
await call(isolatedHost.feasibility, {
|
|
593
|
+
...payload,
|
|
594
|
+
ground_transfer: {
|
|
595
|
+
candidate_id: 'qiandao',
|
|
596
|
+
transfer_index: 0,
|
|
597
|
+
position: 'destination',
|
|
598
|
+
mode: 'driving',
|
|
599
|
+
outbound: { origin: explicitOutbound, destination: GROUND_TRANSFER.destination },
|
|
600
|
+
return: { origin: GROUND_TRANSFER.destination, destination: explicitReturn },
|
|
601
|
+
},
|
|
602
|
+
});
|
|
603
|
+
assert.equal(isolatedCalls.length, 4);
|
|
604
|
+
const newOutboundCall = isolatedCalls[2];
|
|
605
|
+
const newReturnCall = isolatedCalls[3];
|
|
606
|
+
assert.deepEqual(newOutboundCall, { origin: '121,31', destination: '119.4567,29.6789', mode: 'driving' });
|
|
607
|
+
assert.deepEqual(newReturnCall, { origin: '119.4567,29.6789', destination: '122,32', mode: 'driving' });
|
|
608
|
+
}
|
|
609
|
+
finally {
|
|
610
|
+
rmSync(isolatedHost.root, { recursive: true, force: true });
|
|
611
|
+
}
|
|
612
|
+
// 3. One-sided failure: A→B succeeds (13 min), B→A throws. Arrival uses
|
|
613
|
+
// 13 from the live route; return falls back to static minutes (25).
|
|
614
|
+
// The aggregate provenance reflects the partial route success.
|
|
615
|
+
const oneSidedCalls = [];
|
|
616
|
+
const oneSidedHost = fakeHost(async (request) => {
|
|
617
|
+
oneSidedCalls.push({ ...request });
|
|
618
|
+
if (request.destination === '120.1234,30.2345') {
|
|
619
|
+
throw new Error('return-side provider outage');
|
|
620
|
+
}
|
|
621
|
+
return route(780, 'one-sided-outbound');
|
|
622
|
+
});
|
|
623
|
+
try {
|
|
624
|
+
const oneSided = await call(oneSidedHost.feasibility, { ...taxiOnlyPayload, ground_transfer: GROUND_TRANSFER });
|
|
625
|
+
assert.equal(oneSidedCalls.length, 2);
|
|
626
|
+
const osEvidence = asRecord(oneSided.ground_transfer, 'one-sided ground_transfer');
|
|
627
|
+
assert.equal(osEvidence['applied'], true);
|
|
628
|
+
assert.equal(osEvidence['outboundMinutes'], 13);
|
|
629
|
+
assert.equal(osEvidence['returnMinutes'], undefined, 'failed return direction does not borrow the outbound value');
|
|
630
|
+
assert.equal(osEvidence['provenance'], 'map_driving_route');
|
|
631
|
+
const osOutbound = directionOf(osEvidence['outbound'], 'one-sided.outbound');
|
|
632
|
+
const osReturn = directionOf(osEvidence['return'], 'one-sided.return');
|
|
633
|
+
assert.equal(osOutbound.applied, true);
|
|
634
|
+
assert.equal(osOutbound.minutes, 13);
|
|
635
|
+
assert.equal(osReturn.applied, false);
|
|
636
|
+
assert.equal(osReturn.minutes, undefined);
|
|
637
|
+
assert.equal(osReturn.staticMinutes, 25);
|
|
638
|
+
assert.match(String(osReturn.fallbackReason), /return-side provider outage/);
|
|
639
|
+
const osVerdict = (oneSided.verdicts ?? []).find(v => v['candidate_id'] === 'qiandao');
|
|
640
|
+
const osTc = asRecord(osVerdict['true_cost'], 'one-sided true_cost');
|
|
641
|
+
assert.equal(osTc['arrive_stay'], '10:00', 'arrival uses the successful outbound 13 min');
|
|
642
|
+
// 18*60+40 − 20 − 25 = 1075 = 17:55 (static minutes win on the failed return).
|
|
643
|
+
assert.equal(osTc['leave_stay_return'], '17:55', 'return falls back to static 25 min ⇒ 18:40 − 20 − 25 = 17:55');
|
|
644
|
+
}
|
|
645
|
+
finally {
|
|
646
|
+
rmSync(oneSidedHost.root, { recursive: true, force: true });
|
|
647
|
+
}
|
|
648
|
+
// 4. Real ToolRuntime nested map_driving_route through registered
|
|
649
|
+
// gotry_feasibility_check into actual solveChoiceSegment. Use an
|
|
650
|
+
// asymmetric map tool that returns 780s outbound and 1860s return.
|
|
651
|
+
const realAsymmetricRoot = mkdtempSync(join(tmpdir(), 'gotry-364-real-asymmetric-'));
|
|
652
|
+
const realMapCalls = [];
|
|
653
|
+
const realCtx = new Context();
|
|
654
|
+
await realCtx.plugin(SystemPrompt);
|
|
655
|
+
await realCtx.plugin(ToolRuntime, {});
|
|
656
|
+
realCtx.tools.register(defineTool({
|
|
657
|
+
name: 'map_driving_route',
|
|
658
|
+
description: 'asymmetric real map tool',
|
|
659
|
+
parameters: {
|
|
660
|
+
origin: { type: 'string', required: true },
|
|
661
|
+
destination: { type: 'string', required: true },
|
|
662
|
+
},
|
|
663
|
+
output: {
|
|
664
|
+
schema: { type: 'json' },
|
|
665
|
+
render: () => [{ type: 'text', text: 'asymmetric map route' }],
|
|
666
|
+
},
|
|
667
|
+
async execute(args, _execution) {
|
|
668
|
+
realMapCalls.push({ ...args });
|
|
669
|
+
const isReturn = args.origin === '119.4567,29.6789';
|
|
670
|
+
return route(isReturn ? 1_860 : 780, isReturn ? 'real-return' : 'real-outbound');
|
|
671
|
+
},
|
|
672
|
+
}));
|
|
673
|
+
apply(realCtx, {
|
|
674
|
+
stateRoot: realAsymmetricRoot,
|
|
675
|
+
timeoutMs: 30_000,
|
|
676
|
+
hbcliBin: 'hbcli-not-on-path',
|
|
677
|
+
sessionAccess: 'off',
|
|
678
|
+
}, { clock: () => FIXED_NOW });
|
|
679
|
+
try {
|
|
680
|
+
const realExecution = await realCtx.tools.execute({
|
|
681
|
+
callId: 'issue-364-asymmetric-root',
|
|
682
|
+
name: 'gotry_feasibility_check',
|
|
683
|
+
arguments: { payload: clone({ ...taxiOnlyPayload, ground_transfer: GROUND_TRANSFER }) },
|
|
684
|
+
signal: new AbortController().signal,
|
|
685
|
+
});
|
|
686
|
+
assert.equal(realExecution.isError, false, `nested tool execution failed: ${realExecution.isError ? realExecution.error.message : ''}`);
|
|
687
|
+
const realResult = resultOf(realExecution.value);
|
|
688
|
+
const realEvidence = asRecord(realResult.ground_transfer, 'real ToolRuntime asymmetric evidence');
|
|
689
|
+
assert.equal(realEvidence['applied'], true);
|
|
690
|
+
assert.equal(realEvidence['outboundMinutes'], 13);
|
|
691
|
+
assert.equal(realEvidence['returnMinutes'], 31);
|
|
692
|
+
assert.equal(realMapCalls.length, 2);
|
|
693
|
+
const realOutboundCall = realMapCalls[0];
|
|
694
|
+
const realReturnCall = realMapCalls[1];
|
|
695
|
+
assert.equal(realOutboundCall['origin'], '120.1234,30.2345');
|
|
696
|
+
assert.equal(realOutboundCall['destination'], '119.4567,29.6789');
|
|
697
|
+
assert.equal(realReturnCall['origin'], '119.4567,29.6789');
|
|
698
|
+
assert.equal(realReturnCall['destination'], '120.1234,30.2345');
|
|
699
|
+
const realVerdict = (realResult.verdicts ?? []).find(v => v['candidate_id'] === 'qiandao');
|
|
700
|
+
const realTc = asRecord(realVerdict['true_cost'], 'real ToolRuntime asymmetric true_cost');
|
|
701
|
+
assert.equal(realTc['arrive_stay'], '10:00');
|
|
702
|
+
assert.equal(realTc['leave_stay_return'], '17:49', 'real ToolRuntime → real solveChoiceSegment → real evaluateChoice uses minutesRet = 31');
|
|
703
|
+
// Reverse coordinates: prove the solver no longer reuses the outbound
|
|
704
|
+
// value for the return when the route fact is genuinely asymmetric.
|
|
705
|
+
const reverseReq = parseRequest(payload['request']);
|
|
706
|
+
const reverseCandidates = payload.candidates.filter((c) => c['id'] === 'qiandao').map(parseCandidate);
|
|
707
|
+
reverseCandidates[0].destTransfers = [reverseCandidates[0].destTransfers[0]];
|
|
708
|
+
// Force the patched transfer to keep the symmetric minutes field (25) and
|
|
709
|
+
// assign per-direction overrides to mirror the asymmetric fake.
|
|
710
|
+
reverseCandidates[0].destTransfers[0].minutes = 25;
|
|
711
|
+
reverseCandidates[0].destTransfers[0].minutesOut = 13;
|
|
712
|
+
reverseCandidates[0].destTransfers[0].minutesRet = 31;
|
|
713
|
+
const reverseSpec = segmentsFromCandidate(reverseReq, reverseCandidates);
|
|
714
|
+
const reverseSolved = solveChoiceSegment(reverseSpec, reverseReq);
|
|
715
|
+
const reverseVerdict = reverseSolved['verdicts'].find(v => v['candidate_id'] === 'qiandao');
|
|
716
|
+
const reverseTc = asRecord(reverseVerdict['true_cost'], 'reverse-coordinate true_cost');
|
|
717
|
+
assert.equal(reverseTc['arrive_stay'], '10:00');
|
|
718
|
+
assert.equal(reverseTc['leave_stay_return'], '17:49');
|
|
719
|
+
}
|
|
720
|
+
finally {
|
|
721
|
+
await realCtx.fiber.dispose();
|
|
722
|
+
rmSync(realAsymmetricRoot, { recursive: true, force: true });
|
|
723
|
+
}
|
|
724
|
+
// 5. Backward-compat: legacy payload with no ground_transfer keeps byte-
|
|
725
|
+
// for-byte behavior; same baseline is reused unchanged.
|
|
726
|
+
const baselineAgain = await call(host.feasibility, payload);
|
|
727
|
+
assert.deepEqual(withoutLatency(baselineAgain), withoutLatency(baseline));
|
|
728
|
+
// ─── Wider D-39 boundary freeze (2026-09-11, offline remainder) ──────────
|
|
729
|
+
const TAXI_ONLY = taxiOnlyPayload;
|
|
730
|
+
// B1. Vocabulary closure. The GoTry boundary accepts exactly
|
|
731
|
+
// `mode=driving` at `position=destination`. The vendored public seam
|
|
732
|
+
// also exposes transit / walking / bicycling routes; none of them may
|
|
733
|
+
// leak through this seam as a route fact, and no malformed request
|
|
734
|
+
// shape may reach the provider.
|
|
735
|
+
let closureCalls = 0;
|
|
736
|
+
await withFeasibilityHost(async () => {
|
|
737
|
+
closureCalls += 1;
|
|
738
|
+
return route(600);
|
|
739
|
+
}, async (feasibility) => {
|
|
740
|
+
// provenance: cases where no valid transfer is identified report 'none'
|
|
741
|
+
// (nothing to attribute); cases that identify the transfer but carry an
|
|
742
|
+
// invalid mode/position/coordinate report the static pack.
|
|
743
|
+
const closedCases = [
|
|
744
|
+
{ override: { mode: 'transit' }, reason: /^unsupported_ground_transfer_mode:transit/, label: 'transit', provenance: 'static-transfer-pack' },
|
|
745
|
+
{ override: { mode: 'bicycling' }, reason: /^unsupported_ground_transfer_mode:bicycling/, label: 'bicycling', provenance: 'static-transfer-pack' },
|
|
746
|
+
{ override: { mode: 'walking' }, reason: /^unsupported_ground_transfer_mode:walking/, label: 'walking', provenance: 'static-transfer-pack' },
|
|
747
|
+
{ override: { position: 'origin' }, reason: /^unsupported_ground_transfer_position:origin/, label: 'position=origin', provenance: 'static-transfer-pack' },
|
|
748
|
+
{ override: { position: undefined }, reason: /^unsupported_ground_transfer_position:unknown/, label: 'position missing', provenance: 'static-transfer-pack' },
|
|
749
|
+
{ override: { candidate_id: undefined }, reason: /^ground_transfer_binding_mismatch:/, label: 'candidate_id missing', provenance: 'none' },
|
|
750
|
+
{ override: { transfer_index: -1 }, reason: /^ground_transfer_binding_mismatch:/, label: 'transfer_index negative', provenance: 'none' },
|
|
751
|
+
{
|
|
752
|
+
override: { outbound: { origin: GROUND_TRANSFER.origin, destination: GROUND_TRANSFER.destination } },
|
|
753
|
+
reason: /^invalid_ground_transfer_coordinates:/,
|
|
754
|
+
label: 'explicit outbound without return',
|
|
755
|
+
provenance: 'static-transfer-pack',
|
|
756
|
+
},
|
|
757
|
+
];
|
|
758
|
+
for (const closed of closedCases) {
|
|
759
|
+
const rejected = await call(feasibility, { ...payload, ground_transfer: { ...GROUND_TRANSFER, ...closed.override } });
|
|
760
|
+
const rejectedEvidence = asRecord(rejected.ground_transfer, `closure ${closed.label} ground_transfer`);
|
|
761
|
+
assert.equal(rejectedEvidence['applied'], false, `closure ${closed.label} must not apply`);
|
|
762
|
+
assert.equal(rejectedEvidence['provenance'], closed.provenance, `closure ${closed.label} provenance`);
|
|
763
|
+
assert.match(String(rejectedEvidence['fallbackReason']), closed.reason, `closure ${closed.label} reason`);
|
|
764
|
+
}
|
|
765
|
+
// Schema-ring closure: shapes the closed tool schema refuses outright
|
|
766
|
+
// (non-object ground_transfer, fractional transfer_index) never reach
|
|
767
|
+
// the capability layer — the structured failure names the violation.
|
|
768
|
+
for (const schemaCase of [
|
|
769
|
+
{ ground_transfer: 'driving-please', pattern: /"payload\.ground_transfer" must be an object/, label: 'non-object' },
|
|
770
|
+
{
|
|
771
|
+
ground_transfer: { ...GROUND_TRANSFER, transfer_index: 1.5 },
|
|
772
|
+
pattern: /"payload\.ground_transfer\.transfer_index" must be an integer/,
|
|
773
|
+
label: 'fractional transfer_index',
|
|
774
|
+
},
|
|
775
|
+
]) {
|
|
776
|
+
const schemaRejected = await call(feasibility, { ...payload, ...schemaCase });
|
|
777
|
+
assert.equal(schemaRejected.ok, false, `schema ring must refuse ${schemaCase.label}`);
|
|
778
|
+
assert.match(String(schemaRejected['summary']), schemaCase.pattern, `schema ring reason for ${schemaCase.label}`);
|
|
779
|
+
assert.equal('ground_transfer' in schemaRejected, false, `schema ring rejection carries no transfer evidence (${schemaCase.label})`);
|
|
780
|
+
}
|
|
781
|
+
assert.equal(closureCalls, 0, 'no closed-vocabulary or malformed shape may call the provider');
|
|
782
|
+
});
|
|
783
|
+
// B2. Fallback completeness, mixed directions: a direction whose cache
|
|
784
|
+
// entry went stale and whose re-query fails falls back that direction
|
|
785
|
+
// only, with the stale entry visible (cache status 'stale' and a
|
|
786
|
+
// `stale_route_requery_error` reason), while the other direction keeps
|
|
787
|
+
// its re-queried dynamic value.
|
|
788
|
+
let mixedCalls = 0;
|
|
789
|
+
let mixedClockMs = FIXED_NOW.getTime();
|
|
790
|
+
let mixedReturnOutage = false;
|
|
791
|
+
const mixedHost = fakeHost(async (request) => {
|
|
792
|
+
mixedCalls += 1;
|
|
793
|
+
if (mixedReturnOutage && request.origin === '119.4567,29.6789')
|
|
794
|
+
throw new Error('return requery outage');
|
|
795
|
+
return route(900);
|
|
796
|
+
}, () => new Date(mixedClockMs));
|
|
797
|
+
try {
|
|
798
|
+
const mixedFirst = await call(mixedHost.feasibility, { ...TAXI_ONLY, ground_transfer: GROUND_TRANSFER });
|
|
799
|
+
assert.equal(mixedCalls, 2);
|
|
800
|
+
assert.equal(mixedFirst.ok, true);
|
|
801
|
+
mixedClockMs += (GROUND_TRANSFER_DEFAULT_MAX_AGE_S + 1) * 1_000;
|
|
802
|
+
mixedReturnOutage = true;
|
|
803
|
+
const mixed = await call(mixedHost.feasibility, { ...TAXI_ONLY, ground_transfer: GROUND_TRANSFER });
|
|
804
|
+
assert.equal(mixedCalls, 4, 'both stale entries must re-query; only the return fails');
|
|
805
|
+
assert.equal(mixed.ok, true);
|
|
806
|
+
const mixedEvidence = asRecord(mixed.ground_transfer, 'mixed stale/fallback ground_transfer');
|
|
807
|
+
assert.equal(mixedEvidence['applied'], true);
|
|
808
|
+
assert.equal(mixedEvidence['outboundMinutes'], 15);
|
|
809
|
+
assert.equal(mixedEvidence['returnMinutes'], undefined, 'failed stale re-query must not borrow any dynamic value');
|
|
810
|
+
assert.equal(mixedEvidence['freshness'], 'fresh', 'aggregate freshness follows the re-queried direction');
|
|
811
|
+
assert.equal(asRecord(mixedEvidence['cache'], 'mixed aggregate cache')['status'], 'requery');
|
|
812
|
+
assert.equal(mixedEvidence['priceCny'], 30, 'mixed path keeps the static price value');
|
|
813
|
+
assert.equal(mixedEvidence['priceEvidence'], GROUND_TRANSFER_STATIC_PRICE_EVIDENCE);
|
|
814
|
+
const mixedOutbound = directionOf(mixedEvidence['outbound'], 'mixed.outbound');
|
|
815
|
+
const mixedReturn = directionOf(mixedEvidence['return'], 'mixed.return');
|
|
816
|
+
assert.equal(mixedOutbound.applied, true);
|
|
817
|
+
assert.equal(mixedOutbound.freshness, 'fresh');
|
|
818
|
+
assert.equal(asRecord(mixedOutbound['cache'], 'mixed.outbound.cache')['status'], 'requery');
|
|
819
|
+
assert.equal(mixedReturn.applied, false);
|
|
820
|
+
assert.equal(mixedReturn.staticMinutes, 25);
|
|
821
|
+
assert.equal(mixedReturn.freshness, 'fallback');
|
|
822
|
+
assert.equal(asRecord(mixedReturn['cache'], 'mixed.return.cache')['status'], 'stale', 'the stale entry itself must be visible on fallback');
|
|
823
|
+
assert.match(String(mixedReturn.fallbackReason), /^stale_route_requery_error:return:return requery outage$/);
|
|
824
|
+
const mixedVerdict = (mixed.verdicts ?? []).find(v => v['candidate_id'] === 'qiandao');
|
|
825
|
+
const mixedTc = asRecord(mixedVerdict['true_cost'], 'mixed true_cost');
|
|
826
|
+
assert.equal(mixedTc['arrive_stay'], '10:02', 're-queried outbound 15 min ⇒ 9:47 + 15 = 10:02');
|
|
827
|
+
assert.equal(mixedTc['leave_stay_return'], '17:55', 'stale-failed return keeps static 25 min ⇒ 18:40 − 20 − 25 = 17:55');
|
|
828
|
+
const mixedVerdictEvidence = asRecord(mixedVerdict['transfer_evidence'], 'mixed verdict transfer_evidence');
|
|
829
|
+
assert.equal(mixedVerdictEvidence['outboundMinutes'], 15);
|
|
830
|
+
assert.equal(mixedVerdictEvidence['returnMinutes'], undefined);
|
|
831
|
+
assert.equal(mixedVerdictEvidence['priceEvidence'], GROUND_TRANSFER_STATIC_PRICE_EVIDENCE);
|
|
832
|
+
}
|
|
833
|
+
finally {
|
|
834
|
+
rmSync(mixedHost.root, { recursive: true, force: true });
|
|
835
|
+
}
|
|
836
|
+
// B2b. A route fact that contradicts itself — positive distance covered in
|
|
837
|
+
// zero time — is not a credible route fact. It must fall into the
|
|
838
|
+
// explicit provider-miss fallback instead of silently binding
|
|
839
|
+
// minutes = 0 into the solver (issue #341: “不静默改数”).
|
|
840
|
+
let degenerateCalls = 0;
|
|
841
|
+
await withFeasibilityHost(async () => {
|
|
842
|
+
degenerateCalls += 1;
|
|
843
|
+
return { provider: 'degenerate-osrm', distanceM: 42_500, durationS: 0 };
|
|
844
|
+
}, async (feasibility) => {
|
|
845
|
+
const degenerate = await call(feasibility, { ...TAXI_ONLY, ground_transfer: GROUND_TRANSFER });
|
|
846
|
+
const degenerateEvidence = asRecord(degenerate.ground_transfer, 'degenerate ground_transfer');
|
|
847
|
+
assert.equal(degenerateEvidence['applied'], false, 'a contradictory route fact must not apply');
|
|
848
|
+
assert.equal(degenerateEvidence['provenance'], 'static-transfer-pack');
|
|
849
|
+
assert.equal(degenerateEvidence['evidenceClass'], 'static_transfer_estimate');
|
|
850
|
+
assert.equal(degenerateEvidence['outboundMinutes'], undefined);
|
|
851
|
+
assert.equal(degenerateEvidence['returnMinutes'], undefined);
|
|
852
|
+
assert.match(String(degenerateEvidence['fallbackReason']), /invalid public result/);
|
|
853
|
+
assert.equal(degenerateEvidence['priceCny'], 30);
|
|
854
|
+
assert.equal(degenerateEvidence['priceEvidence'], GROUND_TRANSFER_STATIC_PRICE_EVIDENCE);
|
|
855
|
+
const degenerateOutbound = directionOf(degenerateEvidence['outbound'], 'degenerate.outbound');
|
|
856
|
+
assert.equal(degenerateOutbound.applied, false);
|
|
857
|
+
assert.equal(degenerateOutbound.staticMinutes, 25);
|
|
858
|
+
assert.equal(degenerateOutbound['routeFact'], undefined, 'a rejected fact is never cached into evidence');
|
|
859
|
+
const degenerateSerialized = JSON.stringify(degenerate);
|
|
860
|
+
assert.equal(degenerateSerialized.includes('"minutesOut":0'), false, 'zero minutes must never be bound');
|
|
861
|
+
assert.equal(degenerateSerialized.includes('"minutesRet":0'), false);
|
|
862
|
+
const degenerateVerdict = (degenerate.verdicts ?? []).find(v => v['candidate_id'] === 'qiandao');
|
|
863
|
+
const degenerateTc = asRecord(degenerateVerdict['true_cost'], 'degenerate true_cost');
|
|
864
|
+
assert.equal(degenerateTc['leave_stay_return'], '17:55', 'static 25 min preserved, not the degenerate 0');
|
|
865
|
+
assert.equal(degenerateCalls, 2, 'the contradictory response is still a provider query for both directions');
|
|
866
|
+
});
|
|
867
|
+
// B3a. Static↔dynamic switch contract, TTL boundary: a cache entry aged
|
|
868
|
+
// exactly the fixed 900 seconds is still a hit; one second later it
|
|
869
|
+
// is stale and re-queried.
|
|
870
|
+
let ttlCalls = 0;
|
|
871
|
+
let ttlClockMs = FIXED_NOW.getTime();
|
|
872
|
+
const ttlHost = fakeHost(async () => {
|
|
873
|
+
ttlCalls += 1;
|
|
874
|
+
return route(780);
|
|
875
|
+
}, () => new Date(ttlClockMs));
|
|
876
|
+
try {
|
|
877
|
+
await call(ttlHost.feasibility, { ...payload, ground_transfer: GROUND_TRANSFER });
|
|
878
|
+
assert.equal(ttlCalls, 2);
|
|
879
|
+
ttlClockMs += GROUND_TRANSFER_DEFAULT_MAX_AGE_S * 1_000;
|
|
880
|
+
const atBoundary = await call(ttlHost.feasibility, { ...payload, ground_transfer: GROUND_TRANSFER });
|
|
881
|
+
assert.equal(ttlCalls, 2, 'age == TTL (900s) is still a cache hit');
|
|
882
|
+
const atBoundaryEvidence = asRecord(atBoundary.ground_transfer, 'ttl-boundary ground_transfer');
|
|
883
|
+
assert.equal(directionOf(atBoundaryEvidence['outbound'], 'ttl-boundary.outbound')['freshness'], 'cache_hit');
|
|
884
|
+
assert.equal(directionOf(atBoundaryEvidence['return'], 'ttl-boundary.return')['freshness'], 'cache_hit');
|
|
885
|
+
ttlClockMs += 1_000;
|
|
886
|
+
const pastBoundary = await call(ttlHost.feasibility, { ...payload, ground_transfer: GROUND_TRANSFER });
|
|
887
|
+
assert.equal(ttlCalls, 4, 'age > TTL (901s) must re-query both directions');
|
|
888
|
+
const pastBoundaryEvidence = asRecord(pastBoundary.ground_transfer, 'ttl-past ground_transfer');
|
|
889
|
+
assert.equal(directionOf(pastBoundaryEvidence['outbound'], 'ttl-past.outbound')['freshness'], 'fresh');
|
|
890
|
+
assert.equal(asRecord(directionOf(pastBoundaryEvidence['outbound'], 'ttl-past.outbound')['cache'], 'ttl-past.outbound.cache')['status'], 'requery');
|
|
891
|
+
}
|
|
892
|
+
finally {
|
|
893
|
+
rmSync(ttlHost.root, { recursive: true, force: true });
|
|
894
|
+
}
|
|
895
|
+
// B3b. Identity switch: a route whose duration equals the static minutes
|
|
896
|
+
// is still a dynamic fact — the switch is provenance-driven, not
|
|
897
|
+
// value-diff-driven; the symmetric `minutes` field stays untouched
|
|
898
|
+
// and evidence stays fully annotated in both directions.
|
|
899
|
+
await withFeasibilityHost(async () => route(1_500), async (feasibility) => {
|
|
900
|
+
const identity = await call(feasibility, { ...payload, ground_transfer: GROUND_TRANSFER });
|
|
901
|
+
const identityEvidence = asRecord(identity.ground_transfer, 'identity ground_transfer');
|
|
902
|
+
assert.equal(identityEvidence['applied'], true);
|
|
903
|
+
assert.equal(identityEvidence['provenance'], 'map_driving_route', 'identity minutes must keep the dynamic provenance');
|
|
904
|
+
assert.equal(identityEvidence['outboundMinutes'], 25);
|
|
905
|
+
assert.equal(identityEvidence['returnMinutes'], 25);
|
|
906
|
+
assert.equal(identityEvidence['staticMinutes'], 25);
|
|
907
|
+
const identityOutboundFact = asRecord(directionOf(identityEvidence['outbound'], 'identity.outbound')['routeFact'], 'identity.outbound.routeFact');
|
|
908
|
+
const identityReturnFact = asRecord(directionOf(identityEvidence['return'], 'identity.return')['routeFact'], 'identity.return.routeFact');
|
|
909
|
+
for (const [label, fact] of [['outbound', identityOutboundFact], ['return', identityReturnFact]]) {
|
|
910
|
+
assert.equal('priceCny' in fact, false, `identity ${label} route fact must not invent a price`);
|
|
911
|
+
assert.equal(fact['trafficStatus'], 'not-live-route-estimate', `identity ${label} route fact must disclaim live traffic`);
|
|
912
|
+
assert.equal(fact['evidenceClass'], 'public_map_route_estimate');
|
|
913
|
+
}
|
|
914
|
+
});
|
|
915
|
+
const boundedRequests = [];
|
|
916
|
+
const pairFor = (longitude) => ({
|
|
917
|
+
candidateId: 'qiandao',
|
|
918
|
+
transferIndex: 0,
|
|
919
|
+
mode: 'driving',
|
|
920
|
+
position: 'destination',
|
|
921
|
+
outbound: { direction: 'outbound', origin: { longitude, latitude: 20 }, destination: { longitude: longitude + 1, latitude: 21 } },
|
|
922
|
+
ret: { direction: 'return', origin: { longitude: longitude + 1, latitude: 21 }, destination: { longitude, latitude: 20 } },
|
|
923
|
+
});
|
|
924
|
+
const boundedSelection = { candidateId: 'qiandao', transferIndex: 0, staticMode: 'taxi', staticMinutes: 25, staticPriceCny: 30 };
|
|
925
|
+
const boundedResolver = createGroundTransferResolver({
|
|
926
|
+
provider: async (request) => {
|
|
927
|
+
boundedRequests.push({ ...request });
|
|
928
|
+
return route(600);
|
|
929
|
+
},
|
|
930
|
+
clock: () => FIXED_NOW,
|
|
931
|
+
maxEntries: 2,
|
|
932
|
+
});
|
|
933
|
+
await boundedResolver.resolve(pairFor(100), boundedSelection);
|
|
934
|
+
assert.equal(boundedRequests.length, 2);
|
|
935
|
+
await boundedResolver.resolve(pairFor(110), boundedSelection);
|
|
936
|
+
assert.equal(boundedRequests.length, 4);
|
|
937
|
+
await boundedResolver.resolve(pairFor(100), boundedSelection);
|
|
938
|
+
assert.equal(boundedRequests.length, 6, 'at maxEntries=2 the first pair was evicted and must re-query');
|
|
939
|
+
const relaxedRequests = [];
|
|
940
|
+
const relaxedResolver = createGroundTransferResolver({
|
|
941
|
+
provider: async (request) => {
|
|
942
|
+
relaxedRequests.push({ ...request });
|
|
943
|
+
return route(600);
|
|
944
|
+
},
|
|
945
|
+
clock: () => FIXED_NOW,
|
|
946
|
+
});
|
|
947
|
+
await relaxedResolver.resolve(pairFor(120), boundedSelection);
|
|
948
|
+
await relaxedResolver.resolve(pairFor(120), boundedSelection);
|
|
949
|
+
assert.equal(relaxedRequests.length, 2, 'the default cache keeps entries: the second resolve is a hit');
|
|
950
|
+
console.log('issue #341 ground-transfer focused suite: OK');
|
|
951
|
+
}
|
|
952
|
+
finally {
|
|
953
|
+
rmSync(host.root, { recursive: true, force: true });
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
await main();
|