diff --git a/src/services/PlacesSearcher.ts b/src/services/PlacesSearcher.ts index 9d33635..12dc0cd 100644 --- a/src/services/PlacesSearcher.ts +++ b/src/services/PlacesSearcher.ts @@ -529,8 +529,8 @@ export class PlacesSearcher { const origin = stops[0]; const destination = stops[stops.length - 1]; const intermediates = stops.length > 2 ? stops.slice(1, -1) : undefined; - // Optimize if requested, > 2 stops, and not transit (transit doesn't support intermediates for optimization) - const shouldOptimize = params.optimize !== false && stops.length > 2 && mode !== "transit"; + // Optimize if requested, > 3 stops (at least 2 intermediates), and not transit (transit doesn't support intermediates for optimization) + const shouldOptimize = params.optimize !== false && stops.length > 3 && mode !== "transit"; const routeResult = await this.routesService.computeRoutes({ origin, diff --git a/src/services/RoutesService.ts b/src/services/RoutesService.ts index 6f6721d..24ebbc6 100644 --- a/src/services/RoutesService.ts +++ b/src/services/RoutesService.ts @@ -176,11 +176,11 @@ export class RoutesService { requestBody.intermediates = params.intermediates.map(toWaypoint); } - // Waypoint optimization (not supported for TRANSIT) + // Waypoint optimization (not supported for TRANSIT, requires at least 2 intermediates) if ( params.optimizeWaypointOrder && params.intermediates && - params.intermediates.length > 0 && + params.intermediates.length > 1 && travelMode !== "TRANSIT" ) { requestBody.optimizeWaypointOrder = true; diff --git a/src/tools/maps/planRoute.ts b/src/tools/maps/planRoute.ts index 10a2a92..43e93aa 100644 --- a/src/tools/maps/planRoute.ts +++ b/src/tools/maps/planRoute.ts @@ -4,7 +4,7 @@ import { getCurrentApiKey } from "../../utils/requestContext.js"; const NAME = "maps_plan_route"; const DESCRIPTION = - "Plan an optimized multi-stop route in one call — geocodes all stops, uses Routes API waypoint optimization (up to 25 intermediate stops) to find the most efficient visit order, and returns directions for each leg. Use when the user says 'visit these 5 places efficiently', 'plan a route through A, B, C', or needs a multi-stop itinerary. Replaces the manual chain of geocode → distance-matrix → directions. For multi-day trips: create one plan_route call per day with stops that follow a geographic arc (e.g. east→west) rather than mixing distant areas. After results, call static_map to visualize the route."; + "Plan an optimized multi-stop route in one call — geocodes all stops, uses Routes API waypoint optimization (2 to 25 intermediate stops) to find the most efficient visit order, and returns directions for each leg. Use when the user says 'visit these 5 places efficiently', 'plan a route through A, B, C', or needs a multi-stop itinerary. Replaces the manual chain of geocode → distance-matrix → directions. Waypoint optimization requires at least 4 stops (2 intermediates); with 2 or 3 stops the route is returned in the original order. For multi-day trips: create one plan_route call per day with stops that follow a geographic arc (e.g. east→west) rather than mixing distant areas. After results, call static_map to visualize the route."; const SCHEMA = { stops: z.array(z.string()).min(2).describe("List of addresses or landmarks to visit (minimum 2)"), @@ -13,7 +13,7 @@ const SCHEMA = { .boolean() .optional() .describe( - "Auto-optimize visit order via Routes API waypoint optimization (default: true). Set false to keep original order. Not available for transit mode." + "Auto-optimize visit order via Routes API waypoint optimization (default: true). Requires at least 4 stops (2 intermediates) — ignored for 2-3 stops. Set false to keep original order. Not available for transit mode." ), departure_time: z .string() diff --git a/tests/planRoute.unit.test.ts b/tests/planRoute.unit.test.ts new file mode 100644 index 0000000..50c3482 --- /dev/null +++ b/tests/planRoute.unit.test.ts @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { PlacesSearcher } from "../src/services/PlacesSearcher.js"; + +interface RouteStub { + optimizedIntermediateWaypointIndex?: number[]; + legCount: number; +} + +/** + * Build a PlacesSearcher whose network dependencies are stubbed so planRoute + * can be exercised without hitting the Geocoding or Routes APIs. + */ +function createSearcher(routeStub: RouteStub): PlacesSearcher { + const searcher = new PlacesSearcher("test-api-key"); + + // Deterministic geocode: echo the requested stop as originalName. + searcher.geocode = async (address: string) => ({ + success: true, + data: { + location: { lat: 0, lng: 0 }, + formatted_address: `${address} (formatted)`, + place_id: `place-${address}`, + }, + }); + + // Stub the Routes API response. + ( + searcher as unknown as { routesService: { computeRoutes: (p: unknown) => Promise } } + ).routesService.computeRoutes = async () => ({ + routes: [ + { + legs: Array.from({ length: routeStub.legCount }, () => ({ + distanceMeters: 1000, + duration: "600s", + })), + }, + ], + summary: "", + total_distance: { value: 0, text: "" }, + total_duration: { value: 0, text: "" }, + arrival_time: "", + departure_time: "", + ...(routeStub.optimizedIntermediateWaypointIndex + ? { optimizedIntermediateWaypointIndex: routeStub.optimizedIntermediateWaypointIndex } + : {}), + }); + + return searcher; +} + +test("planRoute handles 2 stops (optimize: true, no intermediates)", async () => { + const searcher = createSearcher({ legCount: 1 }); + const result = await searcher.planRoute({ stops: ["A", "B"] }); + + assert.equal(result.data.optimized, false); // <= 3 stops => no optimization + assert.deepEqual(result.data.stops, ["A (A (formatted))", "B (B (formatted))"]); + assert.equal(result.data.legs.length, 1); +}); + +test("planRoute handles 2 stops (optimize: false)", async () => { + const searcher = createSearcher({ legCount: 1 }); + const result = await searcher.planRoute({ stops: ["A", "B"], optimize: false }); + + assert.equal(result.data.optimized, false); + assert.equal(result.data.legs.length, 1); +}); + +test("planRoute handles 3 stops (optimize: true) — skips optimization (too few intermediates)", async () => { + // Optimization is skipped because it requires at least 4 stops (2 intermediates) + const searcher = createSearcher({ legCount: 2 }); + const result = await searcher.planRoute({ stops: ["A", "B", "C"], optimize: true }); + + assert.equal(result.data.optimized, false); + assert.deepEqual( + result.data.stops.map((s: string) => s.split(" (")[0]), + ["A", "B", "C"] + ); + assert.equal(result.data.legs.length, 2); +}); + +test("planRoute handles 3 stops (optimize: false) — keeps original order", async () => { + const searcher = createSearcher({ legCount: 2 }); + const result = await searcher.planRoute({ stops: ["A", "B", "C"], optimize: false }); + + assert.equal(result.data.optimized, false); + assert.deepEqual( + result.data.stops.map((s: string) => s.split(" (")[0]), + ["A", "B", "C"] + ); +}); + +test("planRoute applies a valid optimized waypoint order (4 stops / 2 intermediates)", async () => { + const searcher = createSearcher({ legCount: 3, optimizedIntermediateWaypointIndex: [1, 0] }); + const result = await searcher.planRoute({ + stops: ["Start", "I0", "I1", "End"], + optimize: true, + }); + + assert.equal(result.data.optimized, true); + assert.deepEqual( + result.data.stops.map((s: string) => s.split(" (")[0]), + ["Start", "I1", "I0", "End"] + ); + assert.equal(result.data.legs.length, 3); +}); diff --git a/tests/smoke.test.ts b/tests/smoke.test.ts index ce32e5a..6b23508 100644 --- a/tests/smoke.test.ts +++ b/tests/smoke.test.ts @@ -1148,6 +1148,94 @@ async function testTransitDetailsField(session: McpSession): Promise { ); } +async function testPlanRoute(session: McpSession): Promise { + console.log("\n🧪 Test 9: Plan route (waypoint optimization thresholds)"); + + if (!API_KEY) { + console.log(" ⏭️ Skipped (no GOOGLE_MAPS_API_KEY)"); + return; + } + + // Case A: 3 stops (1 intermediate) — optimization must be bypassed. + // Regression guard: the Routes API returns an unusable + // optimizedIntermediateWaypointIndex of [-1] for a single intermediate, which + // previously crashed with "Cannot read properties of undefined (reading 'originalName')". + const threeStops = ["Shibuya Station, Tokyo", "Tokyo Tower", "Shinjuku Station, Tokyo"]; + const threeStopResult = await sendRequest(session, "tools/call", { + name: "maps_plan_route", + arguments: { stops: threeStops, mode: "driving", optimize: true }, + }); + + const threeContent = threeStopResult?.result?.content ?? []; + assert(threeContent.length > 0, "plan_route (3 stops) returns content"); + + if (threeContent.length > 0) { + const text = threeContent[0]?.text ?? ""; + const isError = threeStopResult?.result?.isError === true; + + assert(!isError, "plan_route (3 stops, optimize: true) succeeds", `got: ${text.slice(0, 200)}`); + assert(!text.includes("originalName"), "plan_route (3 stops) does not surface an originalName crash"); + + if (!isError) { + let parsed: any; + try { + parsed = JSON.parse(text); + } catch { + assert(false, "plan_route (3 stops) returns valid JSON", `got: ${text.slice(0, 200)}`); + return; + } + + assert(parsed?.optimized === false, "plan_route (3 stops) skips optimization", `optimized: ${parsed?.optimized}`); + assert(parsed?.stops?.length === 3, "plan_route (3 stops) returns 3 stops", `got: ${parsed?.stops?.length}`); + assert(parsed?.legs?.length === 2, "plan_route (3 stops) returns 2 legs", `got: ${parsed?.legs?.length}`); + assert( + threeStops.every((stop, i) => (parsed?.stops?.[i] ?? "").startsWith(stop)), + "plan_route (3 stops) preserves the original stop order", + `got: ${JSON.stringify(parsed?.stops)}` + ); + } + } + + // Case B: 4 stops (2 intermediates) — optimization must engage. + const fourStops = ["Tokyo Station", "Ueno Park", "Asakusa", "Shibuya Crossing"]; + const fourStopResult = await sendRequest(session, "tools/call", { + name: "maps_plan_route", + arguments: { stops: fourStops, mode: "driving", optimize: true }, + }); + + const fourContent = fourStopResult?.result?.content ?? []; + assert(fourContent.length > 0, "plan_route (4 stops) returns content"); + if (fourContent.length === 0) return; + + const fourText = fourContent[0]?.text ?? ""; + const fourIsError = fourStopResult?.result?.isError === true; + assert(!fourIsError, "plan_route (4 stops, optimize: true) succeeds", `got: ${fourText.slice(0, 200)}`); + if (fourIsError) return; + + let fourParsed: any; + try { + fourParsed = JSON.parse(fourText); + } catch { + assert(false, "plan_route (4 stops) returns valid JSON", `got: ${fourText.slice(0, 200)}`); + return; + } + + assert( + fourParsed?.optimized === true, + "plan_route (4 stops) applies waypoint optimization", + `optimized: ${fourParsed?.optimized}` + ); + assert(fourParsed?.stops?.length === 4, "plan_route (4 stops) returns 4 stops", `got: ${fourParsed?.stops?.length}`); + assert(fourParsed?.legs?.length === 3, "plan_route (4 stops) returns 3 legs", `got: ${fourParsed?.legs?.length}`); + // The optimized order is decided by Google, so only assert every stop survived + // the geocode → optimized-order remap (no dropped or undefined entries). + assert( + fourStops.every((stop) => (fourParsed?.stops ?? []).some((s: string) => s.startsWith(stop))), + "plan_route (4 stops) retains every requested stop after optimization", + `got: ${JSON.stringify(fourParsed?.stops)}` + ); +} + // --------------- Main --------------- async function main() { @@ -1173,6 +1261,7 @@ async function main() { await testPlaceDetailsPhotos(session); await testTransitErrorMessages(session); await testTransitDetailsField(session); + await testPlanRoute(session); await testMultiSession(); } catch (err) { console.error("\n💥 Fatal error:", err);