fix(assistant): green CI round, trip notes the assistant can keep, calmer change cards

- the assistant keeps facts for a trip (remember_for_trip) as a change card,
  applied into the user's trip notes; existing expenses can be changed
- change card fields as a few icon chips with the rest folded under Details,
  the day band marks only what changes, the week grid as compact tiles
- no map button on receipts of things that have no place on the map
- tests follow the composer's accessible name, the receipt's status icon and
  the GL map's container; the hanging stream helper honours an early abort
- Postgres probe: qualified columns in the upsert updates; entities restored
  to the generator's output; strict ratchet reads the SDK client as untyped
This commit is contained in:
Maurice
2026-10-21 21:22:12 +02:00
parent 26df56d76c
commit b6f7488591
63 changed files with 509 additions and 133 deletions
@@ -161,7 +161,8 @@ describe('useLlmParsingConfig', () => {
await act(async () => result.current.setVision('off'));
act(() => result.current.setProvider('anthropic'));
await act(() => result.current.save());
expect(vi.mocked(adminApi.updateAddon).mock.calls.at(-1)?.[1]).toEqual({
const calls = vi.mocked(adminApi.updateAddon).mock.calls;
expect(calls[calls.length - 1]?.[1]).toEqual({
config: { provider: 'anthropic', model: '', baseUrl: '', apiKey: '', vision: 'off' },
});
});
@@ -166,7 +166,7 @@ describe('AssistantChangeCard: the receipt', () => {
const onShowOnMap = vi.fn();
renderCard([receipt('c1', '2026-10-21T08:15:00.000Z')], { onShowOnMap });
expect(screen.getByText('Applied')).toBeInTheDocument();
expect(screen.getByRole('img', { name: 'Applied' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Show on map' }));
expect(onShowOnMap).toHaveBeenCalledWith(expect.objectContaining({ toolCallId: 'c1', dayId: 3 }));
expect(screen.getByRole('button', { name: 'Undo' })).toBeInTheDocument();
@@ -188,7 +188,7 @@ describe('AssistantChangeCard: the receipt', () => {
fireEvent.click(screen.getByRole('button', { name: 'Undo' }));
});
expect(undo).toHaveBeenCalledWith(TRIP, 'c1');
expect(screen.getByText('Undone')).toBeInTheDocument();
expect(screen.getByRole('img', { name: 'Undone' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Undo' })).toBeNull();
first.unmount();
@@ -204,7 +204,7 @@ describe('AssistantChangeCard: the receipt', () => {
renderCard([receipt('c1', null), { ...pending('c2', 'Ryoan-ji'), state: 'discarded' }]);
expect(screen.queryByRole('button', { name: 'Show on map' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Undo' })).toBeNull();
expect(screen.getByText('Discarded')).toBeInTheDocument();
expect(screen.getByRole('img', { name: 'Discarded' })).toBeInTheDocument();
});
});
@@ -1,6 +1,5 @@
import type { AssistantChangePreview, AssistantChangeRow, AssistantChangeVerbKey } from '@trek/shared';
import type { AssistantApplied, AssistantChangePreview, AssistantChangeVerbKey } from '@trek/shared';
import {
ArrowRight,
CalendarArrowUp,
CalendarMinus,
CalendarPlus,
@@ -11,6 +10,7 @@ import {
Luggage,
MapPin,
MapPinPlus,
NotebookPen,
PencilLine,
StickyNote,
Ticket,
@@ -27,10 +27,11 @@ import { useTripStore } from '../../store/tripStore';
import ToggleSwitch from '../Settings/ToggleSwitch';
import { useToast } from '../shared/Toast';
import { Tooltip } from '../shared/Tooltip';
import AssistantChangeFields from './AssistantChangeFields';
import AssistantCompareSwitch from './AssistantCompareSwitch';
import AssistantDayBand from './AssistantDayBand';
import AssistantWeekOverview from './AssistantWeekOverview';
import { CHANGE_LABELS, type ChangeItem, type ChangeState, undoLeftMs } from './assistantParts';
import { type ChangeItem, type ChangeState, undoLeftMs } from './assistantParts';
import { useAssistantView } from './assistantViewContext';
import { changePoints, type DayChange } from './dayAfterModel';
import { useChangeHover } from './useChangeHover';
@@ -78,8 +79,10 @@ const VERB_LOOK: Record<AssistantChangeVerbKey, { Icon: typeof Check; tone: Tone
updatePackingItem: { Icon: Luggage, tone: 'info' },
addTodo: { Icon: ListTodo, tone: 'success' },
addBudgetItem: { Icon: Wallet, tone: 'success' },
updateBudgetItem: { Icon: Wallet, tone: 'info' },
addReservation: { Icon: Ticket, tone: 'success' },
importBooking: { Icon: Ticket, tone: 'success' },
rememberNote: { Icon: NotebookPen, tone: 'info' },
};
/** How a decided change reads: one quiet mark, its words in the tooltip. */
@@ -130,37 +133,6 @@ function ChangeHead({ preview, struck = false }: { preview: AssistantChangePrevi
);
}
/** One field as a badge that holds its label as a smaller badge; a change shows the old value struck through. */
function FieldBadge({ row }: { row: AssistantChangeRow }) {
const { t } = useTranslation();
const empty = <span className="italic text-content-faint">{t('assistant.change.empty')}</span>;
return (
<span className="inline-flex max-w-full items-center gap-1.5 rounded-full bg-surface-secondary py-[3px] pe-2.5 ps-[3px] text-caption text-content">
<span className="flex-shrink-0 rounded-full bg-surface-card px-2 py-px text-content-muted">
{t(CHANGE_LABELS[row.labelKey].labelKey)}
</span>
{row.op !== 'add' && (
<>
<bdi className="min-w-0 truncate text-content-faint line-through">{row.before || empty}</bdi>
<ArrowRight size={11} aria-hidden="true" className="flex-shrink-0 text-content-faint rtl:-scale-x-100" />
</>
)}
<bdi className="min-w-0 truncate">{row.after || empty}</bdi>
</span>
);
}
function ChangeFields({ rows }: { rows: AssistantChangeRow[] }) {
if (rows.length === 0) return null;
return (
<div className="mt-2 flex flex-wrap gap-1.5">
{rows.map((row, i) => (
<FieldBadge key={`${row.labelKey}-${i}`} row={row} />
))}
</div>
);
}
/** The time now, ticking once more when the next undo window closes. */
function useUndoClock(items: ChangeItem[]): number {
const [now, setNow] = useState(() => Date.now());
@@ -192,6 +164,9 @@ function dayChangesOf(items: readonly ChangeItem[], excluded: ReadonlySet<string
);
}
/** What an applied change can be shown as on the map; a kept note, a todo or an expense has no place there. */
const ON_MAP: ReadonlySet<AssistantApplied['entity']> = new Set(['place', 'assignment', 'day_note', 'reservation']);
function Receipt({
item,
undone,
@@ -209,6 +184,7 @@ function Receipt({
const receipt = undone ? RECEIPT.discarded : RECEIPT[item.state];
const { Icon } = receipt;
const applied = item.state === 'applied' && !undone ? item.applied : null;
const mappable = applied !== null && ON_MAP.has(applied.entity);
const settled = item.state === 'discarded' || item.state === 'expired' || undone;
const statusLabel = t(undone ? 'assistant.change.undone' : receipt.labelKey);
return (
@@ -217,7 +193,7 @@ function Receipt({
<div className={`min-w-0 flex-1 ${settled ? 'opacity-60' : ''}`}>
<ChangeHead preview={item.preview} struck={settled} />
</div>
{applied && onShowOnMap && (
{applied && mappable && onShowOnMap && (
<Tooltip label={t('assistant.change.showOnMap')} placement="top">
<button
type="button"
@@ -276,7 +252,7 @@ function PendingRow({
<KindTile verbKey={item.preview?.verbKey ?? null} />
<div className="min-w-0 flex-1">
<ChangeHead preview={item.preview} />
{item.preview && <ChangeFields rows={item.preview.rows} />}
{item.preview && <AssistantChangeFields rows={item.preview.rows} />}
</div>
{withSwitch && (
<ToggleSwitch on={!excluded} onToggle={onToggle} label={t('assistant.change.include')} disabled={disabled} />
@@ -0,0 +1,138 @@
import type { AssistantChangeLabelKey, AssistantChangeRow } from '@trek/shared';
import {
ArrowRight,
CalendarClock,
CalendarDays,
ChevronDown,
Clock,
Hash,
LogIn,
LogOut,
type LucideIcon,
Tag,
Wallet,
} from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from '../../i18n';
import { CHANGE_LABELS } from './assistantParts';
/**
* The fields of one change, read at a glance: the few that decide whether it
* fits (when, what, what it costs) as quiet icon chips, a value that changes
* as old to new, everything else folded behind one "Details" line. Positions
* and colours never show; the map and the trip show those better.
*/
/** The fields worth a chip of their own, each with its glyph, in this order. */
const KEY_FIELDS: Partial<Record<AssistantChangeLabelKey, LucideIcon>> = {
day: CalendarDays,
time: Clock,
endTime: Clock,
checkIn: LogIn,
checkOut: LogOut,
dueDate: CalendarClock,
category: Tag,
price: Wallet,
quantity: Hash,
};
const KEY_ORDER = Object.keys(KEY_FIELDS) as AssistantChangeLabelKey[];
/** Fields that mean nothing written out. */
const HIDDEN: ReadonlySet<AssistantChangeLabelKey> = new Set(['coordinates', 'position', 'icon', 'color', 'currency']);
/** "#3" is how a day comes from the server; it reads as "Day 3". */
function shown(row: AssistantChangeRow, value: string | null | undefined, dayWord: string): string {
if (row.labelKey === 'day' && value && /^#\d+$/.test(value)) return `${dayWord} ${value.slice(1)}`;
return value ?? '';
}
function Chip({ row }: { row: AssistantChangeRow }) {
const { t } = useTranslation();
const Icon = KEY_FIELDS[row.labelKey] ?? Tag;
const label = t(CHANGE_LABELS[row.labelKey].labelKey);
const dayWord = t('assistant.week.day');
const changed = row.op !== 'add' && !!row.before;
return (
<span
title={label}
className="inline-flex max-w-full items-center gap-1 rounded-md bg-surface-secondary px-1.5 py-0.5 text-caption text-content-secondary"
>
<Icon size={11} aria-label={label} className="flex-shrink-0 text-content-faint" />
{changed && (
<>
<bdi className="min-w-0 truncate text-content-faint line-through">{shown(row, row.before, dayWord)}</bdi>
<ArrowRight size={10} aria-hidden="true" className="flex-shrink-0 text-content-faint rtl:-scale-x-100" />
</>
)}
<bdi className="min-w-0 truncate font-medium text-content">{shown(row, row.after, dayWord)}</bdi>
</span>
);
}
/** A field behind "Details": its name, then the value, old to new where it changes. */
function DetailLine({ row }: { row: AssistantChangeRow }) {
const { t } = useTranslation();
const changed = row.op !== 'add' && !!row.before;
return (
<div className="flex min-w-0 gap-2 text-caption">
<span className="w-20 flex-shrink-0 truncate text-content-faint">{t(CHANGE_LABELS[row.labelKey].labelKey)}</span>
<span className="flex min-w-0 flex-1 items-center gap-1 text-content-secondary">
{changed && (
<>
<bdi className="min-w-0 truncate text-content-faint line-through">{row.before}</bdi>
<ArrowRight size={10} aria-hidden="true" className="flex-shrink-0 text-content-faint rtl:-scale-x-100" />
</>
)}
<bdi className="min-w-0 truncate">{row.after || t('assistant.change.empty')}</bdi>
</span>
</div>
);
}
export default function AssistantChangeFields({ rows }: { rows: AssistantChangeRow[] }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const visible = rows.filter((row) => !HIDDEN.has(row.labelKey) && row.labelKey !== 'name');
const key = visible
.filter((row) => row.labelKey in KEY_FIELDS && row.after)
.sort((a, b) => KEY_ORDER.indexOf(a.labelKey) - KEY_ORDER.indexOf(b.labelKey));
const rest = visible.filter((row) => !key.includes(row));
if (key.length === 0 && rest.length === 0) return null;
return (
<div className="mt-1.5 flex flex-col gap-1.5">
{key.length > 0 && (
<div className="flex flex-wrap gap-1">
{key.map((row, i) => (
<Chip key={`${row.labelKey}-${i}`} row={row} />
))}
</div>
)}
{rest.length > 0 && (
<>
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="inline-flex items-center gap-1 self-start rounded-md text-caption text-content-faint transition-colors hover:text-content"
>
<ChevronDown
size={12}
aria-hidden="true"
className={`transition-transform duration-200 ${open ? 'rotate-180' : ''}`}
/>
{t(open ? 'assistant.change.detailsHide' : 'assistant.change.details')}
</button>
{open && (
<div className="trek-assistant-enter flex flex-col gap-1 rounded-lg bg-surface-secondary px-2.5 py-2">
{rest.map((row, i) => (
<DetailLine key={`${row.labelKey}-${i}`} row={row} />
))}
</div>
)}
</>
)}
</div>
);
}
@@ -51,7 +51,7 @@ function seed(tripInPlanner = TRIP) {
async function input(): Promise<HTMLTextAreaElement> {
render(<AssistantSurface tripId={TRIP} tripTitle="Paris" variant="window" />);
return (await screen.findByPlaceholderText('Ask anything about Paris')) as HTMLTextAreaElement;
return (await screen.findByRole('textbox', { name: 'Ask anything about Paris' })) as HTMLTextAreaElement;
}
const mentions = () => chatOf(useAssistantStore.getState(), TRIP).mentions;
@@ -1,4 +1,4 @@
import { ChevronDown, CircleCheck, TriangleAlert } from 'lucide-react';
import { ChevronDown, CircleCheck, Minus, Plus, TriangleAlert } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
@@ -20,8 +20,9 @@ import { useChangeHover } from './useChangeHover';
/**
* The days a waiting card touches, as they would read once it ran: per day its
* date and its stops in order as small named chips, so a glance shows where the
* suggestion lands among the rest. The details (times, the way between stops,
* date and its stops in order. What stays reads as quiet text; only what the
* card adds is a filled pill and what it takes away is struck, so a glance
* finds the suggestion among the rest. The details (times, the way between stops,
* free time, a clash) stay folded away until asked for. Two days at most; the
* rest are counted. Shown only while the planner holds this trip, since the day
* plans come from it.
@@ -33,26 +34,33 @@ const MAX_DAYS = 2;
const MAX_CHIPS = 6;
const CHIP: Record<TimelineStop['status'], string> = {
kept: 'border-transparent bg-surface-secondary text-content-muted',
new: 'border-dashed border-success bg-success-soft text-content',
removed: 'border-transparent text-content-faint line-through',
kept: 'px-0.5 text-content-muted',
new: 'rounded-full bg-accent px-2 py-0.5 font-medium text-accent-text shadow-sm',
removed: 'rounded-full px-1.5 py-0.5 text-content-faint line-through',
};
/** One stop of the day, named; the change it belongs to lights up with it. */
function StopChip({ stop }: { stop: TimelineStop }) {
const { tripId } = useAssistantView();
const { lit, handlers } = useChangeHover(tripId, stop.toolCallId);
const clash = stop.lead?.overlapsWith ? 'border-danger' : stopIssue(stop) ? 'border-warning' : '';
const issue = stop.lead?.overlapsWith ? 'ring-2 ring-danger' : stopIssue(stop) ? 'ring-2 ring-warning' : '';
const Mark = stop.status === 'new' ? Plus : stop.status === 'removed' ? Minus : null;
return (
<span
{...(stop.toolCallId ? handlers : {})}
className={`inline-flex max-w-[11rem] items-center rounded-full border px-2 py-0.5 text-caption transition-colors ${CHIP[stop.status]} ${clash} ${lit ? 'bg-surface-hover' : ''}`}
className={`inline-flex max-w-[12rem] items-center gap-1 text-caption transition-all ${CHIP[stop.status]} ${issue} ${lit ? 'scale-[1.04]' : ''}`}
>
{Mark && <Mark size={11} strokeWidth={2.6} aria-hidden="true" className="flex-shrink-0" />}
<bdi className="truncate">{stop.name}</bdi>
</span>
);
}
/** The thin rule between two stops, read as "then". */
function Then() {
return <span aria-hidden="true" className="h-px w-2 flex-shrink-0 bg-edge" />;
}
function Lead({ stop, timed }: { stop: TimelineStop; timed: boolean }) {
const { t } = useTranslation();
const lead = stop.lead;
@@ -111,11 +119,16 @@ function DayRow({ day, label, open }: { day: DayAfter; label: string; open: bool
const chips = day.stops.slice(0, MAX_CHIPS);
return (
<div>
<bdi className="block truncate text-caption font-medium text-content-muted">{label}</bdi>
<bdi className="block truncate text-caption font-semibold uppercase tracking-wide text-content-faint">
{label}
</bdi>
{!open && (
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-1">
{chips.map((stop) => (
<StopChip key={stop.key} stop={stop} />
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-1 gap-y-1.5">
{chips.map((stop, i) => (
<span key={stop.key} className="contents">
{i > 0 && <Then />}
<StopChip stop={stop} />
</span>
))}
{day.stops.length > MAX_CHIPS && (
<span className="text-caption tabular-nums text-content-faint">+{day.stops.length - MAX_CHIPS}</span>
@@ -87,7 +87,7 @@ describe('AssistantSurface', () => {
it('FE-AI-SURF-003: Enter sends what was typed, with the trip in the placeholder', async () => {
seed([]);
renderChat();
const input = await screen.findByPlaceholderText('Ask anything about Kyoto');
const input = await screen.findByRole('textbox', { name: 'Ask anything about Kyoto' });
fireEvent.change(input, { target: { value: 'Where should we eat?' } });
expect(chatOf(useAssistantStore.getState(), TRIP).draft).toBe('Where should we eat?');
fireEvent.keyDown(input, { key: 'Enter' });
@@ -105,7 +105,7 @@ describe('AssistantSurface', () => {
},
]);
renderChat();
const input = await screen.findByPlaceholderText('Ask anything about Kyoto');
const input = await screen.findByRole('textbox', { name: 'Ask anything about Kyoto' });
fireEvent.change(input, { target: { value: 'Something else' } });
expect(screen.getByRole('button', { name: 'Send' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument();
@@ -1,11 +1,10 @@
import { Check, Minus } from 'lucide-react';
import { useTranslation } from '../../i18n';
import type { WeekDay } from './useWeekDays';
/**
* A plan over several days as one grid: a tile per day with how many changes
* it gets, taken in or left out as a whole with one tap. The single changes
* A plan over several days as one row of small tiles: a day per tile, its
* number of changes in the corner, taken in or left out as a whole with one
* tap. The single changes
* stay below it, folded away until asked for.
*/
export default function AssistantWeekOverview({
@@ -24,38 +23,43 @@ export default function AssistantWeekOverview({
<div
role="group"
aria-label={t('assistant.week.title')}
className="mb-2 grid gap-1.5"
className="mb-2 grid gap-2 pt-1"
style={{ gridTemplateColumns: `repeat(${Math.min(days.length, 7)}, minmax(0, 1fr))` }}
>
{days.map((day, i) => {
const on = day.ids.filter((id) => !excluded.has(id)).length;
const taken = on > 0;
const count = taken ? on : day.ids.length;
const name = `${day.weekday ?? t('assistant.week.day')} ${day.n}`;
return (
<button
key={day.id}
type="button"
disabled={disabled}
aria-pressed={taken}
aria-label={`${name}, ${t('assistant.week.changes', { count })}`}
title={t('assistant.week.changes', { count })}
onClick={() => onToggleDay(day.ids, !taken)}
className={`trek-assistant-enter group/day relative flex min-w-0 flex-col items-center gap-0.5 rounded-xl border px-1 py-2 transition-all disabled:cursor-default ${
className={`trek-assistant-enter relative flex min-w-0 flex-col items-center rounded-lg py-1.5 outline-none transition-all focus-visible:ring-2 focus-visible:ring-accent disabled:cursor-default ${
taken
? 'border-accent bg-accent-subtle text-content shadow-sm hover:-translate-y-px hover:shadow-md'
: 'border-dashed border-edge text-content-faint hover:border-edge-secondary'
? 'bg-accent text-accent-text shadow-sm hover:-translate-y-px hover:shadow-md'
: 'bg-surface-secondary text-content-faint hover:text-content-muted'
}`}
style={{ animationDelay: `${i * 40}ms` }}
>
<span className={`text-caption leading-tight ${taken ? 'opacity-75' : ''}`}>
{day.weekday ?? t('assistant.week.day')}
</span>
<span className={`text-body font-semibold tabular-nums leading-tight ${taken ? '' : 'line-through'}`}>
{day.n}
</span>
<span
aria-hidden="true"
className={`absolute end-1 top-1 grid h-3.5 w-3.5 place-items-center rounded-full ${
taken ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-faint'
className={`absolute -end-1 -top-1 grid h-4 min-w-4 place-items-center rounded-full px-1 text-caption font-semibold tabular-nums leading-none ring-2 ring-surface-card ${
taken ? 'bg-surface-card text-content' : 'bg-surface-tertiary text-content-faint'
}`}
>
{taken ? <Check size={9} strokeWidth={3} /> : <Minus size={9} strokeWidth={3} />}
</span>
<span className="text-caption text-content-muted">{day.weekday ?? t('assistant.week.day')}</span>
<span className="text-subtitle font-bold tabular-nums leading-none">{day.n}</span>
<span className={`text-caption tabular-nums ${taken ? 'text-content-secondary' : ''}`}>
{t('assistant.week.changes', { count: taken ? on : day.ids.length })}
{count}
</span>
</button>
);
@@ -10,7 +10,7 @@
import type { AiStatusResponse } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import { act, fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import { tripRepo } from '../../repo/tripRepo';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import type { Trip } from '../../types';
@@ -62,6 +62,8 @@ const resume = vi.fn(async () => {});
const cancel = vi.fn();
function seed(window: { open: boolean; minimized: boolean }, isRunning = false) {
// A chat the test before unmounted stops itself on the way out; that call is not this test's.
cancel.mockClear();
useAssistantStore.setState({
status: STATUS,
window,
@@ -135,9 +137,11 @@ describe('AssistantWindow', () => {
expect(cancel).toHaveBeenCalledWith(TRIP.id);
expect(useAssistantStore.getState().window.open).toBe(true);
useAssistantStore.setState({
byTrip: { [TRIP.id]: { ...chatOf(useAssistantStore.getState(), TRIP.id), isRunning: false } },
});
act(() =>
useAssistantStore.setState({
byTrip: { [TRIP.id]: { ...chatOf(useAssistantStore.getState(), TRIP.id), isRunning: false } },
})
);
fireEvent.keyDown(dialog, { key: 'Escape' });
expect(useAssistantStore.getState().window.open).toBe(false);
});
@@ -18,6 +18,10 @@ const STATUS: Partial<Record<AssistantMessage['status'], ThreadMessageLike['stat
function toPart(part: AssistantMessagePart): LikePart | null {
if (part.type === 'text') return part.text ? { type: 'text', text: part.text } : null;
// An attached file is drawn from the stored message; the runtime takes it as one more data part.
if (part.type === 'attachment') {
return { type: 'data-trek-attachment', data: { name: part.name, mime: part.mime, size: part.size } };
}
if (part.type !== 'tool-call') return part;
return {
type: 'tool-call',
@@ -47,7 +47,7 @@ function seed(tripInPlanner = TRIP) {
async function input(): Promise<HTMLTextAreaElement> {
render(<AssistantSurface tripId={TRIP} tripTitle="Paris" variant="window" />);
return (await screen.findByPlaceholderText('Ask anything about Paris')) as HTMLTextAreaElement;
return (await screen.findByRole('textbox', { name: 'Ask anything about Paris' })) as HTMLTextAreaElement;
}
/** A drag as startPlaceDrag starts it; jsdom keeps the type's case, browsers lower it. */
@@ -47,6 +47,8 @@ const glMap = vi.hoisted(() => ({
getStyle: vi.fn().mockReturnValue({ layers: [] }),
isStyleLoaded: vi.fn().mockReturnValue(true),
getCanvasContainer: vi.fn(() => glCanvasContainer),
// The assistant's camera listens on the map's container for the user's own moves.
getContainer: vi.fn(() => glCanvasContainer),
getLayer: vi.fn().mockReturnValue(null),
queryRenderedFeatures: vi.fn().mockReturnValue([]),
querySourceFeatures: vi.fn().mockReturnValue([]),
@@ -16,22 +16,26 @@ function setup(hoveredId: string | null = null) {
const attached: { el: HTMLElement; lng: number; lat: number; remove: ReturnType<typeof vi.fn> }[] = [];
const pinsRef = { current: [] as { remove: () => void }[] };
const onHover = vi.fn();
const onClick = vi.fn();
// Stable across renders, as the map view hands them over: only `hovered` changes.
const mapRef = { current: map };
const attachPin = (el: HTMLElement, lng: number, lat: number) => {
container.appendChild(el);
const pin = { el, lng, lat, remove: vi.fn(() => el.remove()) };
attached.push(pin);
return pin;
};
const hook = renderHook(
({ hovered }) =>
useAssistantGhostMarkersGL({
mapRef: { current: map },
mapRef,
mapReady: true,
pinsRef,
attachPin: (el, lng, lat) => {
container.appendChild(el);
const pin = { el, lng, lat, remove: vi.fn(() => el.remove()) };
attached.push(pin);
return pin;
},
attachPin,
ghosts: GHOSTS,
hoveredId: hovered,
onHover,
onClick: vi.fn(),
onClick,
}),
{ initialProps: { hovered: hoveredId } }
);
@@ -1013,8 +1013,9 @@ describe('IntegrationsTab – failure toasts', () => {
}),
),
);
render(<IntegrationsTab />);
const off = render(<IntegrationsTab />);
expect(screen.queryByText('AI assistant')).not.toBeInTheDocument();
off.unmount();
enableAddons(['ai_assistant']);
const { unmount } = render(<IntegrationsTab />);
@@ -31,7 +31,8 @@ describe('ClaudeConnectorCard', () => {
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
const qr = screen.getByAltText('QR code to add TREK in Claude');
expect(qr.getAttribute('src')).toMatch(/^data:image\/svg\+xml;charset=utf-8,/);
expect(container.querySelector('rect')).toBeNull();
// The QR code's own markup never lands in the page; icons may draw rects of their own.
expect(container.querySelector('rect[width="1"][height="1"]')).toBeNull();
expect(document.getElementById('claude-connector')).not.toBeNull();
});
+12 -4
View File
@@ -46,12 +46,18 @@ async function* streamOf(chunks: UiStreamChunk[]): AsyncGenerator<UiStreamChunk>
for (const chunk of chunks) yield chunk;
}
/** A stream that sends `first` and then waits until its request is aborted. */
/**
* A stream that sends `first` and then waits until its request is aborted. An
* abort that came before it got that far ends it at once: the event fired
* already, and a listener added after it would wait for ever.
*/
async function* hangingStream(first: UiStreamChunk[], signal: AbortSignal): AsyncGenerator<UiStreamChunk> {
yield* first;
await new Promise((_, reject) =>
signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')))
);
await new Promise((_, reject) => {
const stop = () => reject(new DOMException('Aborted', 'AbortError'));
if (signal.aborted) stop();
else signal.addEventListener('abort', stop);
});
}
const chat = () => chatOf(useAssistantStore.getState(), TRIP);
@@ -126,6 +132,8 @@ describe('assistantStore: a turn', () => {
const running = store().send(TRIP, 'Plan it');
await vi.waitFor(() => expect(chat().isRunning).toBe(true));
await vi.waitFor(() => expect(chat().abort).not.toBeNull());
// Stopped once the answer has begun, as a user would.
await vi.waitFor(() => expect(chat().messages[1]?.parts).toEqual([{ type: 'text', text: 'Let me' }]));
store().cancel(TRIP);
await running;
+2
View File
@@ -270,6 +270,8 @@ function onChunk(ctx: Ctx, tripId: number, chunk: UiStreamChunk) {
} else if (chunk.type === 'data-trek-applied') {
ctx.set({ lastApplied: { tripId, applied: chunk.data } });
noteAddedPlace(ctx, tripId, chunk.data);
// A kept note lives in the user's settings; read them again so the notes dialog shows it.
if (chunk.data.entity === 'memory') void useSettingsStore.getState().loadSettings();
} else if (chunk.type === 'data-trek-activity') {
patch(ctx, tripId, { live: { activity: chunk.data } });
} else if (chunk.type === 'tool-output-available' || chunk.type === 'tool-output-error') {
+9
View File
@@ -0,0 +1,9 @@
// The MCP SDK's client and in-memory transport resolve, through the `paths`
// redirect in tsconfig.json, to its CommonJS build, which ships no type
// definitions next to it, so the build reads them as untyped modules. Only
// the strict ratchet (tsconfig.strict.json) loads this file: it declares the
// two modules untyped there too, so an import of them is not counted as an
// error the build does not have. The build and the tests never see it.
declare module '@modelcontextprotocol/sdk/client/index';
declare module '@modelcontextprotocol/sdk/inMemory';
@@ -21,7 +21,9 @@ export class AssistantMessages {
export const AssistantMessagesSchema = defineEntity({
class: AssistantMessages,
repository: () => AssistantMessagesRepository,
indexes: [{ name: 'idx_assistant_messages_thread', properties: ['thread_id', 'id'] }],
indexes: [
{ name: 'idx_assistant_messages_thread', properties: ['thread_id', 'id'] },
],
properties: {
id: p.integer().primary(),
thread: () => p.manyToOne(AssistantThreads).ref().deleteRule('cascade').hidden(),
@@ -30,8 +30,7 @@ export const AssistantToolCallsSchema = defineEntity({
id: p.text().primary(),
thread: () => p.manyToOne(AssistantThreads).ref().deleteRule('cascade').hidden(),
thread_id: p.integer().persist(false),
message: () =>
p.manyToOne(AssistantMessages).ref().deleteRule('cascade').hidden().index('idx_assistant_tool_calls_message'),
message: () => p.manyToOne(AssistantMessages).ref().deleteRule('cascade').hidden().index('idx_assistant_tool_calls_message'),
message_id: p.integer().persist(false).index('idx_assistant_tool_calls_message'),
provider_call_id: p.text(),
tool_name: p.text(),
@@ -117,7 +117,7 @@ export class AiConnectionsRepository extends TrekRepository<AiConnections> {
: oc.column('user_id').where('user_id', 'is not', null);
return target.doUpdateSet({
...columns,
secrets_version: (eb) => eb('secrets_version', '+', 1),
secrets_version: (eb) => eb('ai_connections.secrets_version', '+', 1),
status: 'ok',
updated_at: () => this.now(),
});
@@ -35,9 +35,9 @@ export class AssistantUsageRepository extends TrekRepository<AssistantUsage> {
})
.onConflict((oc) =>
oc.columns(['user_id', 'day', 'paid_by']).doUpdateSet({
turns: (eb) => eb('turns', '+', eb.ref('excluded.turns')),
input_tokens: (eb) => eb('input_tokens', '+', eb.ref('excluded.input_tokens')),
output_tokens: (eb) => eb('output_tokens', '+', eb.ref('excluded.output_tokens')),
turns: (eb) => eb('assistant_usage.turns', '+', eb.ref('excluded.turns')),
input_tokens: (eb) => eb('assistant_usage.input_tokens', '+', eb.ref('excluded.input_tokens')),
output_tokens: (eb) => eb('assistant_usage.output_tokens', '+', eb.ref('excluded.output_tokens')),
}),
)
.execute();
+1 -1
View File
@@ -13,7 +13,7 @@ export {
} from './helpers';
export { McpRegistryService } from './mcp-registry.service';
export { McpModule } from './mcp.module';
export { McpRegistry, type McpRegistryOptions } from './registry';
export { createLocalMcpServer, McpRegistry, type McpRegistryOptions } from './registry';
export { createTestRegistry } from './testing';
export {
type McpAccess,
+6 -1
View File
@@ -17,7 +17,7 @@ import type {
ToolOptions,
} from './types';
import { ResourceTemplate as SdkResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
@@ -478,3 +478,8 @@ export class McpRegistry {
registrar.registerPrompt(options.name, config, cb);
}
}
/** A server of its own for a client in the same process (the assistant's tool session), to attach a registry to. */
export function createLocalMcpServer(name: string): McpServer {
return new McpServer({ name, version: '1.0.0' });
}
@@ -370,7 +370,7 @@ export function renderTripCard(card: TripCard): string {
type UserPrefs = Readonly<Record<string, unknown>>;
/** What a traveller writes for the assistant to keep in mind on one trip; longer notes are cut. */
const MAX_MEMORY_CHARS = 2_000;
export const MAX_MEMORY_CHARS = 2_000;
/** The notes the user keeps for this trip (`assistant_memory`, by trip id), trimmed; null when there are none. */
export function tripMemoryOf(prefs: UserPrefs, tripId: number): string | null {
@@ -0,0 +1,60 @@
import type { AiToolSpec } from '../ai-connections/ai-chat-client.interface';
import type { SettingsService } from '../settings/settings.service';
import { MAX_MEMORY_CHARS, tripMemoryOf } from './assistant-context';
import { REMEMBER_TOOL } from './assistant-tools.constants';
import type { ToolRun } from './assistant-tools.service';
/** The longest single note the model may keep. */
const MAX_NOTE_CHARS = 500;
/** How the model is offered the trip notes: one short fact per call, confirmed by the user like any change. */
export const REMEMBER_SPEC: AiToolSpec = {
name: REMEMBER_TOOL,
description:
"Keep a fact or wish about this trip in the user's trip notes, which come along with every later conversation " +
'about this trip (for example "we travel as two", "vegetarian", "no museums before 10"). Call it whenever the ' +
'user asks you to remember something, instead of saying you cannot. The user confirms it like any change.',
parameters: {
type: 'object',
properties: {
note: {
type: 'string',
maxLength: MAX_NOTE_CHARS,
description: "The fact as one short sentence, in the user's language.",
},
},
required: ['note'],
additionalProperties: false,
},
};
/** The note a call carries, trimmed; null when there is none. */
export function noteOf(args: Record<string, unknown>): string | null {
const note = typeof args.note === 'string' ? args.note.trim().slice(0, MAX_NOTE_CHARS) : '';
return note || null;
}
/**
* Adds the note to this trip's notes, below what is there. A note already
* kept is not added twice; notes that would run past the limit are refused,
* so nothing the user wrote is cut off.
*/
export async function rememberForTrip(
settings: SettingsService,
userId: number,
tripId: number,
args: Record<string, unknown>,
): Promise<ToolRun> {
const note = noteOf(args);
if (!note) return { ok: false, value: 'There is nothing to remember.' };
const prefs = await settings.getUserSettings(userId);
const current = tripMemoryOf(prefs, tripId);
if (current?.split('\n').some((line) => line.trim() === note)) return { ok: true, value: { notes: current } };
const next = current ? `${current}\n${note}` : note;
if (next.length > MAX_MEMORY_CHARS) {
return { ok: false, value: 'The trip notes are full. Ask the user to tidy them up in the chat menu first.' };
}
const all = prefs.assistant_memory && typeof prefs.assistant_memory === 'object' ? prefs.assistant_memory : {};
await settings.upsertSetting(userId, 'assistant_memory', { ...all, [String(tripId)]: next });
return { ok: true, value: { notes: next } };
}
@@ -87,6 +87,7 @@ export const ASSISTANT_WRITE_TOOLS: Readonly<Record<string, AssistantWriteTool>>
update_packing_item: { verbKey: 'updatePackingItem', entity: 'packing' },
create_todo: { verbKey: 'addTodo', entity: 'todo' },
create_budget_item: { verbKey: 'addBudgetItem', entity: 'budget' },
update_budget_item: { verbKey: 'updateBudgetItem', entity: 'budget' },
create_reservation: { verbKey: 'addReservation', entity: 'reservation' },
};
@@ -105,14 +106,22 @@ export const IMPORT_BOOKING_TOOL = 'import_booking';
*/
export const ADD_FOUND_PLACE_TOOL = 'add_found_place';
/**
* A fact or wish the user wants kept for this trip ("we are two"). It is no
* MCP tool: the server offers it to the model itself, holds it as a change
* card like any write and, once applied, adds it to the user's trip notes,
* which come along with every later turn about the trip.
*/
export const REMEMBER_TOOL = 'remember_for_trip';
/** The model's tools a found place is added through: a new place for the day, or one the trip has already. */
const FOUND_PLACE_VIA: ReadonlySet<string> = new Set(['create_and_assign_place', 'assign_place_to_day']);
/**
* Changes the server proposes itself, from an attached file or a search result
* the user picked rather than from the model. They are held, decided and
* recorded like the writes above, and they are not on the allowlist, so no
* session ever offers them to the model. A booking is applied by the server
* Changes the server applies itself: a booking from an attached file, a search
* result the user picked, a note the user wants kept. They are held, decided
* and recorded like the writes above, and they are not on the allowlist; only
* the note is offered to the model, by the session itself (REMEMBER_SPEC). A booking is applied by the server
* itself; a found place deliberately goes through the MCP tool named in its
* `via` argument, so it is applied with the user's rights, broadcast and
* undone exactly like the same change proposed by the model.
@@ -121,6 +130,7 @@ export const ASSISTANT_SERVER_WRITE_TOOLS: Readonly<Record<string, AssistantWrit
[IMPORT_BOOKING_TOOL]: { verbKey: 'importBooking', entity: 'reservation' },
// The card names the verb of the `via` tool; these are its fallbacks.
[ADD_FOUND_PLACE_TOOL]: { verbKey: 'addPlaceToDay', entity: 'place', quiet: true },
[REMEMBER_TOOL]: { verbKey: 'rememberNote', entity: 'memory' },
};
/**
@@ -1,8 +1,9 @@
import { McpRegistryService, type McpRegistryListing } from '../../nest-mcp';
import { createLocalMcpServer, McpRegistryService, type McpRegistryListing } from '../../nest-mcp';
import type { AiToolSpec } from '../ai-connections/ai-chat-client.interface';
import { AuditService } from '../audit/audit.service';
import { traceEntry } from '../audit/entry-trace.logger';
import { withRequestContext } from '../database/request-context';
import { REMEMBER_SPEC } from './assistant-memory';
import {
ASSISTANT_ARG_POLICY,
ASSISTANT_READ_TOOLS,
@@ -13,11 +14,11 @@ import {
ASSISTANT_WRITE_TOOLS,
isDeniedTool,
PINNED_ARG,
REMEMBER_TOOL,
} from './assistant-tools.constants';
import { MikroORM } from '@mikro-orm/core';
import { Client } from '@modelcontextprotocol/sdk/client/index';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { Injectable, type OnApplicationBootstrap } from '@nestjs/common';
type ToolArgs = Record<string, unknown>;
@@ -121,23 +122,25 @@ function readResult(result: unknown): ToolRun {
*/
export class AssistantToolSession {
constructor(
private readonly client: Client,
private readonly server: McpServer,
private readonly client: InstanceType<typeof Client>,
private readonly server: ReturnType<typeof createLocalMcpServer>,
private readonly tools: ReadonlyMap<string, ListedTool>,
private readonly tripId: number,
private readonly audit: (toolName: string) => void,
) {}
/** The tools as the model is offered them. */
/** The tools as the model is offered them, with the trip notes the server keeps itself. */
specs(): AiToolSpec[] {
return [...this.tools.values()].map((tool) => ({
const listed = [...this.tools.values()].map((tool) => ({
name: tool.name,
description: tool.description,
parameters: modelSchema(tool.name, tool.inputSchema),
}));
return [...listed, REMEMBER_SPEC];
}
kindOf(name: string): AssistantToolKind | null {
if (name === REMEMBER_TOOL) return 'write';
return this.tools.get(name)?.kind ?? null;
}
@@ -208,7 +211,7 @@ export class AssistantToolsService implements OnApplicationBootstrap {
* attached: the assistant passes no dynamic tool source.
*/
async open(userId: number, tripId: number, include: ReadonlySet<string> = ASSISTANT_TOOL_ALLOWLIST) {
const server = new McpServer({ name: 'TREK assistant', version: '1.0.0' });
const server = createLocalMcpServer('TREK assistant');
const around = (info: { kind: string; name: string }, call: () => unknown): unknown =>
traceEntry('mcp', `${info.kind} ${info.name} user=${userId} client=assistant`, () =>
withRequestContext(this.orm, call),
@@ -25,6 +25,8 @@ How you work:
- The trip card may start with what the user is asking about. A question about "this place", "it" or "here" is about that; never ask which place they mean then.
- The user can name days, places and bookings with "@" before the name. The trip card then lists what they named, with the ids; use those.
- The app shows the places your searches find as cards with a picture, and the weather you look up as a strip. Do not list them again in your text; say in a sentence or two which ones you suggest and why.
- When the user asks you to remember something for this trip (who travels, what they like or avoid), keep it with remember_for_trip; it comes back as <trip_notes> in every later conversation. Never say you cannot remember things.
- Existing expenses can be changed with update_budget_item (their ids are in get_trip_summary); never add a second entry to correct one.
- If the request is unclear, ask one short question instead of guessing.`;
/** The standing instructions, telling the model which language to answer in. */
@@ -36,6 +36,7 @@ import { AssistantBookingImportService } from './assistant-booking-import.servic
import { mentionItems } from './assistant-cards.helpers';
import { buildTripCard, categoriesFrom, languageName, todayIn, turnContext, type TripCard } from './assistant-context';
import { messageIdOf, outputText, partsOf, toChatHistory, toThreadMessages } from './assistant-history';
import { rememberForTrip } from './assistant-memory';
import { toolOutputForProvider } from './assistant-redact';
import { AssistantStreamRegistry } from './assistant-stream.registry';
import { titleFrom } from './assistant-title';
@@ -47,6 +48,7 @@ import {
foundPlaceArgs,
IMPORT_BOOKING_TOOL,
READ_BOOKING_FILE_TOOL,
REMEMBER_TOOL,
TURNS_PER_MINUTE,
UNDO_WINDOW_MS,
writeToolOf,
@@ -397,6 +399,7 @@ export class AssistantService {
): Promise<ToolRun> {
if (name === IMPORT_BOOKING_TOOL) return this.inContext(() => this.bookings.apply(userId, tripId, args));
if (name === ADD_FOUND_PLACE_TOOL) return session.call(writeToolOf(name, args), foundPlaceArgs(args));
if (name === REMEMBER_TOOL) return this.inContext(() => rememberForTrip(this.settings, userId, tripId, args));
return session.call(name, args);
}
@@ -1,9 +1,11 @@
import type { CardPlace, TripCard } from './assistant-context';
import { noteOf } from './assistant-memory';
import {
ASSISTANT_SERVER_WRITE_TOOLS,
ASSISTANT_UNDO_MAP,
ASSISTANT_WRITE_TOOLS,
IMPORT_BOOKING_TOOL,
REMEMBER_TOOL,
writeToolOf,
} from './assistant-tools.constants';
import type {
@@ -222,8 +224,7 @@ function route(endpoints: unknown): string {
if (!Array.isArray(endpoints)) return '';
const end = (role: string) => {
const found = endpoints.find((ep: unknown) => (ep as { role?: unknown } | null)?.role === role) as
| { name?: unknown; code?: unknown }
| undefined;
{ name?: unknown; code?: unknown } | undefined;
const name = text(found?.name);
const code = text(found?.code);
if (!name) return code;
@@ -270,6 +271,13 @@ interface Described {
type Describer = (ctx: PreviewContext, args: Args) => Described;
/** Who shares an expense: the uneven split's members, else the equal one's. */
function splitOf(args: Args): unknown {
return Array.isArray(args.members)
? args.members.map((member: unknown) => (member as { user_id?: unknown } | null)?.user_id)
: args.member_ids;
}
const DESCRIBERS: Readonly<Record<string, Describer>> = {
create_place: (ctx, args) => {
const rows = new Rows();
@@ -359,9 +367,6 @@ const DESCRIBERS: Readonly<Record<string, Describer>> = {
},
create_budget_item: (ctx, args) => {
const placeId = id(args, 'place_id');
const split = Array.isArray(args.members)
? args.members.map((member: unknown) => (member as { user_id?: unknown } | null)?.user_id)
: args.member_ids;
const rows = new Rows()
.add(
'price',
@@ -370,11 +375,25 @@ const DESCRIBERS: Readonly<Record<string, Describer>> = {
: '',
)
.add('category', text(args.category))
.add('members', memberNames(ctx, split))
.add('members', memberNames(ctx, splitOf(args)))
.add('location', placeName(ctx, placeId))
.add('notes', text(args.note));
return { subject: text(args.name), rows, dayId: null, placeIds: placeId ? [placeId] : [] };
},
update_budget_item: (ctx, args) => {
const price =
typeof args.total_price === 'number'
? money(ctx, args.total_price, text(args.currency) || ctx.card.currency)
: '';
const rows = new Rows()
.add('name', text(args.name))
.add('price', price)
.add('category', text(args.category))
.add('members', memberNames(ctx, splitOf(args)))
.add('notes', text(args.note));
return { subject: text(args.name) || `#${id(args, 'itemId') ?? ''}`, rows, dayId: null, placeIds: [] };
},
[REMEMBER_TOOL]: (_ctx, args) => ({ subject: noteOf(args) ?? '', rows: new Rows(), dayId: null, placeIds: [] }),
create_reservation: (ctx, args) => {
const placeId = id(args, 'place_id');
const startDay = id(args, 'start_day_id');
+2
View File
@@ -236,6 +236,8 @@ describe('platform/discovery parity pins', () => {
code_challenge_methods_supported: ['S256'],
token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
scopes_supported: ALL_SCOPES,
// A client may name itself by its metadata document's URL (Claude's connector does).
client_id_metadata_document_supported: true,
userinfo_endpoint: 'https://trek.example.com/oauth/userinfo',
});
});
@@ -66,7 +66,7 @@ describe('ModelCatalogService', () => {
safeFetch.mockResolvedValueOnce(json(200, { data: [{ id: 'claude-sonnet-5', display_name: 'Claude Sonnet 5' }] }));
const key = secret();
expect(await catalog().list({ kind: 'anthropic', baseUrl: null, secret: key })).toEqual([
{ id: 'claude-sonnet-5', label: 'Claude Sonnet 5', supportsTools: true },
{ id: 'claude-sonnet-5', label: 'Claude Sonnet 5', supportsTools: true, recommended: true },
]);
const [url, init] = safeFetch.mock.calls[0] as [string, RequestInit, { maxBytes: number }];
expect(url).toBe('https://api.anthropic.com/v1/models?limit=1000');
@@ -117,7 +117,7 @@ describe('ModelCatalogService', () => {
}),
);
expect(await catalog().list({ kind: 'openrouter', baseUrl: null, secret: secret() })).toEqual([
{ id: 'anthropic/claude-sonnet-5', label: 'Sonnet', supportsTools: true, free: false },
{ id: 'anthropic/claude-sonnet-5', label: 'Sonnet', supportsTools: true, free: false, recommended: true },
{ id: 'meta/llama:free', label: 'meta/llama:free', supportsTools: true, free: true },
]);
expect(safeFetch.mock.calls.map((call) => call[0])).toEqual([
@@ -167,7 +167,7 @@ describe('ModelCatalogService', () => {
}),
);
expect(await catalog().list({ kind: 'chatgpt', baseUrl: null, secret: secret() })).toEqual([
{ id: 'gpt-6.1-sol', label: 'GPT-6.1 Sol', supportsTools: true },
{ id: 'gpt-6.1-sol', label: 'GPT-6.1 Sol', supportsTools: true, recommended: true },
]);
safeFetch.mockResolvedValueOnce(json(500, {}));
expect((await catalog().list({ kind: 'chatgpt', baseUrl: null, secret: secret() }))[0]?.id).toBe('gpt-6.1-sol');
@@ -183,7 +183,7 @@ describe('ModelCatalogService', () => {
it('MCAT-007: a custom server is listed through LlmLocalService, its failure is provider_unavailable', async () => {
llmLocal.listModels.mockResolvedValueOnce({ models: [{ name: 'qwen3:14b', size: 9 }] });
expect(await catalog().list({ kind: 'custom', baseUrl: 'http://ollama:11434/v1', secret: null })).toEqual([
{ id: 'qwen3:14b', label: 'qwen3:14b', supportsTools: true },
{ id: 'qwen3:14b', label: 'qwen3:14b', supportsTools: true, recommended: true },
]);
expect(llmLocal.listModels).toHaveBeenCalledWith('http://ollama:11434/v1', undefined);
llmLocal.listModels.mockRejectedValueOnce(new DomainError(502, 'Could not reach local LLM server'));
@@ -224,8 +224,9 @@ describe('ModelCatalogService', () => {
).toEqual([
['gemini-3-flash', true],
['gemini-3-pro', false],
['gemini-2.5-pro', false],
// The same version reads in name order.
['gemini-2.5-flash', false],
['gemini-2.5-pro', false],
]);
});
});
+2 -1
View File
@@ -9,5 +9,6 @@
"noEmit": true,
"strictNullChecks": true,
"noImplicitAny": true
}
},
"include": ["src", "scripts/strict-typings"]
}
+3 -1
View File
@@ -63,8 +63,10 @@ export const ASSISTANT_CHANGE_VERB_KEYS = [
'updatePackingItem',
'addTodo',
'addBudgetItem',
'updateBudgetItem',
'addReservation',
'importBooking',
'rememberNote',
] as const;
export const assistantChangeVerbKeySchema = z.enum(ASSISTANT_CHANGE_VERB_KEYS);
export type AssistantChangeVerbKey = z.infer<typeof assistantChangeVerbKeySchema>;
@@ -271,7 +273,7 @@ export type AssistantChangePreview = z.infer<typeof assistantChangePreviewSchema
/** An applied change: what it touched, and until when it can be undone. */
export const assistantAppliedSchema = z.object({
toolCallId: z.string(),
entity: z.enum(['place', 'assignment', 'day_note', 'packing', 'todo', 'budget', 'reservation']),
entity: z.enum(['place', 'assignment', 'day_note', 'packing', 'todo', 'budget', 'reservation', 'memory']),
ids: z.array(idSchema),
dayId: idSchema.nullable(),
undoUntil: z.string().nullable(),
+4
View File
@@ -488,5 +488,9 @@ const assistant: TranslationStrings = {
'assistant.week.showAll.two': 'عرض التغييرين',
'assistant.week.showAll.few': 'عرض التغييرات الـ {count}',
'assistant.week.showAll.many': 'عرض التغييرات الـ {count}',
'assistant.change.verb.updateBudgetItem': 'تعديل المصروف',
'assistant.change.verb.rememberNote': 'تذكّر لهذه الرحلة',
'assistant.change.details': 'التفاصيل',
'assistant.change.detailsHide': 'إخفاء التفاصيل',
};
export default assistant;
+4
View File
@@ -396,5 +396,9 @@ const assistant: TranslationStrings = {
'assistant.week.changes.one': '{count} dəyişiklik',
'assistant.week.showAll': 'Bütün {count} dəyişikliyi göstər',
'assistant.week.showAll.one': '{count} dəyişikliyi göstər',
'assistant.change.verb.updateBudgetItem': 'Xərci dəyiş',
'assistant.change.verb.rememberNote': 'Bu səyahət üçün yadda saxla',
'assistant.change.details': 'Təfərrüatlar',
'assistant.change.detailsHide': 'Təfərrüatları gizlət',
};
export default assistant;
+4
View File
@@ -396,5 +396,9 @@ const assistant: TranslationStrings = {
'assistant.week.changes.one': "{count} c'hemm",
'assistant.week.showAll': 'Diskouez an {count} kemm',
'assistant.week.showAll.one': "Diskouez {count} c'hemm",
'assistant.change.verb.updateBudgetItem': 'Kemmañ an dispign',
'assistant.change.verb.rememberNote': 'Derc’hel soñj evit ar veaj-mañ',
'assistant.change.details': 'Munudoù',
'assistant.change.detailsHide': 'Kuzhat ar munudoù',
};
export default assistant;
+4
View File
@@ -399,5 +399,9 @@ const assistant: TranslationStrings = {
'assistant.week.changes.one': '{count} canvi',
'assistant.week.showAll': 'Mostra els {count} canvis',
'assistant.week.showAll.one': 'Mostra {count} canvi',
'assistant.change.verb.updateBudgetItem': 'Canvia la despesa',
'assistant.change.verb.rememberNote': 'Recorda per a aquest viatge',
'assistant.change.details': 'Detalls',
'assistant.change.detailsHide': 'Amaga els detalls',
};
export default assistant;
+4
View File
@@ -418,5 +418,9 @@ const assistant: TranslationStrings = {
'assistant.week.showAll': 'Zobrazit všech {count} změn',
'assistant.week.showAll.one': 'Zobrazit {count} změnu',
'assistant.week.showAll.few': 'Zobrazit všechny {count} změny',
'assistant.change.verb.updateBudgetItem': 'Změnit výdaj',
'assistant.change.verb.rememberNote': 'Zapamatovat pro tuto cestu',
'assistant.change.details': 'Podrobnosti',
'assistant.change.detailsHide': 'Skrýt podrobnosti',
};
export default assistant;
+4
View File
@@ -396,5 +396,9 @@ const assistant: TranslationStrings = {
'assistant.week.changes.one': '{count} Änderung',
'assistant.week.showAll': 'Alle {count} Änderungen zeigen',
'assistant.week.showAll.one': '{count} Änderung zeigen',
'assistant.change.verb.updateBudgetItem': 'Ausgabe ändern',
'assistant.change.verb.rememberNote': 'Für diese Reise merken',
'assistant.change.details': 'Details',
'assistant.change.detailsHide': 'Details ausblenden',
};
export default assistant;
+4
View File
@@ -391,5 +391,9 @@ const assistant: TranslationStrings = {
'assistant.week.changes.one': '{count} change',
'assistant.week.showAll': 'Show all {count} changes',
'assistant.week.showAll.one': 'Show {count} change',
'assistant.change.verb.updateBudgetItem': 'Change expense',
'assistant.change.verb.rememberNote': 'Remember for this trip',
'assistant.change.details': 'Details',
'assistant.change.detailsHide': 'Hide details',
};
export default assistant;
+4
View File
@@ -394,5 +394,9 @@ const assistant: TranslationStrings = {
'assistant.week.changes.one': '{count} cambio',
'assistant.week.showAll': 'Ver los {count} cambios',
'assistant.week.showAll.one': 'Ver {count} cambio',
'assistant.change.verb.updateBudgetItem': 'Cambiar gasto',
'assistant.change.verb.rememberNote': 'Recordar para este viaje',
'assistant.change.details': 'Detalles',
'assistant.change.detailsHide': 'Ocultar detalles',
};
export default assistant;
+4
View File
@@ -395,5 +395,9 @@ const assistant: TranslationStrings = {
'assistant.week.changes.one': '{count} muudatus',
'assistant.week.showAll': 'Näita kõiki {count} muudatust',
'assistant.week.showAll.one': 'Näita {count} muudatust',
'assistant.change.verb.updateBudgetItem': 'Muuda kulu',
'assistant.change.verb.rememberNote': 'Jäta selle reisi jaoks meelde',
'assistant.change.details': 'Üksikasjad',
'assistant.change.detailsHide': 'Peida üksikasjad',
};
export default assistant;
+4
View File
@@ -401,5 +401,9 @@ const assistant: TranslationStrings = {
'assistant.week.changes.one': '{count} modification',
'assistant.week.showAll': 'Afficher les {count} modifications',
'assistant.week.showAll.one': 'Afficher {count} modification',
'assistant.change.verb.updateBudgetItem': 'Modifier la dépense',
'assistant.change.verb.rememberNote': 'Retenir pour ce voyage',
'assistant.change.details': 'Détails',
'assistant.change.detailsHide': 'Masquer les détails',
};
export default assistant;
+4
View File
@@ -400,5 +400,9 @@ const assistant: TranslationStrings = {
'assistant.week.changes.one': '{count} αλλαγή',
'assistant.week.showAll': 'Εμφάνιση όλων των {count} αλλαγών',
'assistant.week.showAll.one': 'Εμφάνιση {count} αλλαγής',
'assistant.change.verb.updateBudgetItem': 'Αλλαγή εξόδου',
'assistant.change.verb.rememberNote': 'Απομνημόνευση για αυτό το ταξίδι',
'assistant.change.details': 'Λεπτομέρειες',
'assistant.change.detailsHide': 'Απόκρυψη λεπτομερειών',
};
export default assistant;
+4
View File
@@ -401,5 +401,9 @@ const assistant: TranslationStrings = {
'assistant.week.changes.one': '{count} módosítás',
'assistant.week.showAll': 'Mind a {count} módosítás mutatása',
'assistant.week.showAll.one': '{count} módosítás mutatása',
'assistant.change.verb.updateBudgetItem': 'Kiadás módosítása',
'assistant.change.verb.rememberNote': 'Megjegyezni ehhez az utazáshoz',
'assistant.change.details': 'Részletek',
'assistant.change.detailsHide': 'Részletek elrejtése',
};
export default assistant;
+4
View File
@@ -373,5 +373,9 @@ const assistant: TranslationStrings = {
'assistant.check.issues': '{count} hal perlu dicek',
'assistant.week.changes': '{count} perubahan',
'assistant.week.showAll': 'Tampilkan semua {count} perubahan',
'assistant.change.verb.updateBudgetItem': 'Ubah pengeluaran',
'assistant.change.verb.rememberNote': 'Ingat untuk perjalanan ini',
'assistant.change.details': 'Detail',
'assistant.change.detailsHide': 'Sembunyikan detail',
};
export default assistant;
+4
View File
@@ -402,5 +402,9 @@ const assistant: TranslationStrings = {
'assistant.week.changes.one': '{count} modifica',
'assistant.week.showAll': 'Mostra tutte le {count} modifiche',
'assistant.week.showAll.one': 'Mostra {count} modifica',
'assistant.change.verb.updateBudgetItem': 'Modifica spesa',
'assistant.change.verb.rememberNote': 'Ricorda per questo viaggio',
'assistant.change.details': 'Dettagli',
'assistant.change.detailsHide': 'Nascondi dettagli',
};
export default assistant;
+4
View File
@@ -368,5 +368,9 @@ const assistant: TranslationStrings = {
'assistant.check.issues': '確認事項 {count} 件',
'assistant.week.changes': '{count} 件の変更',
'assistant.week.showAll': '{count} 件の変更をすべて表示',
'assistant.change.verb.updateBudgetItem': '支出を変更',
'assistant.change.verb.rememberNote': 'この旅行のために覚えておく',
'assistant.change.details': '詳細',
'assistant.change.detailsHide': '詳細を隠す',
};
export default assistant;
+4
View File
@@ -365,5 +365,9 @@ const assistant: TranslationStrings = {
'assistant.check.issues': '확인할 점 {count}개',
'assistant.week.changes': '변경 {count}개',
'assistant.week.showAll': '변경 {count}개 모두 보기',
'assistant.change.verb.updateBudgetItem': '지출 변경',
'assistant.change.verb.rememberNote': '이 여행에 기억하기',
'assistant.change.details': '세부 정보',
'assistant.change.detailsHide': '세부 정보 숨기기',
};
export default assistant;
+4
View File
@@ -397,5 +397,9 @@ const assistant: TranslationStrings = {
'assistant.week.changes.one': '{count} wijziging',
'assistant.week.showAll': 'Alle {count} wijzigingen tonen',
'assistant.week.showAll.one': '{count} wijziging tonen',
'assistant.change.verb.updateBudgetItem': 'Uitgave wijzigen',
'assistant.change.verb.rememberNote': 'Onthouden voor deze reis',
'assistant.change.details': 'Details',
'assistant.change.detailsHide': 'Details verbergen',
};
export default assistant;
+4
View File
@@ -445,5 +445,9 @@ const assistant: TranslationStrings = {
'assistant.week.showAll.one': 'Pokaż {count} zmianę',
'assistant.week.showAll.few': 'Pokaż wszystkie {count} zmiany',
'assistant.week.showAll.many': 'Pokaż wszystkie {count} zmian',
'assistant.change.verb.updateBudgetItem': 'Zmień wydatek',
'assistant.change.verb.rememberNote': 'Zapamiętaj dla tej podróży',
'assistant.change.details': 'Szczegóły',
'assistant.change.detailsHide': 'Ukryj szczegóły',
};
export default assistant;
+4
View File
@@ -445,5 +445,9 @@ const assistant: TranslationStrings = {
'assistant.week.showAll.one': 'Показать {count} изменение',
'assistant.week.showAll.few': 'Показать все {count} изменения',
'assistant.week.showAll.many': 'Показать все {count} изменений',
'assistant.change.verb.updateBudgetItem': 'Изменить расход',
'assistant.change.verb.rememberNote': 'Запомнить для этой поездки',
'assistant.change.details': 'Подробности',
'assistant.change.detailsHide': 'Скрыть подробности',
};
export default assistant;
+4
View File
@@ -418,5 +418,9 @@ const assistant: TranslationStrings = {
'assistant.week.showAll': 'Zobraziť všetkých {count} zmien',
'assistant.week.showAll.one': 'Zobraziť {count} zmenu',
'assistant.week.showAll.few': 'Zobraziť všetky {count} zmeny',
'assistant.change.verb.updateBudgetItem': 'Zmeniť výdavok',
'assistant.change.verb.rememberNote': 'Zapamätať pre túto cestu',
'assistant.change.details': 'Podrobnosti',
'assistant.change.detailsHide': 'Skryť podrobnosti',
};
export default assistant;
+4
View File
@@ -395,5 +395,9 @@ const assistant: TranslationStrings = {
'assistant.week.changes.one': '{count} ändring',
'assistant.week.showAll': 'Visa alla {count} ändringar',
'assistant.week.showAll.one': 'Visa {count} ändring',
'assistant.change.verb.updateBudgetItem': 'Ändra utgift',
'assistant.change.verb.rememberNote': 'Kom ihåg för den här resan',
'assistant.change.details': 'Detaljer',
'assistant.change.detailsHide': 'Dölj detaljer',
};
export default assistant;
+4
View File
@@ -365,5 +365,9 @@ const assistant: TranslationStrings = {
'assistant.check.issues': 'มี {count} จุดที่ควรตรวจ',
'assistant.week.changes': 'การเปลี่ยนแปลง {count} รายการ',
'assistant.week.showAll': 'แสดงการเปลี่ยนแปลงทั้ง {count} รายการ',
'assistant.change.verb.updateBudgetItem': 'แก้ไขค่าใช้จ่าย',
'assistant.change.verb.rememberNote': 'จำไว้สำหรับทริปนี้',
'assistant.change.details': 'รายละเอียด',
'assistant.change.detailsHide': 'ซ่อนรายละเอียด',
};
export default assistant;
+4
View File
@@ -395,5 +395,9 @@ const assistant: TranslationStrings = {
'assistant.week.changes.one': '{count} değişiklik',
'assistant.week.showAll': '{count} değişikliğin tümünü göster',
'assistant.week.showAll.one': '{count} değişikliği göster',
'assistant.change.verb.updateBudgetItem': 'Harcamayı değiştir',
'assistant.change.verb.rememberNote': 'Bu gezi için hatırla',
'assistant.change.details': 'Ayrıntılar',
'assistant.change.detailsHide': 'Ayrıntıları gizle',
};
export default assistant;
+4
View File
@@ -443,5 +443,9 @@ const assistant: TranslationStrings = {
'assistant.week.showAll.one': 'Показати {count} зміну',
'assistant.week.showAll.few': 'Показати всі {count} зміни',
'assistant.week.showAll.many': 'Показати всі {count} змін',
'assistant.change.verb.updateBudgetItem': 'Змінити витрату',
'assistant.change.verb.rememberNote': 'Запам’ятати для цієї подорожі',
'assistant.change.details': 'Подробиці',
'assistant.change.detailsHide': 'Сховати подробиці',
};
export default assistant;
+4
View File
@@ -368,5 +368,9 @@ const assistant: TranslationStrings = {
'assistant.check.issues': '{count} điểm cần xem',
'assistant.week.changes': '{count} thay đổi',
'assistant.week.showAll': 'Hiện tất cả {count} thay đổi',
'assistant.change.verb.updateBudgetItem': 'Sửa khoản chi',
'assistant.change.verb.rememberNote': 'Ghi nhớ cho chuyến đi này',
'assistant.change.details': 'Chi tiết',
'assistant.change.detailsHide': 'Ẩn chi tiết',
};
export default assistant;
+4
View File
@@ -362,5 +362,9 @@ const assistant: TranslationStrings = {
'assistant.check.issues': '{count} 處需要注意',
'assistant.week.changes': '{count} 項變更',
'assistant.week.showAll': '顯示全部 {count} 項變更',
'assistant.change.verb.updateBudgetItem': '修改支出',
'assistant.change.verb.rememberNote': '為此行程記住',
'assistant.change.details': '詳細資料',
'assistant.change.detailsHide': '隱藏詳細資料',
};
export default assistant;
+4
View File
@@ -362,5 +362,9 @@ const assistant: TranslationStrings = {
'assistant.check.issues': '{count} 处需要注意',
'assistant.week.changes': '{count} 项更改',
'assistant.week.showAll': '显示全部 {count} 项更改',
'assistant.change.verb.updateBudgetItem': '修改支出',
'assistant.change.verb.rememberNote': '为此行程记住',
'assistant.change.details': '详情',
'assistant.change.detailsHide': '隐藏详情',
};
export default assistant;
+1 -9
View File
@@ -5,15 +5,7 @@
*/
export type PlaceKind =
| 'lodging'
| 'food'
| 'drinks'
| 'sight'
| 'shopping'
| 'transport'
| 'beach'
| 'nature'
| 'activity';
'lodging' | 'food' | 'drinks' | 'sight' | 'shopping' | 'transport' | 'beach' | 'nature' | 'activity';
/** A trip category as far as the guess reads it. */
export interface PlaceKindCategory {