Compare commits

...

8 Commits

Author SHA1 Message Date
Maurice 6741e8ca0d fix(help): give the close button a row of its own
A wider gutter left the button floating in the corner over the first card and the scrollbar. It now sits in its own row above the scrolling content, level with the Help title, and the content keeps symmetric padding.
2026-10-28 10:24:08 +01:00
Maurice c14f179e39 fix(help): keep the help center's content clear of its close button
The close button floats over the scrolling content, which only kept a 40px gutter on the end side while the button reaches 52px in, so the first card ran right up to it. The content now keeps a wider gutter on that side.
2026-10-28 10:24:08 +01:00
Maurice 3bfef73a46 refactor(maps): move the place clusters out of MapViewGL
The cluster source, its layers, the expansion zoom and the DOM bubbles
now live in placeClusterLayers.ts with their own tests, which brings
MapViewGL back under its size baseline instead of growing it. Lowers the
size and eslint baselines that this change made smaller.
2026-10-28 10:11:56 +01:00
Maurice 7e790c3fb7 fix(maps): draw the GL cluster bubbles like the Leaflet ones
The MapLibre and Mapbox maps painted their place clusters as a canvas
circle plus a symbol layer, so the count used whatever glyphs the map
style ships and the bubble had no hover. They are now DOM pins built by
the same markup and CSS as the Leaflet bubbles, with the same sizes,
font and hover scale. The canvas layers stay, invisible, as the hit
area the track and route layers check a click against.
2026-10-28 10:11:56 +01:00
Maurice 228db8541c chore(notices): show 5.0.1 in the release popup
The release popup still named 5.0.0 and linked the 5.0.0 release notes. It now names 5.0.1 and links its tag.
2026-10-28 09:52:18 +01:00
Maurice a729a6935f fix(journey): keep the photo picker's calendar inside the screen
The date picker of the external photo picker hung absolutely under its
trigger, inside a dialog that clips its content. The last week of a
six-week month fell below the dialog, and for the end date in the
right-hand column the calendar opened past the dialog's right edge, so
the last weekday and the next-month arrow could not be reached.

The calendar now opens in a portal, pinned to its trigger through
useAnchoredPosition: it flips above the trigger when below is too short,
scrolls when neither side holds it, and is kept inside the viewport
sideways.

Closes #2858
2026-10-28 09:25:53 +01:00
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
Maurice 22574a2270 fix(assistant): send Gemini's thought signatures back with its tool calls
Gemini 3 signs a tool call on its OpenAI-compatible endpoint
(extra_content.google.thought_signature) and refuses the next request
with a 400 when the call comes back without it, so every turn in which
the model called a tool failed. The Chat Completions client now keeps
the signature per call as the step's provider state and sends it back
with the call, to the model that signed it only.

An own server entered with Gemini's endpoint now also gets Gemini's
narrower tool schemas, which it refused before.
2026-10-27 21:06:29 +01:00
23 changed files with 648 additions and 146 deletions
+1 -1
View File
@@ -30,7 +30,7 @@
"no-useless-escape": 1
},
"suppressed": {
"@typescript-eslint/no-explicit-any": 40,
"@typescript-eslint/no-explicit-any": 38,
"react-hooks/exhaustive-deps": 46
},
"tests": {
+1 -1
View File
@@ -7,7 +7,7 @@
"src/components/Budget/CostsPanel.tsx": 1798,
"src/components/Map/MapView.tsx": 1385,
"src/components/Map/MapViewGL.test.tsx": 2919,
"src/components/Map/MapViewGL.tsx": 2290,
"src/components/Map/MapViewGL.tsx": 2218,
"src/components/Map/RouteCalculator.ts": 1063,
"src/components/PDF/TripPDF.tsx": 1082,
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
+13 -9
View File
@@ -102,16 +102,20 @@ export default function HelpPanel(): React.ReactElement | null {
<HelpNav context={context} currentId={contextId} activeGuideId={!searching && guide ? guide.id : null} />
<div className="relative flex flex-col min-h-0 bg-surface" role="main">
<button
type="button"
onClick={closeHelp}
aria-label={t('help.center.close')}
className="absolute top-4 end-4 z-10 w-9 h-9 rounded-full flex items-center justify-center text-content-muted bg-surface-card border border-edge shadow-card hover:text-content hover:bg-surface-hover transition-colors"
>
<X className="w-4 h-4" />
</button>
{/* The close button sits in a row of its own above the scrolling content rather
than floating over it, so no card ever runs up against it. */}
<div className="flex shrink-0 justify-end px-4 pt-4">
<button
type="button"
onClick={closeHelp}
aria-label={t('help.center.close')}
className="w-9 h-9 rounded-full flex items-center justify-center text-content-muted bg-surface-card border border-edge shadow-card hover:text-content hover:bg-surface-hover transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
<div ref={scrollRef} className="trek-help-scroll flex-1 min-h-0 overflow-y-auto overscroll-contain">
<div className="max-w-[1080px] mx-auto px-6 sm:px-10 py-8">
<div className="max-w-[1080px] mx-auto px-6 sm:px-10 pt-4 pb-8">
{searching ? (
<HelpSearchResults key="search" />
) : guide ? (
@@ -1,4 +1,4 @@
// FE-COMP-JOURNEYDATEPICKER-001 to FE-COMP-JOURNEYDATEPICKER-007
// FE-COMP-JOURNEYDATEPICKER-001 to FE-COMP-JOURNEYDATEPICKER-008
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '../../../tests/helpers/render'
@@ -119,3 +119,28 @@ describe('DatePicker week start (#2029)', () => {
}
})
})
describe('DatePicker inside a clipping dialog (#2858)', () => {
it('FE-COMP-JOURNEYDATEPICKER-008: the calendar floats over the page, stays inside the viewport and closes on a click away', async () => {
const user = userEvent.setup()
const { container } = render(
<div style={{ overflow: 'hidden' }}>
<DatePicker value="2026-03-15" onChange={vi.fn()} />
</div>,
)
const trigger = screen.getByText(/Mar 15, 2026/).closest('button') as HTMLButtonElement
// A trigger at the right edge, where the end date of a range sits.
trigger.getBoundingClientRect = () => ({ top: 100, bottom: 136, left: window.innerWidth - 120, right: window.innerWidth - 8, width: 112, height: 36, x: 0, y: 0, toJSON: () => ({}) })
await user.click(trigger)
const day31 = screen.getByRole('button', { name: '31' })
// Rendered outside the dialog that would clip it.
expect(container.contains(day31)).toBe(false)
const panel = day31.closest('.fixed') as HTMLElement
expect(panel.style.top).toBe('140px')
expect(parseFloat(panel.style.left)).toBe(window.innerWidth - 280 - 8)
await user.click(document.querySelector('[role="presentation"]') as HTMLElement)
expect(screen.queryByRole('button', { name: '31' })).toBeNull()
})
})
@@ -1,9 +1,11 @@
import { useState } from 'react'
import { useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { localIsoDate } from '../../utils/localDate'
import { ArrowLeft, ChevronRight, Calendar } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useWeekStartDay } from '../../hooks/useWeekStartDay'
import { leadingBlanks, weekdayLabels } from '../../utils/calendarWeek'
import { useAnchoredPosition } from '../../hooks/useAnchoredPosition'
/**
* The header steps up a level on each click, days → months → years, and a
@@ -14,6 +16,9 @@ import { leadingBlanks, weekdayLabels } from '../../utils/calendarWeek'
*/
type CalendarView = 'days' | 'months' | 'years'
const YEAR_PAGE_SIZE = 12
/** The calendar's width; `w-[280px]` below. */
const PANEL_WIDTH = 280
const VIEWPORT_PAD = 8
export function DatePicker({ value, onChange, tripDates }: {
value: string
@@ -23,6 +28,13 @@ export function DatePicker({ value, onChange, tripDates }: {
const { t, locale } = useTranslation()
const weekStart = useWeekStartDay()
const [open, setOpen] = useState(false)
const triggerRef = useRef<HTMLButtonElement>(null)
// The calendar used to hang absolutely under its trigger, inside dialogs that clip
// their content: the photo picker cut off the last week and, for the end date in
// the right-hand column, the last weekday (#2858). It now floats over the page,
// pinned to the trigger, flipped above it when below is too short and kept inside
// the viewport sideways.
const anchored = useAnchoredPosition(triggerRef, open, { estimatedHeight: 340, matchWidth: false })
const [view, setView] = useState<CalendarView>('days')
const [yearPageStart, setYearPageStart] = useState(0)
const [viewMonth, setViewMonth] = useState(() => {
@@ -107,6 +119,7 @@ export function DatePicker({ value, onChange, tripDates }: {
return (
<div className="relative">
<button
ref={triggerRef}
type="button"
onClick={toggleOpen}
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white text-start flex items-center justify-between"
@@ -122,12 +135,20 @@ export function DatePicker({ value, onChange, tripDates }: {
<Calendar size={13} className="text-zinc-400" />
</button>
{open && (
{open && anchored && createPortal(
<>
{/* Click-away catcher — no semantics of its own; the trigger button
above closes the popover again from the keyboard. */}
<div role="presentation" className="fixed inset-0 z-[10]" onClick={() => setOpen(false)} />
<div className="absolute top-full start-0 mt-1 z-[20] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg p-3 w-[280px]">
<div role="presentation" className="fixed inset-0 z-[var(--z-toast)]" onClick={() => setOpen(false)} />
<div
className="fixed z-[var(--z-toast)] overflow-y-auto bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg p-3 w-[280px]"
style={{
top: anchored.top,
bottom: anchored.bottom,
left: Math.max(VIEWPORT_PAD, Math.min(anchored.left, window.innerWidth - PANEL_WIDTH - VIEWPORT_PAD)),
maxHeight: anchored.maxHeight,
}}
>
{/* Header: arrows step the current view, the label climbs to the next one up */}
<div className="flex items-center justify-between mb-2">
<button type="button" onClick={handlePrev} aria-label={prevLabel} className={navButton}>
@@ -200,7 +221,7 @@ export function DatePicker({ value, onChange, tripDates }: {
>
{day}
{isTrip && !isSelected && (
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-indigo-500" />
<span className="absolute bottom-1 inset-x-0 mx-auto w-1 h-1 rounded-full bg-indigo-500" />
)}
</button>
)
@@ -209,7 +230,8 @@ export function DatePicker({ value, onChange, tripDates }: {
</>
)}
</div>
</>
</>,
document.body,
)}
</div>
)
@@ -375,11 +375,12 @@ describe('ProviderPicker', () => {
await user.click(screen.getByRole('button', { name: 'Search' }))
expect(ranges).toHaveLength(0)
// The calendar opens in a portal, so its days are looked up on the page, not under the trigger.
const [fromPicker, toPicker] = Array.from(baseElement.querySelectorAll('.flex-1 > .relative')) as HTMLElement[]
await user.click(within(fromPicker).getByRole('button'))
await user.click(within(fromPicker).getByRole('button', { name: '3' }))
await user.click(screen.getByRole('button', { name: '3' }))
await user.click(within(toPicker).getByRole('button'))
await user.click(within(toPicker).getByRole('button', { name: '9' }))
await user.click(screen.getByRole('button', { name: '9' }))
await user.click(screen.getByRole('button', { name: 'Search' }))
await waitFor(() => expect(ranges).toHaveLength(1))
+18 -91
View File
@@ -50,16 +50,10 @@ import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
import { alongShortestArc, computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
import { selectedPlaceTarget } from './selectedPlaceTarget'
import { PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID } from './placeClusterLayers'
// Marker grouping for the GL map (#1385): MapLibre/Mapbox can't show the rich
// HTML photo markers *and* cluster them natively, so we feed the place points
// into a clustered GeoJSON source. The cluster bubbles render as GL circles +
// a count label; the individual rich HTML markers are then only drawn for the
// points the source reports as currently unclustered. Grouping is always on,
// matching the Leaflet map's MarkerClusterGroup.
const PLACE_CLUSTER_SOURCE_ID = 'trip-place-clusters'
const PLACE_UNCLUSTERED_LAYER_ID = 'trip-place-unclustered-hit'
import {
PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID, PLACE_CLUSTER_SOURCE_ID,
addPlaceClusterLayers, drawClusterBubbles, expandCluster,
} from './placeClusterLayers'
type PlaceWithCoords = Place & { lat: number; lng: number }
@@ -112,72 +106,6 @@ interface Props extends MapViewProps {
gl: any
}
/**
* How eagerly place markers merge into a cluster.
*
* The overview band the Leaflet map clusters in as well (`CLUSTER_UNTIL_ZOOM` there),
* kept as plain numbers rather than shared constants so the GL bundle does not have to
* carry Leaflet for them. Above the cutoff supercluster hands every point back on its
* own, and the stops that share a coordinate are folded together at the pin level
* instead — see the reconcile below.
*/
const CLUSTER_RADIUS = 20
const CLUSTER_MAX_ZOOM = 8
/** The cluster source and the three layers over it. Extracted so the rebuild on a mode
* change and the initial build cannot drift apart. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function addPlaceClusterLayers(map: any): void {
map.addSource(PLACE_CLUSTER_SOURCE_ID, {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
cluster: true,
clusterRadius: CLUSTER_RADIUS,
clusterMaxZoom: CLUSTER_MAX_ZOOM,
})
map.addLayer({
id: PLACE_CLUSTER_CIRCLE_LAYER_ID,
type: 'circle',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['has', 'point_count'],
paint: {
'circle-color': '#111827',
'circle-opacity': 0.97,
'circle-radius': ['step', ['get', 'point_count'], 18, 10, 21, 50, 24],
'circle-stroke-width': 2.5,
'circle-stroke-color': 'rgba(255,255,255,0.9)',
},
})
map.addLayer({
id: PLACE_CLUSTER_COUNT_LAYER_ID,
type: 'symbol',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['has', 'point_count'],
layout: {
'text-field': ['get', 'point_count_abbreviated'],
'text-size': 12,
'text-allow-overlap': true,
},
paint: {
'text-color': '#ffffff',
'text-halo-color': 'rgba(17,24,39,0.35)',
'text-halo-width': 1,
},
})
map.addLayer({
id: PLACE_UNCLUSTERED_LAYER_ID,
type: 'circle',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['!', ['has', 'point_count']],
paint: {
'circle-radius': 24,
'circle-opacity': 0,
'circle-stroke-opacity': 0,
},
})
}
function createMarkerElement(place: Place & PlaceMarkerFlags & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
// A stop that interrupts the drive gets its own small disc, decided before the photo
// branch: the brand logo a fuel search comes back with is exactly what this replaces.
@@ -612,6 +540,8 @@ export function MapViewGL({
useDawarichTrailGL(mapRef.current, mapReady, dawarichTrack, dawarichSelectedDate, 'trip-route-casing', dawarichHiddenDates)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const markersRef = useRef<Map<number, PlacePin>>(new Map())
/** The count bubbles, one per cluster the source currently reports. */
const clusterPinsRef = useRef<PlacePin[]>([])
// Own layer for the hand-positioned place pins (MapLibre path, see makePlacePin).
const pinLayerRef = useRef<HTMLDivElement | null>(null)
const locationMarkerRef = useRef<LocationMarkerHandle | null>(null)
@@ -642,6 +572,7 @@ export function MapViewGL({
// in the same frame the canvas draws, or the ones left out swim against the ones in.
const repositionPins = useCallback(() => {
markersRef.current.forEach(pin => pin.reposition())
clusterPinsRef.current.forEach(pin => pin.reposition())
poiMarkersRef.current.forEach(pin => pin.reposition())
pluginMarkersRef.current.forEach(pin => pin.reposition())
routeViaMarkersRef.current.forEach(pin => pin.reposition())
@@ -1304,21 +1235,7 @@ export function MapViewGL({
? map.queryRenderedFeatures(e.point, { layers: [PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID] })
: []
const feature = features?.[0]
const clusterId = feature?.properties?.cluster_id
const coordinates = feature?.geometry?.coordinates
if (clusterId == null || !Array.isArray(coordinates)) return
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const source = map.getSource(PLACE_CLUSTER_SOURCE_ID) as any
const easeToZoom = (nextZoom: number) => {
try { map.easeTo({ center: coordinates, zoom: nextZoom, duration: 350 }) } catch { /* noop */ }
}
try {
const maybeZoom = source?.getClusterExpansionZoom?.(clusterId, (err: Error | null, nextZoom: number) => {
if (!err && typeof nextZoom === 'number') easeToZoom(nextZoom)
})
if (typeof maybeZoom === 'number') easeToZoom(maybeZoom)
else if (maybeZoom && typeof maybeZoom.then === 'function') maybeZoom.then(easeToZoom).catch(() => {})
} catch { /* noop */ }
expandCluster(map, feature?.properties?.cluster_id, feature?.geometry?.coordinates)
}
const setClusterCursor = () => {
const canvas = typeof map.getCanvas === 'function' ? map.getCanvas() : null
@@ -1704,8 +1621,17 @@ export function MapViewGL({
source.setData(buildPlaceClusterData(places) as any)
const placesById = new Map<number, PlaceWithCoords>(validPlaces.map(place => [place.id, place]))
let raf: number | null = null
const dropClusterPins = () => {
clusterPinsRef.current.forEach(pin => pin.remove())
clusterPinsRef.current = []
}
const reconcileClusterPins = () => {
dropClusterPins()
clusterPinsRef.current = drawClusterBubbles(map, (el, lng, lat) => attachPin(map, gl, pinLayerRef.current, el, lng, lat))
}
const runReconcile = () => {
raf = null
reconcileClusterPins()
const features = map.querySourceFeatures(PLACE_CLUSTER_SOURCE_ID, { filter: ['!', ['has', 'point_count']] }) || []
const seen = new Set<number>()
const visiblePlaces: PlaceWithCoords[] = []
@@ -1752,6 +1678,7 @@ export function MapViewGL({
return () => {
if (raf !== null) cancelAnimationFrame(raf)
dropClusterPins()
map.off('moveend', scheduleReconcile)
map.off('zoomend', scheduleReconcile)
map.off('idle', scheduleReconcile)
+2 -1
View File
@@ -1,5 +1,6 @@
import L from 'leaflet'
import { STACK_RADIUS_PX } from './coincidentPlaces'
import { clusterBubbleSize } from './placeClusterLayers'
/** Up to here the map is an overview, where stops merge to keep the world readable. */
export const CLUSTER_UNTIL_ZOOM = 9
@@ -66,7 +67,7 @@ export interface ClusterLike {
/** A count bubble sized by its count, styled by `.marker-cluster-custom` in index.css. */
export function createClusterIcon(cluster: ClusterLike) {
const count = cluster.getChildCount()
const size = count < 10 ? 36 : count < 50 ? 42 : 48
const size = clusterBubbleSize(count)
return L.divIcon({
html: `<div class="marker-cluster-custom" style="width:${size}px;height:${size}px;"><span>${count}</span></div>`,
className: 'marker-cluster-wrapper',
@@ -0,0 +1,86 @@
import { describe, expect, it, vi } from 'vitest';
import {
type ClusterMap,
PLACE_CLUSTER_SOURCE_ID,
addPlaceClusterLayers,
clusterBubbleSize,
createClusterBubble,
drawClusterBubbles,
} from './placeClusterLayers';
function fakeMap(features: unknown[], expansionZoom: unknown = 11) {
const source = { getClusterExpansionZoom: vi.fn(() => expansionZoom) };
const map = {
addSource: vi.fn(),
addLayer: vi.fn(),
getSource: vi.fn((id: string) => (id === PLACE_CLUSTER_SOURCE_ID ? source : undefined)),
easeTo: vi.fn(),
querySourceFeatures: vi.fn((_id: string, query: { filter: unknown[] }) =>
query.filter[0] === 'has' ? features : []
),
};
return { map: map as unknown as ClusterMap & typeof map, source };
}
describe('cluster bubbles', () => {
it('FE-COMP-PLACECLUSTER-001: a bubble grows in three steps with its count', () => {
expect([1, 9, 10, 49, 50, 500].map(clusterBubbleSize)).toEqual([36, 36, 42, 42, 48, 48]);
});
it('FE-COMP-PLACECLUSTER-002: the GL bubble is the Leaflet markup inside a bare wrapper the pin can move', () => {
const wrapper = createClusterBubble(38);
expect(wrapper.className).toBe('');
const bubble = wrapper.firstElementChild as HTMLElement;
expect(bubble.className).toBe('marker-cluster-custom');
expect(bubble.style.width).toBe('42px');
expect(bubble.style.height).toBe('42px');
expect(bubble.textContent).toBe('38');
});
it('FE-COMP-PLACECLUSTER-003: the canvas circle and count stay as an invisible hit area', () => {
const { map } = fakeMap([]);
addPlaceClusterLayers(map);
expect(map.addSource).toHaveBeenCalledWith(PLACE_CLUSTER_SOURCE_ID, expect.objectContaining({ cluster: true }));
const layers = map.addLayer.mock.calls.map(([layer]) => layer as { id: string; paint: Record<string, unknown> });
expect(layers.find((l) => l.id === 'trip-place-clusters-circle')?.paint['circle-opacity']).toBe(0);
expect(layers.find((l) => l.id === 'trip-place-clusters-count')?.paint['text-opacity']).toBe(0);
});
it('FE-COMP-PLACECLUSTER-004: one bubble per cluster, placed at its centre, that zooms in on a click', () => {
const cluster = { properties: { cluster_id: 5, point_count: 12 }, geometry: { coordinates: [139.7, 35.6] } };
const { map, source } = fakeMap([
cluster,
cluster, // the same cluster crossing a tile edge
{ properties: { cluster_id: 6 }, geometry: { coordinates: [1, 1] } }, // no count
{ properties: { cluster_id: 7, point_count: 3 } }, // no geometry
]);
const placed: { el: HTMLElement; lng: number; lat: number }[] = [];
const pins = drawClusterBubbles(map, (el, lng, lat) => {
placed.push({ el, lng, lat });
return lng;
});
expect(pins).toEqual([139.7]);
expect(placed[0].el.textContent).toBe('12');
expect(placed[0].lat).toBe(35.6);
const onMapClick = vi.fn();
document.body.appendChild(placed[0].el);
document.body.addEventListener('click', onMapClick);
placed[0].el.dispatchEvent(new MouseEvent('click', { bubbles: true }));
document.body.removeEventListener('click', onMapClick);
placed[0].el.remove();
// The click is the bubble's: the map underneath never sees it.
expect(onMapClick).not.toHaveBeenCalled();
expect(source.getClusterExpansionZoom).toHaveBeenCalledWith(5, expect.any(Function));
expect(map.easeTo).toHaveBeenCalledWith({ center: [139.7, 35.6], zoom: 11, duration: 350 });
});
it('FE-COMP-PLACECLUSTER-005: a source that reports nothing draws nothing', () => {
const { map } = fakeMap([]);
map.querySourceFeatures.mockReturnValue(null as never);
expect(drawClusterBubbles(map, () => 1)).toEqual([]);
});
});
@@ -5,3 +5,169 @@
*/
export const PLACE_CLUSTER_CIRCLE_LAYER_ID = 'trip-place-clusters-circle';
export const PLACE_CLUSTER_COUNT_LAYER_ID = 'trip-place-clusters-count';
// Marker grouping for the GL map (#1385): MapLibre/Mapbox can't show the rich
// HTML photo markers *and* cluster them natively, so we feed the place points
// into a clustered GeoJSON source. The source decides what is clustered; the
// bubbles are drawn as DOM (`drawClusterBubbles`) and the individual rich HTML
// markers only for the points the source reports as currently unclustered.
// Grouping is always on, matching the Leaflet map's MarkerClusterGroup.
export const PLACE_CLUSTER_SOURCE_ID = 'trip-place-clusters';
const PLACE_UNCLUSTERED_LAYER_ID = 'trip-place-unclustered-hit';
/**
* How eagerly place markers merge into a cluster.
*
* The overview band the Leaflet map clusters in as well (`CLUSTER_UNTIL_ZOOM` there),
* kept as plain numbers rather than shared constants so the GL bundle does not have to
* carry Leaflet for them. Above the cutoff supercluster hands every point back on its
* own, and the stops that share a coordinate are folded together at the pin level
* instead — see the reconcile in MapViewGL.
*/
const CLUSTER_RADIUS = 20;
const CLUSTER_MAX_ZOOM = 8;
/** The part of either SDK's map the cluster code calls. */
export interface ClusterMap {
addSource: (id: string, source: object) => void;
addLayer: (layer: object) => void;
getSource: (id: string) => unknown;
easeTo: (options: { center: unknown[]; zoom: number; duration: number }) => void;
querySourceFeatures: (id: string, options: { filter: unknown[] }) => ClusterFeature[] | null | undefined;
}
interface ClusterFeature {
properties?: { cluster_id?: unknown; point_count?: unknown } | null;
geometry?: { coordinates?: unknown };
}
interface ClusterSource {
getClusterExpansionZoom?: (id: unknown, done: (err: Error | null, zoom: number) => void) => unknown;
}
/** The cluster source and the three layers over it. Extracted so the rebuild on a mode
* change and the initial build cannot drift apart. */
export function addPlaceClusterLayers(map: Pick<ClusterMap, 'addSource' | 'addLayer'>): void {
map.addSource(PLACE_CLUSTER_SOURCE_ID, {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
cluster: true,
clusterRadius: CLUSTER_RADIUS,
clusterMaxZoom: CLUSTER_MAX_ZOOM,
});
// Invisible: the bubble the user sees is DOM (createClusterBubble), these two
// layers stay as the hit area the track and route layers check a click against.
map.addLayer({
id: PLACE_CLUSTER_CIRCLE_LAYER_ID,
type: 'circle',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['has', 'point_count'],
paint: {
'circle-opacity': 0,
'circle-stroke-opacity': 0,
'circle-radius': ['step', ['get', 'point_count'], 18, 10, 21, 50, 24],
},
});
map.addLayer({
id: PLACE_CLUSTER_COUNT_LAYER_ID,
type: 'symbol',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['has', 'point_count'],
layout: {
'text-field': ['get', 'point_count_abbreviated'],
'text-size': 12,
'text-allow-overlap': true,
},
paint: { 'text-opacity': 0 },
});
map.addLayer({
id: PLACE_UNCLUSTERED_LAYER_ID,
type: 'circle',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['!', ['has', 'point_count']],
paint: {
'circle-radius': 24,
'circle-opacity': 0,
'circle-stroke-opacity': 0,
},
});
}
/** Eases the map in to the zoom a cluster falls apart at. Both SDKs answer that
* question, one through a callback and one through a promise. */
export function expandCluster(
map: Pick<ClusterMap, 'getSource' | 'easeTo'>,
clusterId: unknown,
coordinates: unknown
): void {
if (clusterId == null || !Array.isArray(coordinates)) return;
const source = map.getSource(PLACE_CLUSTER_SOURCE_ID) as ClusterSource | undefined;
const easeToZoom = (nextZoom: number) => {
try {
map.easeTo({ center: coordinates, zoom: nextZoom, duration: 350 });
} catch {
/* noop */
}
};
try {
const maybeZoom = source?.getClusterExpansionZoom?.(clusterId, (err, nextZoom) => {
if (!err && typeof nextZoom === 'number') easeToZoom(nextZoom);
});
if (typeof maybeZoom === 'number') easeToZoom(maybeZoom);
else if (maybeZoom instanceof Promise) maybeZoom.then(easeToZoom).catch(() => {});
} catch {
/* noop */
}
}
/**
* Puts one count bubble on the map per cluster the source currently reports, through
* `place` (the caller's pin factory), and returns what `place` handed back. A cluster
* crossing a tile edge comes back once per tile, so bubbles are keyed by cluster id.
*/
export function drawClusterBubbles<T>(map: ClusterMap, place: (el: HTMLElement, lng: number, lat: number) => T): T[] {
const pins: T[] = [];
const seen = new Set<unknown>();
for (const feature of map.querySourceFeatures(PLACE_CLUSTER_SOURCE_ID, { filter: ['has', 'point_count'] }) ?? []) {
const clusterId = feature?.properties?.cluster_id;
const count = feature?.properties?.point_count;
const coordinates = feature?.geometry?.coordinates;
if (typeof count !== 'number' || !Array.isArray(coordinates) || seen.has(clusterId)) continue;
seen.add(clusterId);
const el = createClusterBubble(count);
el.addEventListener('click', (ev) => {
ev.stopPropagation();
expandCluster(map, clusterId, coordinates);
});
pins.push(place(el, coordinates[0], coordinates[1]));
}
return pins;
}
/** A count bubble's diameter in pixels: both renderers size their bubbles by it. */
export function clusterBubbleSize(count: number): number {
return count < 10 ? 36 : count < 50 ? 42 : 48;
}
/**
* A count bubble as DOM, styled by `.marker-cluster-custom` in index.css.
*
* The GL map draws its bubbles with this element rather than as canvas text, so they
* read in the app's own font and get the same hover the Leaflet map has. A canvas
* symbol layer can only use the glyphs the map style ships and knows no `:hover`.
* The bubble sits in a bare wrapper because the pin positions its element through
* `transform`, which would otherwise override the hover's scale.
*/
export function createClusterBubble(count: number): HTMLDivElement {
const size = clusterBubbleSize(count);
const bubble = document.createElement('div');
bubble.className = 'marker-cluster-custom';
bubble.style.width = `${size}px`;
bubble.style.height = `${size}px`;
const label = document.createElement('span');
label.textContent = String(count);
bubble.appendChild(label);
const wrapper = document.createElement('div');
wrapper.appendChild(bubble);
return wrapper;
}
@@ -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>
)}
+6
View File
@@ -375,6 +375,12 @@ const aiProviderStateSchema = z.discriminatedUnion('adapter', [
adapter: z.literal('anthropic-messages'),
blocks: z.array(z.record(z.string(), z.unknown())),
}),
// Gemini's thought signature per tool call id, which its Chat Completions endpoint wants back with the call.
z.object({
adapter: z.literal('openai-chat'),
model: z.string(),
signatures: z.record(z.string(), z.string()),
}),
]);
export type AiProviderState = z.infer<typeof aiProviderStateSchema>;
@@ -26,16 +26,29 @@ import type { AiConnectionKind } from '@trek/shared';
/** The end of an OpenAI-compatible stream. */
const DONE = '[DONE]';
/** The host of Gemini's OpenAI-compatible endpoint, which an admin may also enter as their own server. */
const GEMINI_HOST = 'generativelanguage.googleapis.com';
function isGeminiHost(baseUrl: string): boolean {
try {
return new URL(baseUrl).hostname === GEMINI_HOST;
} catch {
return false;
}
}
/**
* The provider-neutral history as Chat Completions messages, the instructions
* and the trip card first. A tool message takes no image, so the images of a
* step's tool results follow its last result as one user message.
* step's tool results follow its last result as one user message. `model` is
* the one the request goes to: a thought signature goes back only to the model
* that signed it, since another one cannot read it.
*/
export function toChatMessages(request: AiChatRequest): Record<string, unknown>[] {
export function toChatMessages(request: AiChatRequest, model?: string): Record<string, unknown>[] {
const system = [request.system, request.context].filter(Boolean).join('\n\n');
const out: Record<string, unknown>[] = [{ role: 'system', content: system }];
request.messages.forEach((message, index) => {
out.push(toChatMessage(message));
out.push(toChatMessage(message, model));
const withImages = toolImagesEndingAt(request.messages, index);
if (withImages.length === 0) return;
const content = withImages.flatMap((tool) => [
@@ -51,12 +64,14 @@ function imagePart(image: AiImage): Record<string, unknown> {
return { type: 'image_url', image_url: { url: imageDataUrl(image), detail: image.detail ?? 'high' } };
}
function toChatMessage(message: AiChatMessage): Record<string, unknown> {
function toChatMessage(message: AiChatMessage, model: string | undefined): Record<string, unknown> {
if (message.role === 'user') {
if (!message.images?.length) return { role: 'user', content: message.text };
return { role: 'user', content: [{ type: 'text', text: message.text }, ...message.images.map(imagePart)] };
}
if (message.role === 'tool') return { role: 'tool', tool_call_id: message.toolCallId, content: message.output };
const state = message.providerState;
const signatures = state?.adapter === 'openai-chat' && state.model === model ? state.signatures : {};
return {
role: 'assistant',
content: message.text || null,
@@ -66,19 +81,32 @@ function toChatMessage(message: AiChatMessage): Record<string, unknown> {
id: call.id,
type: 'function',
function: { name: call.name, arguments: JSON.stringify(call.args) },
...(signatures[call.id] ? { extra_content: { google: { thought_signature: signatures[call.id] } } } : {}),
})),
}
: {}),
};
}
/** Gemini signs a tool call on its first piece; the call has to go back with it or the next request is refused. */
function thoughtSignatureOf(piece: Record<string, unknown>): string {
return asText(asRecord(asRecord(piece.extra_content).google).thought_signature);
}
/** The id a call goes on with; a provider that names none gets one from the call's position. */
function callId(index: number, id: string): string {
return id || `call_${index}`;
}
const FINISH_REASONS: Record<string, AiFinishReason> = { tool_calls: 'tool-calls', length: 'length' };
/**
* The OpenAI-compatible Chat Completions API, streamed: OpenAI itself, Gemini
* (its `/v1beta/openai` endpoint), OpenRouter and an admin's own server. The
* kind decides only what a request may carry: Gemini gets its narrower tool
* schemas, OpenAI the prompt cache key, which the others do not know.
* schemas, also when an admin enters its endpoint as their own server, and
* OpenAI the prompt cache key, which the others do not know. Gemini's thought
* signatures come back as the turn's provider state.
*/
export class OpenAiChatClient implements AiChatClient {
/** OpenRouter searches the web for any model through its web plugin; the others have no search here. */
@@ -86,20 +114,25 @@ export class OpenAiChatClient implements AiChatClient {
return this.kind === 'openrouter';
}
/** The kind whose tool schemas the endpoint takes. */
private readonly schemaKind: AiConnectionKind;
constructor(
readonly kind: AiConnectionKind,
readonly model: string,
private readonly baseUrl: string,
private readonly apiKey: string | null,
private readonly fetcher: AiFetch,
) {}
) {
this.schemaKind = kind === 'custom' && isGeminiHost(baseUrl) ? 'gemini' : kind;
}
async *stream(request: AiChatRequest, signal: AbortSignal): AsyncGenerator<AiChatEvent> {
const body = {
model: this.model,
stream: true,
stream_options: { include_usage: true },
messages: toChatMessages(request),
messages: toChatMessages(request, this.model),
...(request.tools.length > 0
? {
tools: request.tools.map((tool) => ({
@@ -107,7 +140,7 @@ export class OpenAiChatClient implements AiChatClient {
function: {
name: tool.name,
description: tool.description,
parameters: toProviderSchema(tool.parameters, this.kind),
parameters: toProviderSchema(tool.parameters, this.schemaKind),
},
})),
tool_choice: 'auto',
@@ -153,10 +186,22 @@ export class OpenAiChatClient implements AiChatClient {
cachedInputTokens: turn.cachedInputTokens,
outputTokens: turn.outputTokens,
};
const signatures = turn.signatures();
if (Object.keys(signatures).length > 0) {
yield { type: 'provider-state', state: { adapter: 'openai-chat', model: this.model, signatures } };
}
yield { type: 'finish', reason: turn.finishReason() };
}
}
/** A tool call while it streams in: its id, name and arguments so far, and Gemini's signature. */
interface PendingCall {
id: string;
name: string;
args: string;
signature: string;
}
/**
* What one streamed Chat Completions turn has said so far. A tool call
* arrives in pieces keyed by its index (its id and name in the first, the
@@ -168,7 +213,7 @@ class ChatTurn {
outputTokens = 0;
reason: string | null = null;
done = false;
private readonly pending = new Map<number, { id: string; name: string; args: string }>();
private readonly pending = new Map<number, PendingCall>();
private readonly cited = new Set<string>();
private fresh: AiSource[] = [];
@@ -199,10 +244,11 @@ class ChatTurn {
const piece = asRecord(raw);
const index = typeof piece.index === 'number' ? piece.index : position;
const fn = asRecord(piece.function);
const call = this.pending.get(index) ?? { id: '', name: '', args: '' };
const call = this.pending.get(index) ?? { id: '', name: '', args: '', signature: '' };
call.id ||= asText(piece.id);
call.name ||= asText(fn.name);
call.args += asText(fn.arguments);
call.signature ||= thoughtSignatureOf(piece);
this.pending.set(index, call);
});
return asText(delta.content);
@@ -216,10 +262,24 @@ class ChatTurn {
}
calls(): AiToolCall[] {
return [...this.pending.entries()]
.sort(([a], [b]) => a - b)
.filter(([, call]) => call.name)
.map(([index, call]) => ({ id: call.id || `call_${index}`, name: call.name, args: parseToolArgs(call.args) }));
return this.named().map(([index, call]) => ({
id: callId(index, call.id),
name: call.name,
args: parseToolArgs(call.args),
}));
}
/** The thought signature of each signed call, by the id the call goes back with. */
signatures(): Record<string, string> {
return Object.fromEntries(
this.named()
.filter(([, call]) => call.signature)
.map(([index, call]) => [callId(index, call.id), call.signature]),
);
}
private named(): [number, PendingCall][] {
return [...this.pending.entries()].sort(([a], [b]) => a - b).filter(([, call]) => call.name);
}
finishReason(): AiFinishReason {
@@ -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' };
}
+2 -2
View File
@@ -59,7 +59,7 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
titleKey: 'system_notice.release_notes.headline',
bodyKey: 'system_notice.release_notes.intro',
release: {
version: '5.0.0',
version: '5.0.1',
eyebrowKey: 'system_notice.release_notes.eyebrow',
headlineKey: 'system_notice.release_notes.headline',
introKey: 'system_notice.release_notes.intro',
@@ -95,7 +95,7 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
footnoteKey: 'system_notice.release_notes.footnote',
notes: {
labelKey: 'system_notice.release_notes.notes_label',
href: 'https://github.com/liketrek/TREK/releases/tag/v5.0.0',
href: 'https://github.com/liketrek/TREK/releases/tag/v5.0.1',
},
note: {
eyebrowKey: 'system_notice.release_notes.note_eyebrow',
@@ -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);
@@ -529,6 +529,125 @@ describe('OpenAiChatClient', () => {
await failure(client(streamResponse([{ choices: [{ delta: { content: 'half' } }] }])).stream(REQUEST, signal())),
).toMatchObject({ providerCode: 'stream_cut' });
});
// Gemini 3 signs the first call of a step on its first piece, the way its Chat Completions endpoint streams it.
const SIGNED = [
{
choices: [
{
delta: {
tool_calls: [
{
index: 0,
id: 'function-call-1',
type: 'function',
function: { name: 'create_place', arguments: '{"name":"Nijo"}' },
extra_content: { google: { thought_signature: 'sig-A' } },
},
],
},
},
],
},
{
choices: [
{
delta: {
tool_calls: [{ index: 1, function: { name: 'list_places', arguments: '{}' } }],
},
finish_reason: 'tool_calls',
},
],
},
'[DONE]',
];
it('OACH-006: a signed call comes back as the provider state, keyed by the id it goes on with', async () => {
const events = await run(
new OpenAiChatClient(
'gemini',
'gemini-3-flash',
'https://g',
'k',
fetchAnswering(streamResponse(SIGNED)).fetcher,
).stream(REQUEST, signal()),
);
expect(events.filter((event) => event.type === 'tool-call')).toEqual([
{ type: 'tool-call', call: { id: 'function-call-1', name: 'create_place', args: { name: 'Nijo' } } },
{ type: 'tool-call', call: { id: 'call_1', name: 'list_places', args: {} } },
]);
expect(events.slice(-2)).toEqual([
{
type: 'provider-state',
state: { adapter: 'openai-chat', model: 'gemini-3-flash', signatures: { 'function-call-1': 'sig-A' } },
},
{ type: 'finish', reason: 'tool-calls' },
]);
// An unsigned turn keeps no state: nothing is stored for a provider that signs nothing.
const plain = await run(
new OpenAiChatClient('openai', 'm', 'https://x', 'k', fetchAnswering(streamResponse(STREAM)).fetcher).stream(
REQUEST,
signal(),
),
);
expect(plain.some((event) => event.type === 'provider-state')).toBe(false);
});
it('OACH-007: the signature goes back with its call to the model that signed it, and to no other', async () => {
const signed: AiChatRequest = {
...REQUEST,
messages: [
{ role: 'user', text: 'Add Nijo' },
{
role: 'assistant',
text: '',
toolCalls: [
{ id: 'function-call-1', name: 'create_place', args: { name: 'Nijo' } },
{ id: 'call_1', name: 'list_places', args: {} },
],
providerState: {
adapter: 'openai-chat',
model: 'gemini-3-flash',
signatures: { 'function-call-1': 'sig-A' },
},
},
{ role: 'tool', toolCallId: 'function-call-1', name: 'create_place', output: '{"id":7}' },
{ role: 'tool', toolCallId: 'call_1', name: 'list_places', output: '[]' },
],
};
const http = fetchAnswering(streamResponse(['[DONE]']));
await run(
new OpenAiChatClient('gemini', 'gemini-3-flash', 'https://g', 'k', http.fetcher).stream(signed, signal()),
);
expect(dig(http.body(), 'messages', 2, 'tool_calls')).toEqual([
{
id: 'function-call-1',
type: 'function',
function: { name: 'create_place', arguments: '{"name":"Nijo"}' },
extra_content: { google: { thought_signature: 'sig-A' } },
},
{ id: 'call_1', type: 'function', function: { name: 'list_places', arguments: '{}' } },
]);
// A thread that moved on to another model sends the calls plain: no other model reads the signature.
for (const messages of [toChatMessages(signed, 'gpt-5-mini'), toChatMessages(signed)]) {
expect(JSON.stringify(messages)).not.toContain('extra_content');
}
});
it('OACH-008: an own server on Gemini’s endpoint gets Gemini’s narrower schemas, any other the full ones', async () => {
const parameters = async (baseUrl: string) => {
const http = fetchAnswering(streamResponse(['[DONE]']));
await run(new OpenAiChatClient('custom', 'm', baseUrl, 'k', http.fetcher).stream(REQUEST, signal()));
return dig(http.body(), 'tools', 0, 'function', 'parameters');
};
expect(await parameters('https://generativelanguage.googleapis.com/v1beta/openai')).toEqual(
toProviderSchema(TOOL.parameters, 'gemini'),
);
expect(await parameters('http://ollama:11434/v1')).toEqual(toProviderSchema(TOOL.parameters, 'custom'));
expect(await parameters('not a url')).toEqual(toProviderSchema(TOOL.parameters, 'custom'));
});
});
describe('OpenAiResponsesClient', () => {
@@ -8,6 +8,7 @@ import type { AssistantMessageRow } from '../../../../src/db/repositories/Assist
import type { AssistantToolCallRow } from '../../../../src/db/repositories/AssistantToolCalls.repository';
import type { AiChatMessage } from '../../../../src/nest/ai-connections/ai-chat-client.interface';
import { toAnthropicMessages } from '../../../../src/nest/ai-connections/providers/anthropic-messages.client';
import { toChatMessages } from '../../../../src/nest/ai-connections/providers/openai-chat.client';
import { toResponsesInput } from '../../../../src/nest/ai-connections/providers/openai-responses.client';
import {
messageIdOf,
@@ -573,6 +574,23 @@ describe('toChatHistory: calls the model made of the server tools (#2845)', () =
expect(messages.flatMap((message) => message.content).some((block) => block.text === '')).toBe(false);
});
it('ASTHIS-018: Gemini gets every stored call back with its thought signature, an applied change included', () => {
const state = (id: string, signature: string) => ({
adapter: 'openai-chat',
model: 'gemini-3-flash',
signatures: { [id]: signature },
});
type SentCall = { id: string; extra_content?: { google: { thought_signature: string } } };
const history = appliedAfterANote(state('call_note', 'sig-note'), state('call_move', 'sig-move'));
const sent = toChatMessages({ system: '', messages: history, tools: [] }, 'gemini-3-flash')
.flatMap((message) => (message.tool_calls ?? []) as SentCall[])
.map((call) => [call.id, call.extra_content?.google.thought_signature]);
expect(sent).toEqual([
['call_note', 'sig-note'],
['call_move', 'sig-move'],
]);
});
it('ASTHIS-017: a step that only kept a note is the model call it was, answered with what was kept', () => {
const history = toChatHistory(
[
@@ -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