mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-12 23:31:02 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 228db8541c | |||
| a729a6935f | |||
| 6c0e39e287 | |||
| 22574a2270 |
@@ -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))
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user