Compare commits

...

1 Commits

Author SHA1 Message Date
Maurice 6c0e39e287 fix(transit): name and record the backend that answered, not always Transitous
With Google as the transit provider, the result list still credited
Transitous, and a route added from the panel or through
create_transit_journey was saved with provider 'transitous'. The panel
now credits "Google Maps" (untranslated, as Google's terms ask) or
Transitous after the backend that answered and saves that one; the MCP
journey records the backend that plans routes for the caller, and
search_transit_routes reports it.
2026-10-27 21:30:18 +01:00
8 changed files with 85 additions and 18 deletions
@@ -138,6 +138,34 @@ describe('TransitSearchPanel', () => {
expect(payload.metadata.transit.legs[1]).toMatchObject({ mode: 'SUBWAY', line: 'U2', line_color: '#FF3300', headsign: 'Ruhleben' })
})
it('FE-PLANNER-TRANSIT-003b: routes Google answered name Google Maps and are saved as Google’s (#2850)', async () => {
const user = userEvent.setup()
const onAdd = vi.fn().mockResolvedValue({})
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY], provider: 'google' })
render(<TransitSearchPanel {...makeProps({ onAdd })} />)
await pickFromAndTo(user)
await user.click(screen.getByRole('button', { name: /^Search$/ }))
await user.click(await screen.findByText(/08:30 – 09:00/))
// Google's terms want the words as written, so the browser must not translate them.
expect(screen.getByText('Google Maps')).toHaveAttribute('translate', 'no')
expect(screen.queryByRole('link', { name: 'Transitous' })).not.toBeInTheDocument()
await user.click(await screen.findByRole('button', { name: 'Add to day' }))
await waitFor(() => expect(onAdd).toHaveBeenCalled())
expect(onAdd.mock.calls[0][0].metadata.transit.provider).toBe('google')
})
it('FE-PLANNER-TRANSIT-003c: routes Transitous answered credit Transitous with its sources link', async () => {
const user = userEvent.setup()
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY], provider: 'transitous' })
render(<TransitSearchPanel {...makeProps()} />)
await pickFromAndTo(user)
await user.click(screen.getByRole('button', { name: /^Search$/ }))
expect(await screen.findByRole('link', { name: 'Transitous' })).toHaveAttribute('href', 'https://transitous.org/sources/')
expect(screen.queryByText('Google Maps')).not.toBeInTheDocument()
})
it('FE-PLANNER-TRANSIT-004: search failure shows the empty state, not a crash', async () => {
const user = userEvent.setup()
transitApiMock.plan.mockRejectedValueOnce(new Error('boom'))
@@ -604,7 +604,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
notes: null,
metadata: {
transit: {
provider: 'transitous',
provider: provider ?? 'transitous',
duration: it.duration,
transfers: it.transfers,
walk_seconds: it.walkSeconds,
@@ -760,7 +760,10 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
))}
<div className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', textAlign: 'center', marginTop: 2 }}>
{t('transit.attribution')}{' '}
<a href="https://transitous.org/sources/" target="_blank" rel="noopener noreferrer" style={{ color: 'inherit', textDecoration: 'underline' }}>Transitous</a>
{/* Named after the backend that answered; Google's terms want "Google Maps" as written, never translated. */}
{provider === 'google'
? <span translate="no">Google Maps</span>
: <a href="https://transitous.org/sources/" target="_blank" rel="noopener noreferrer" style={{ color: 'inherit', textDecoration: 'underline' }}>Transitous</a>}
</div>
</div>
)}
@@ -11,6 +11,7 @@ import { haversineKm } from '../common/geo';
import { localParts, resolveTimeZone } from '../common/timezoneService';
import type { EndpointInput } from '../reservations/reservations.service';
import { deriveTransitStats, type TransitItinerary, type TransitLeg } from './transit.helpers';
import type { TransitProvider } from '@trek/shared';
import { z } from 'zod';
@@ -205,10 +206,12 @@ function timezoneAt(lat: number, lng: number): string {
return timezone;
}
/** `provider` is the backend the itinerary came from, recorded with the journey. */
export function buildTransitReservationParts(
from: TransitPlaceInput,
to: TransitPlaceInput,
itinerary: TransitItinerary,
provider: TransitProvider,
) {
// The same stop appears up to three times below (transfer endpoint + the
// adjacent legs' metadata) — memoise the tz lookup per coordinate pair.
@@ -276,7 +279,7 @@ export function buildTransitReservationParts(
const stats = deriveTransitStats(itinerary.startTime, itinerary.endTime, itinerary.legs, itinerary.transfers);
const metadata = {
transit: {
provider: 'transitous',
provider,
duration: stats.duration,
transfers: stats.transfers,
walk_seconds: stats.walkSeconds,
+5 -3
View File
@@ -110,7 +110,7 @@ export class TransitMcp {
@Tool({
name: 'search_transit_routes',
description:
'Search scheduled public-transit routes between two coordinates, via whichever backend the instance is configured for. Returns itineraries that can be passed unchanged to create_transit_journey. `dropped` counts provider itineraries that failed validation and are therefore absent from the results — a non-zero value means the provider offered routes this tool could not represent.',
'Search scheduled public-transit routes between two coordinates, via whichever backend the instance is configured for. Returns itineraries that can be passed unchanged to create_transit_journey, and `provider`, the backend that answered. `dropped` counts provider itineraries that failed validation and are therefore absent from the results — a non-zero value means the provider offered routes this tool could not represent.',
inputSchema: {
from: transitPlaceInput,
to: transitPlaceInput,
@@ -169,7 +169,7 @@ export class TransitMcp {
// A rejected itinerary is provider data we could not vouch for, but dropping it
// silently is indistinguishable from "no routes exist" — report the count so the
// caller knows the difference.
return ok({ itineraries, dropped: result.itineraries.length - itineraries.length });
return ok({ itineraries, dropped: result.itineraries.length - itineraries.length, provider: result.provider });
} catch (err) {
return errorResult(err, 'Transit route search failed.');
}
@@ -228,9 +228,11 @@ export class TransitMcp {
isError: true,
};
}
// The itinerary came from search_transit_routes, which the active backend answered.
const provider = await this.transit.activeProvider(ctx.userId);
let reservationParts: ReturnType<typeof buildTransitReservationParts>;
try {
reservationParts = buildTransitReservationParts(from, to, cleaned);
reservationParts = buildTransitReservationParts(from, to, cleaned, provider);
} catch (err) {
return errorResult(err, 'Unable to resolve the transit journey timezones.');
}
+7 -2
View File
@@ -159,6 +159,11 @@ export class TransitService {
@Inject(transitConfig.KEY) private readonly transitEnv: ConfigType<typeof transitConfig>,
) {}
/** The backend that answers this user's searches: Google where it is set up and keyed, Transitous otherwise. */
async activeProvider(userId = 0): Promise<TransitProvider> {
return (await this.google.isActive(userId)) ? 'google' : 'transitous';
}
/**
* Station/place search for the from/to pickers. `near` biases results.
*
@@ -175,7 +180,7 @@ export class TransitService {
const text = (query || '').trim();
// Answered before either backend is consulted, so it reports the one that
// WOULD have been asked rather than claiming nobody was.
const provider: TransitProvider = (await this.google.isActive(userId)) ? 'google' : 'transitous';
const provider = await this.activeProvider(userId);
if (text.length < 2) return { results: [], provider };
if (text.length > 200) {
const e = new Error('Query too long') as Error & { status: number };
@@ -259,7 +264,7 @@ export class TransitService {
// After validation on purpose: whichever backend answers, the caller is held
// to the same coordinate/mode/transfer contract and gets the same 400s.
if (await this.google.isActive(userId)) {
if ((await this.activeProvider(userId)) === 'google') {
return { ...(await this.google.plan(q, language, userId)), provider: 'google' };
}
@@ -201,6 +201,7 @@ describe('MCP transit tools', () => {
expect(routes.itineraries[0].legs[0].from.name).toBe('Namba');
expect(routes.itineraries[0].legs[1].to.name).toBe('Umeda');
expect(routes.itineraries).toHaveLength(1);
expect(routes.provider).toBe('transitous');
expect(planMock).toHaveBeenCalledWith(expect.objectContaining({ modes: 'SUBWAY' }), undefined, user.id);
const invalidNear = await harness.client.callTool({
@@ -244,6 +245,27 @@ describe('MCP transit tools', () => {
});
});
it('records Google as the provider of a journey planned while Google answers searches (#2850)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2026-12-03', end_date: '2026-12-04' });
const day = await dayOf(trip.id, '2026-12-03');
const active = vi.spyOn(TransitService.prototype, 'activeProvider').mockResolvedValue('google');
try {
await withHarness(user.id, ['reservations:write'], async (harness) => {
const result = parseToolResult(
await harness.client.callTool({
name: 'create_transit_journey',
arguments: { tripId: trip.id, dayId: day.id, from, to, itinerary },
}),
) as { reservation: { metadata: string } };
expect(JSON.parse(result.reservation.metadata).transit.provider).toBe('google');
});
expect(active).toHaveBeenCalledWith(user.id);
} finally {
active.mockRestore();
}
});
it('rejects dateless, mismatched, and out-of-range journey dates', async () => {
const { user } = createUser(testDb);
const datelessTrip = createTrip(testDb, user.id);
@@ -311,7 +311,7 @@ describe('buildTransitReservationParts', () => {
],
};
const { endpoints, metadata } = buildTransitReservationParts(namba, kyoto, itinerary);
const { endpoints, metadata } = buildTransitReservationParts(namba, kyoto, itinerary, 'transitous');
expect(endpoints).toEqual([
{
@@ -407,7 +407,7 @@ describe('buildTransitReservationParts', () => {
const kyoto = { name: 'Kyoto', lat: 35.0116, lng: 135.7681 };
const itinerary = twoLegs();
itinerary.legs = [{ ...itinerary.legs[0], to: stop('Umeda', 34.702, 135.496, null) }, itinerary.legs[1]];
const { endpoints, metadata } = buildTransitReservationParts(namba, kyoto, itinerary);
const { endpoints, metadata } = buildTransitReservationParts(namba, kyoto, itinerary, 'transitous');
expect(endpoints[1]).toEqual({
role: 'stop',
sequence: 1,
@@ -423,14 +423,18 @@ describe('buildTransitReservationParts', () => {
});
it('TRANSIT-ITIN-020: an unresolvable endpoint timezone throws with the coordinates in the message', () => {
expect(() => buildTransitReservationParts({ name: 'Nowhere', lat: NaN, lng: 135.501 }, umeda, singleLeg())).toThrow(
'Unable to resolve timezone for NaN,135.501.',
);
expect(() =>
buildTransitReservationParts({ name: 'Nowhere', lat: NaN, lng: 135.501 }, umeda, singleLeg(), 'transitous'),
).toThrow('Unable to resolve timezone for NaN,135.501.');
});
it('TRANSIT-ITIN-021: an unconvertible itinerary time throws with the ISO string and timezone', () => {
expect(() => buildTransitReservationParts(namba, umeda, singleLeg({ startTime: 'not-a-date' }))).toThrow(
'Unable to convert not-a-date to local time in Asia/Tokyo.',
);
expect(() =>
buildTransitReservationParts(namba, umeda, singleLeg({ startTime: 'not-a-date' }), 'transitous'),
).toThrow('Unable to convert not-a-date to local time in Asia/Tokyo.');
});
it('TRANSIT-ITIN-022: the journey records the backend its itinerary came from (#2850)', () => {
expect(buildTransitReservationParts(namba, umeda, singleLeg(), 'google').metadata.transit.provider).toBe('google');
});
});
+2 -2
View File
@@ -133,8 +133,8 @@ Transit search is powered by Transitous and uses the existing `geo:read` and `re
| Tool | Scope required | Description |
|---|---|---|
| `search_transit_stops` | `geo:read` | Search real public-transit stops and stations, optionally biased around coordinates. |
| `search_transit_routes` | `geo:read` | Search scheduled routes between two coordinates with time, mode, and transfer filters. Also returns `dropped`, the number of provider itineraries that failed validation and are absent from the results. |
| `create_transit_journey` | `reservations:write` | Save a selected route as a first-class automated transit journey on a trip day. |
| `search_transit_routes` | `geo:read` | Search scheduled routes between two coordinates with time, mode, and transfer filters. Also returns `provider` (`transitous` or `google`, the backend that answered) and `dropped`, the number of provider itineraries that failed validation and are absent from the results. |
| `create_transit_journey` | `reservations:write` | Save a selected route as a first-class automated transit journey on a trip day, recording the backend that plans routes for the caller as its provider. |
### Reservations