fix(assistant): lint, types and tests for the CI round

- agent loop and decisions without await in loops: steps as recursion,
  calls and applied changes in order through one promise chain, the
  independent reads and claims in parallel; part mapping in its own helpers
- place card, header and weather strip without refs or components created
  in render; long hooks and components split; state reset during render
- trip notes only in a turn's tool session, never an undo's; map view
  reported only while the assistant can run
- place strip scrolls through the strip's own smooth scroll, no physical side
- tests follow the raised limits and the new card fields; toasts read through
  a mocked useToast instead of a window global; baselines lowered where the
  counts fell
This commit is contained in:
Maurice
2026-10-21 21:45:25 +02:00
parent b6f7488591
commit 3c3a228737
47 changed files with 892 additions and 610 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
{
"src": {
"@typescript-eslint/no-non-null-assertion": 404,
"@typescript-eslint/no-misused-promises": 874,
"@typescript-eslint/no-misused-promises": 870,
"no-console": 88,
"complexity": 230,
"@typescript-eslint/no-explicit-any": 147,
@@ -35,7 +35,7 @@
},
"tests": {
"@typescript-eslint/no-explicit-any": 491,
"@typescript-eslint/no-non-null-assertion": 1946,
"@typescript-eslint/no-non-null-assertion": 1945,
"@typescript-eslint/no-misused-promises": 22,
"@typescript-eslint/no-unused-vars": 51,
"@typescript-eslint/no-floating-promises": 33,
-1
View File
@@ -148,7 +148,6 @@
"tests/integration/api/client.test.ts": 1,
"tests/integration/hooks/useRouteCalculation.test.ts": 1,
"tests/unit/i18n/i18nKeysScript.test.ts": 3,
"tests/unit/mobile/admin/MAdminAddonManager.test.tsx": 2,
"tests/unit/mobile/admin/MAdminGitHubPanel.test.tsx": 1,
"tests/unit/mobile/admin/MAdminNotifyMatrix.test.tsx": 1,
"tests/unit/mobile/admin/MAdminStoragePanel.test.tsx": 1,
+1 -1
View File
@@ -156,7 +156,7 @@
"mobile/components/MToastHost.tsx": 2,
"mobile/components/MUserPickerList.tsx": 1,
"mobile/screens/admin/MAdmin.tsx": 9,
"mobile/screens/admin/MAdminAddonManager.tsx": 26,
"mobile/screens/admin/MAdminAddonManager.tsx": 13,
"mobile/screens/admin/MAdminAuditLogPanel.tsx": 15,
"mobile/screens/admin/MAdminBackupPanel.tsx": 8,
"mobile/screens/admin/MAdminCategoryManager.tsx": 8,
@@ -167,7 +167,9 @@ describe('AI parsing', () => {
expect(
await screen.findByText('No connection yet. Set one up for everyone, or give AI parsing its own.')
).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Connection' }));
// The link in the hint, not the tab of the same name above it.
const link = screen.getAllByRole('button', { name: 'Connection' }).find((b) => !b.hasAttribute('aria-pressed'));
await user.click(link as HTMLElement);
expect(await screen.findByRole('radio', { name: 'I pay, own allowed' })).toBeInTheDocument();
});
@@ -26,6 +26,72 @@ function savedOf(cfg: Record<string, unknown>): SavedParsing {
};
}
/**
* The models a local (Ollama) server has, and pulling the recommended ones.
* The list loads while the form shows the local provider: a provider change or
* opening the form loads it, the address field's own blur reloads it.
*/
function useLocalModels(baseUrl: string, isLocal: boolean, formOpen: boolean, onPulled: (id: string) => void) {
const { t } = useTranslation();
const toast = useToast();
const [installed, setInstalled] = useState<string[]>([]);
const [modelsErr, setModelsErr] = useState('');
const [loadingModels, setLoadingModels] = useState(false);
const [pulling, setPulling] = useState<string | null>(null);
const [pullPct, setPullPct] = useState(0);
const [pullStatus, setPullStatus] = useState('');
const effectiveUrl = baseUrl.trim() || DEFAULT_OLLAMA_URL;
const isInstalled = (id: string) => installed.some((n) => n === id || n.startsWith(id + ':') || n.startsWith(id));
const loadModels = async () => {
if (!isLocal) return;
setLoadingModels(true);
setModelsErr('');
try {
const res = await adminApi.llmLocalModels(effectiveUrl);
setInstalled(res.models.map((m) => m.name));
} catch (e: unknown) {
// The server says why (unreachable, key refused); axios' own status line does not.
setModelsErr(getApiErrorMessage(e, t('admin.addons.ai.parsing.unreachable')));
setInstalled([]);
} finally {
setLoadingModels(false);
}
};
const loadModelsForProvider = useEffectEvent(() => {
if (formOpen && isLocal) void loadModels();
});
useEffect(() => {
loadModelsForProvider();
}, [isLocal, formOpen]);
const pull = async (id: string) => {
if (pulling) return;
setPulling(id);
setPullPct(0);
setPullStatus(t('admin.addons.ai.parsing.pulling'));
try {
await adminApi.llmLocalPull(effectiveUrl, id, (p) => {
if (p.error) throw new Error(p.error);
if (p.status) setPullStatus(p.status);
if (p.total && p.completed != null) setPullPct(Math.round((p.completed / p.total) * 100));
});
toast.success(t('admin.addons.ai.parsing.pulled'));
onPulled(id);
await loadModels();
} catch (e: unknown) {
toast.error(e instanceof Error && e.message ? e.message : t('admin.addons.ai.parsing.pullFailed'));
} finally {
setPulling(null);
setPullPct(0);
setPullStatus('');
}
};
return { installed, modelsErr, loadingModels, pulling, pullPct, pullStatus, isInstalled, loadModels, pull };
}
/**
* AI parsing's admin settings behind both admin shells. Parsing reads with the
* AI connection the assistant runs on unless the instance keeps one of its own
@@ -45,14 +111,6 @@ export function useLlmParsingConfig(addon: Addon) {
const [apiKey, setApiKey] = useState<string>(saved.apiKey);
const [saving, setSaving] = useState(false);
// Local-provider model management.
const [installed, setInstalled] = useState<string[]>([]);
const [modelsErr, setModelsErr] = useState('');
const [loadingModels, setLoadingModels] = useState(false);
const [pulling, setPulling] = useState<string | null>(null);
const [pullPct, setPullPct] = useState(0);
const [pullStatus, setPullStatus] = useState('');
/** A connection of its own, which wins over the shared one. */
const ownConnection = !!(saved.provider && saved.model);
const formOpen = ownConnection || editing;
@@ -68,57 +126,7 @@ export function useLlmParsingConfig(addon: Addon) {
setProviderState(next);
};
const baseUrlPlaceholder = LLM_BASE_URL_PLACEHOLDER[provider] ?? LLM_BASE_URL_PLACEHOLDER.openai;
const effectiveUrl = baseUrl.trim() || DEFAULT_OLLAMA_URL;
const isInstalled = (id: string) => installed.some((n) => n === id || n.startsWith(id + ':') || n.startsWith(id));
const loadModels = async () => {
if (provider !== 'local') return;
setLoadingModels(true);
setModelsErr('');
try {
const res = await adminApi.llmLocalModels(effectiveUrl);
setInstalled(res.models.map((m) => m.name));
} catch (e: unknown) {
// The server says why (unreachable, key refused); axios' own status line does not.
setModelsErr(getApiErrorMessage(e, t('admin.addons.ai.parsing.unreachable')));
setInstalled([]);
} finally {
setLoadingModels(false);
}
};
// Load installed models while the form shows the local provider. Only a provider
// change (or opening the form) triggers it; the base URL's own blur reloads.
const loadModelsForProvider = useEffectEvent(() => {
if (formOpen && provider === 'local') void loadModels();
});
useEffect(() => {
loadModelsForProvider();
}, [provider, formOpen]);
const pull = async (id: string) => {
if (pulling) return;
setPulling(id);
setPullPct(0);
setPullStatus(t('admin.addons.ai.parsing.pulling'));
try {
await adminApi.llmLocalPull(effectiveUrl, id, (p) => {
if (p.error) throw new Error(p.error);
if (p.status) setPullStatus(p.status);
if (p.total && p.completed != null) setPullPct(Math.round((p.completed / p.total) * 100));
});
toast.success(t('admin.addons.ai.parsing.pulled'));
setModel(id);
await loadModels();
} catch (e: unknown) {
toast.error(e instanceof Error && e.message ? e.message : t('admin.addons.ai.parsing.pullFailed'));
} finally {
setPulling(null);
setPullPct(0);
setPullStatus('');
}
};
const local = useLocalModels(baseUrl, provider === 'local', formOpen, setModel);
/** Writes the config; the stored key stays as long as the mask is sent in its place. */
const write = async (next: SavedParsing, config: Record<string, unknown>): Promise<boolean> => {
@@ -186,15 +194,7 @@ export function useLlmParsingConfig(addon: Addon) {
startOwn: () => setEditing(true),
cancelOwn: () => setEditing(false),
switchToShared,
installed,
modelsErr,
loadingModels,
pulling,
pullPct,
pullStatus,
isInstalled,
loadModels,
pull,
...local,
save,
};
}
@@ -20,6 +20,18 @@ import AssistantCompareSwitch from './AssistantCompareSwitch';
import type { ChangeItem } from './assistantParts';
import { type AssistantView, AssistantViewContext } from './assistantViewContext';
const { addToast } = vi.hoisted(() => ({ addToast: vi.fn() }));
// The toasts a component raises, read as (message, type) the way the toast host takes them.
vi.mock('../shared/Toast', async (importOriginal) => ({
...(await importOriginal<typeof import('../shared/Toast')>()),
useToast: () => ({
success: (message: string) => addToast(message, 'success', undefined),
error: (message: string) => addToast(message, 'error', undefined),
warning: (message: string) => addToast(message, 'warning', undefined),
info: (message: string) => addToast(message, 'info', undefined),
}),
}));
const TRIP = 4;
const INITIAL = useAssistantStore.getState();
const TRIP_INITIAL = useTripStore.getState();
@@ -98,18 +110,15 @@ const undoBlock =
const setCompare = vi.fn<(tripId: number, mode: 'before' | 'after') => void>();
const requestCamera = vi.fn();
const setMinimized = vi.fn();
const addToast = vi.fn();
beforeEach(() => {
decide.mockResolvedValue(undefined);
useAssistantStore.setState({ decide, undo, undoBlock, setCompare, requestCamera, setMinimized });
window.__addToast = addToast as unknown as typeof window.__addToast;
});
afterEach(() => {
useAssistantStore.setState(INITIAL, true);
useTripStore.setState(TRIP_INITIAL, true);
delete window.__addToast;
vi.clearAllMocks();
vi.useRealTimers();
});
@@ -119,9 +128,13 @@ describe('AssistantChangeCard: deciding', () => {
renderCard([pending('c1', 'Kinkaku-ji')]);
expect(screen.getByText('Kinkaku-ji')).toBeInTheDocument();
expect(screen.getByText('Add place')).toBeInTheDocument();
expect(screen.getByText('Address')).toBeInTheDocument();
// The time is a chip of its own, old to new; the address waits behind "Details".
expect(screen.getByText('09:00')).toBeInTheDocument();
expect(screen.getByText('10:30')).toBeInTheDocument();
expect(screen.queryByText('Kyoto')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Details' }));
expect(screen.getByText('Address')).toBeInTheDocument();
expect(screen.getByText('Kyoto')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Include this change' })).toBeNull();
expect(screen.getByRole('status')).toHaveTextContent('1 change is waiting for you');
});
@@ -80,7 +80,7 @@ describe('AssistantComposer mentions', () => {
fireEvent.keyDown(field, { key: 'Enter' });
await waitFor(() => expect(field.value).toBe('Is @Louvre '));
expect(send).not.toHaveBeenCalled();
expect(screen.queryByRole('listbox')).toBeNull();
await waitFor(() => expect(screen.queryByRole('listbox')).toBeNull());
expect(mentions()).toEqual([{ kind: 'place', id: 21, label: 'Louvre' }]);
expect(chatOf(useAssistantStore.getState(), TRIP).draft).toBe('Is @Louvre ');
// The pick shows in the text only, never as a chip above it.
@@ -48,6 +48,7 @@ export default function AssistantHeader({ tripId, thread, tripTitle, onClose, dr
const [confirming, setConfirming] = useState(false);
const threads = thread.threads ?? [];
const history = usePopoverList(threads.findIndex((th) => th.id === thread.threadId) + 1);
const { open: historyOpen, triggerRef: historyTrigger, listId: historyListId } = history;
const hints = useAssistantHintsSetting();
const openHistory = () => {
@@ -140,12 +141,12 @@ export default function AssistantHeader({ tripId, thread, tripTitle, onClose, dr
</Tooltip>
<Tooltip label={t('assistant.history.open')}>
<button
ref={history.triggerRef}
ref={historyTrigger}
type="button"
onClick={openHistory}
aria-label={t('assistant.history.open')}
aria-expanded={history.open}
aria-controls={history.open ? history.listId : undefined}
aria-expanded={historyOpen}
aria-controls={historyOpen ? historyListId : undefined}
className={ICON_BUTTON}
>
<History size={13} aria-hidden="true" />
@@ -1,6 +1,6 @@
import type { AssistantFoundPlace } from '@trek/shared';
import { Check, ChevronDown, LoaderCircle, MapPin, Plus, Star } from 'lucide-react';
import { type ReactNode, useRef } from 'react';
import { createElement, type ReactNode, useRef } from 'react';
import { useTranslation } from '../../i18n';
import { getCategoryIcon } from '../shared/categoryIcons';
@@ -23,11 +23,12 @@ function SuggestButton({ place, strip, tall }: { place: AssistantFoundPlace; str
const list = usePopoverList(focusDay ? days.findIndex((d) => d.id === focusDay.id) : 0, {
estimatedHeight: Math.ceil(days.length / 7) * 44 + 16,
});
const { open, toggle, close, triggerRef, listId } = list;
const height = tall ? 'h-10' : 'h-7';
const busy = adding === place.ref;
const pick = (dayId: number) => {
list.close();
close();
void strip.suggest(place, dayId);
};
@@ -50,12 +51,12 @@ function SuggestButton({ place, strip, tall }: { place: AssistantFoundPlace; str
<span className="truncate">{t('assistant.place.addToShort', { n: focusDay.n })}</span>
</button>
<button
ref={list.triggerRef}
ref={triggerRef}
type="button"
disabled={disabled}
onClick={list.toggle}
aria-expanded={list.open}
aria-controls={list.open ? list.listId : undefined}
onClick={toggle}
aria-expanded={open}
aria-controls={open ? listId : undefined}
aria-label={t('assistant.place.pickDay')}
className={`${ACTION} ${height} rounded-s-none px-1.5`}
>
@@ -64,12 +65,12 @@ function SuggestButton({ place, strip, tall }: { place: AssistantFoundPlace; str
</>
) : (
<button
ref={list.triggerRef}
ref={triggerRef}
type="button"
disabled={disabled}
onClick={list.toggle}
aria-expanded={list.open}
aria-controls={list.open ? list.listId : undefined}
onClick={toggle}
aria-expanded={open}
aria-controls={open ? listId : undefined}
className={`${ACTION} ${height} px-2`}
>
<Plus size={12} aria-hidden="true" />
@@ -122,14 +123,113 @@ function SuggestButton({ place, strip, tall }: { place: AssistantFoundPlace; str
);
}
type Category = ReturnType<Strip['categoryOf']>;
/** The picture, or the category's icon on its colour, with the category and whether it is open on it. */
function PlaceMedia({
place,
photo,
category,
}: {
place: AssistantFoundPlace;
photo: string | null;
category: Category;
}) {
const { t } = useTranslation();
const icon = category ? getCategoryIcon(category.icon) : MapPin;
return (
<span
className="relative block h-24 w-full overflow-hidden bg-surface-secondary"
// Without a picture the category's own colour washes the space, data from the trip.
style={
photo || !category
? undefined
: { background: `color-mix(in srgb, ${category.color} 16%, var(--bg-secondary))` }
}
>
{photo ? (
<img
src={photo}
alt=""
decoding="async"
loading="lazy"
className="h-full w-full object-cover transition-transform duration-300 group-hover/card:scale-[1.04]"
/>
) : (
<span className="grid h-full w-full place-items-center">
{createElement(icon, {
size: 30,
'aria-hidden': true,
style: { color: category?.color ?? 'var(--text-faint)' },
})}
</span>
)}
{category && photo && (
<span className="absolute start-2 top-2 inline-flex max-w-[75%] items-center gap-1 rounded-full bg-surface-card px-2 py-0.5 text-caption font-medium text-content shadow-sm">
{createElement(icon, {
size: 11,
'aria-hidden': true,
className: 'flex-shrink-0',
style: { color: category.color },
})}
<bdi className="truncate">{category.name}</bdi>
</span>
)}
{place.openNow !== null && (
<span className="absolute bottom-2 end-2 inline-flex items-center gap-1 rounded-full bg-surface-card px-2 py-0.5 text-caption font-medium text-content-muted shadow-sm">
<span
aria-hidden="true"
className={`h-1.5 w-1.5 rounded-full ${place.openNow ? 'bg-success' : 'bg-[color:var(--text-faint)]'}`}
/>
{t(place.openNow ? 'assistant.place.openNow' : 'assistant.place.closed')}
</span>
)}
</span>
);
}
/** The card's one way on: "On map" for a place the trip has, "Suggested" once it is, else "+ Day 3". */
function PlaceAction({
place,
strip,
suggested,
tall,
}: {
place: AssistantFoundPlace;
strip: Strip;
suggested: boolean;
tall: boolean;
}): ReactNode {
const { t } = useTranslation();
if (place.inTripPlaceId !== null) {
return (
<Tooltip label={t('assistant.place.inTrip')} placement="top">
<button type="button" onClick={() => strip.show(place)} className={`${ACTION} ${tall ? 'h-10' : 'h-7'} px-2`}>
<MapPin size={12} aria-hidden="true" />
{t('assistant.place.showOnMap')}
</button>
</Tooltip>
);
}
if (suggested) {
return (
<span className="inline-flex h-7 items-center gap-1 text-caption text-content-muted">
<Check size={12} aria-hidden="true" className="text-success" />
{t('assistant.place.suggested')}
</span>
);
}
return strip.canSuggest ? <SuggestButton place={place} strip={strip} tall={tall} /> : null;
}
/**
* One place a search found, as a card: its picture from TREK's photo proxy
* with the category it most likely belongs in on it (or that category's icon
* on its colour), its name, address and rating, whether it is open now while
* the trip runs, how far it is from the last stop of the day in focus, and one
* way on: "+ Day 3" for a new
* place, "On map" for one the trip has. A click on the card shows it on the
* map, and resting the pointer on it pins it there for a moment.
* way on: "+ Day 3" for a new place, "On map" for one the trip has. A click on
* the card shows it on the map, and resting the pointer on it pins it there
* for a moment.
*/
export default function AssistantPlaceCard({
place,
@@ -146,30 +246,8 @@ export default function AssistantPlaceCard({
const photo = useFoundPlacePhoto(place, ref);
const category = strip.categoryOf(place);
const distance = strip.distanceOf(place);
const Icon = category ? getCategoryIcon(category.icon) : MapPin;
const tall = variant === 'fullscreen';
let action: ReactNode = null;
if (place.inTripPlaceId !== null) {
action = (
<Tooltip label={t('assistant.place.inTrip')} placement="top">
<button type="button" onClick={() => strip.show(place)} className={`${ACTION} ${tall ? 'h-10' : 'h-7'} px-2`}>
<MapPin size={12} aria-hidden="true" />
{t('assistant.place.showOnMap')}
</button>
</Tooltip>
);
} else if (suggested) {
action = (
<span className="inline-flex h-7 items-center gap-1 text-caption text-content-muted">
<Check size={12} aria-hidden="true" className="text-success" />
{t('assistant.place.suggested')}
</span>
);
} else if (strip.canSuggest) {
action = <SuggestButton place={place} strip={strip} tall={tall} />;
}
return (
<li
ref={ref}
@@ -186,44 +264,7 @@ export default function AssistantPlaceCard({
onBlur={() => strip.hover(null)}
className="flex flex-col text-start outline-none focus-visible:bg-surface-hover"
>
<span
className="relative block h-24 w-full overflow-hidden bg-surface-secondary"
// Without a picture the category's own colour washes the space, data from the trip.
style={
photo || !category
? undefined
: { background: `color-mix(in srgb, ${category.color} 16%, var(--bg-secondary))` }
}
>
{photo ? (
<img
src={photo}
alt=""
decoding="async"
loading="lazy"
className="h-full w-full object-cover transition-transform duration-300 group-hover/card:scale-[1.04]"
/>
) : (
<span className="grid h-full w-full place-items-center">
<Icon size={30} aria-hidden="true" style={{ color: category?.color ?? 'var(--text-faint)' }} />
</span>
)}
{category && photo && (
<span className="absolute start-2 top-2 inline-flex max-w-[75%] items-center gap-1 rounded-full bg-surface-card px-2 py-0.5 text-caption font-medium text-content shadow-sm">
<Icon size={11} aria-hidden="true" className="flex-shrink-0" style={{ color: category.color }} />
<bdi className="truncate">{category.name}</bdi>
</span>
)}
{place.openNow !== null && (
<span className="absolute bottom-2 end-2 inline-flex items-center gap-1 rounded-full bg-surface-card px-2 py-0.5 text-caption font-medium text-content-muted shadow-sm">
<span
aria-hidden="true"
className={`h-1.5 w-1.5 rounded-full ${place.openNow ? 'bg-success' : 'bg-[color:var(--text-faint)]'}`}
/>
{t(place.openNow ? 'assistant.place.openNow' : 'assistant.place.closed')}
</span>
)}
</span>
<PlaceMedia place={place} photo={photo} category={category} />
<span className="flex min-w-0 flex-col gap-1 px-3 pt-2.5">
<bdi className="line-clamp-2 text-body font-semibold leading-snug text-content">{place.name}</bdi>
{category && !photo && <bdi className="truncate text-caption text-content-muted">{category.name}</bdi>}
@@ -241,7 +282,9 @@ export default function AssistantPlaceCard({
)}
</span>
</button>
<div className="mt-auto flex min-w-0 px-3 pb-3 pt-2.5">{action}</div>
<div className="mt-auto flex min-w-0 px-3 pb-3 pt-2.5">
<PlaceAction place={place} strip={strip} suggested={suggested} tall={tall} />
</div>
</li>
);
}
@@ -3,7 +3,6 @@ import { ChevronLeft, ChevronRight } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from '../../i18n';
import { prefersReducedMotion } from '../../utils/reducedMotion';
import AssistantPlaceCard from './AssistantPlaceCard';
import { suggestedFromStrip } from './assistantParts';
import { useAssistantView } from './assistantViewContext';
@@ -71,10 +70,8 @@ export default function AssistantPlaceStrip({ found, message }: { found: Assista
const step = card.offsetWidth + gap;
const cards = Math.max(1, Math.floor((el.clientWidth - 32) / step));
const rtl = getComputedStyle(el).direction === 'rtl';
el.scrollBy({
left: (rtl ? -towards : towards) * cards * step,
behavior: prefersReducedMotion() ? 'auto' : 'smooth',
});
// Smooth through the strip's own scroll-behavior, which reduced motion turns off.
el.scrollBy((rtl ? -towards : towards) * cards * step, 0);
};
const total = found.places.length + found.more;
@@ -114,7 +111,7 @@ export default function AssistantPlaceStrip({ found, message }: { found: Assista
onScroll={measure}
data-fade-start={fade.start || undefined}
data-fade-end={fade.end || undefined}
className="trek-assistant-strip flex snap-x snap-mandatory scroll-px-4 gap-2.5 overflow-x-auto overscroll-x-contain px-4 pb-2 pt-0.5"
className="trek-assistant-strip flex snap-x snap-mandatory scroll-px-4 gap-2.5 overflow-x-auto overscroll-x-contain px-4 pb-2 pt-0.5 motion-safe:scroll-smooth"
>
{found.places.slice(0, SHOWN).map((place) => (
<AssistantPlaceCard
@@ -13,6 +13,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { buildDay, buildPlace, buildTrip, buildUser } from '../../../tests/helpers/factories';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import { TranslationProvider } from '../../i18n/TranslationContext';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
@@ -197,11 +198,12 @@ describe('the context menu rows', () => {
const openWith = vi.fn();
useAssistantStore.setState({ status: null, send, openWith });
const louvre = buildPlace({ id: 5, name: 'Louvre' });
const off = renderHook(() => useAssistantMenu(TRIP));
const translated = ({ children }: { children: ReactNode }) => <TranslationProvider>{children}</TranslationProvider>;
const off = renderHook(() => useAssistantMenu(TRIP), { wrapper: translated });
expect(off.result.current.place(louvre)).toEqual([]);
useAssistantStore.setState({ status: { available: true } as never });
const on = renderHook(() => useAssistantMenu(TRIP));
const on = renderHook(() => useAssistantMenu(TRIP), { wrapper: translated });
const rows = on.result.current.place(louvre);
const alternative = rows.find((r) => 'label' in r && r.label === 'Find an alternative');
act(() => (alternative && 'onClick' in alternative ? alternative.onClick() : undefined));
@@ -1,5 +1,6 @@
import type { AssistantWeatherDay } from '@trek/shared';
import { Droplet } from 'lucide-react';
import { createElement } from 'react';
import { useTranslation } from '../../i18n';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
@@ -31,7 +32,7 @@ function WeatherChip({
onPick: ((dayId: number) => void) | null;
}) {
const { t, locale } = useTranslation();
const Icon = weatherIconFor(day.main);
const icon = weatherIconFor(day.main);
const { weekday, day: dayOfMonth } = dateParts(day.date, locale);
// A climate figure is an average for the date, not a forecast; it says so with "≈".
const approx = day.kind === 'climate' ? '≈' : '';
@@ -45,7 +46,7 @@ function WeatherChip({
<span className="text-caption text-content-muted">
{weekday} {dayOfMonth}
</span>
<Icon size={16} strokeWidth={1.8} aria-hidden="true" className="text-content-secondary" />
{createElement(icon, { size: 16, strokeWidth: 1.8, 'aria-hidden': true, className: 'text-content-secondary' })}
<span className="flex items-baseline gap-1 tabular-nums">
<span className="text-body font-semibold text-content">{max}</span>
{min && <span className="text-caption text-content-faint">{min}</span>}
@@ -225,6 +225,7 @@ export default function AssistantWindow({ trip, onShowOnMap, onFocusDay }: Assis
const loadStatus = useAssistantStore((s) => s.loadStatus);
const toggle = useAssistantStore((s) => s.toggle);
const [picked, setPicked] = useState<WindowTrip | null>(null);
const [wasOpen, setWasOpen] = useState(open);
useEffect(() => {
void loadStatus();
@@ -232,9 +233,10 @@ export default function AssistantWindow({ trip, onShowOnMap, onFocusDay }: Assis
useAssistantHotkey(available, toggle);
// A trip picked outside the planner is only for this opening; the next one asks again.
useEffect(() => {
if (open !== wasOpen) {
setWasOpen(open);
if (!open) setPicked(null);
}, [open]);
}
const current = trip ?? picked;
const showPicked = useCallback(() => {
@@ -50,14 +50,7 @@ const DETOUR_FACTOR = 1.3;
const TIGHT_LEG_SLACK_MINUTES = 10;
export type HintRule =
| 'overlap'
| 'closed'
| 'outsideHours'
| 'tightLeg'
| 'gap'
| 'longLeg'
| 'noStay'
| 'unplacedBooking';
'overlap' | 'closed' | 'outsideHours' | 'tightLeg' | 'gap' | 'longLeg' | 'noStay' | 'unplacedBooking';
const RULE_ORDER: readonly HintRule[] = [
'overlap',
@@ -363,7 +356,7 @@ function legHints(pd: PlannedDay, ctx: Ctx): AssistantHint[] {
{ lat: from.assignment.place.lat as number, lng: from.assignment.place.lng as number },
{ lat: to.assignment.place.lat as number, lng: to.assignment.place.lng as number }
);
const tooTight = tight ? null : tightLegHint(pd, from, to, km, cls);
const tooTight: AssistantHint | null = tight ? null : tightLegHint(pd, from, to, km, cls);
if (tooTight) {
tight = tooTight;
continue;
@@ -82,6 +82,19 @@ function weatherDays(parts: readonly AssistantMessagePart[]): AssistantWeatherDa
return [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date));
}
/**
* Adds a change to the change block before it, or starts one. A suggestion
* from the strip is a block of its own, decided apart from the changes before
* and after it, even where the answer joins them.
*/
function pushChange(blocks: MessageBlock[], item: ChangeItem, fromStrip: ReadonlySet<string>): void {
const last = blocks[blocks.length - 1];
const lastId = last?.kind === 'change' ? last.items[last.items.length - 1]?.toolCallId : undefined;
const apart = fromStrip.has(item.toolCallId) || (lastId !== undefined && fromStrip.has(lastId));
if (last?.kind === 'change' && !apart) last.items.push(item);
else blocks.push({ kind: 'change', key: `change-${item.toolCallId}`, items: [item] });
}
/** The steps block a new step joins: the last one, past any strip shown after it. */
function openSteps(blocks: readonly MessageBlock[]): Extract<MessageBlock, { kind: 'steps' }> | null {
for (let i = blocks.length - 1; i >= 0; i--) {
@@ -134,7 +147,6 @@ export function messageBlocks(message: AssistantMessage, writing: boolean): Mess
const blocks: MessageBlock[] = [];
message.parts.forEach((part, index) => {
const last = blocks[blocks.length - 1];
if (part.type === 'text') {
if (part.text.trim()) blocks.push({ kind: 'text', key: `text-${index}`, text: part.text });
return;
@@ -153,12 +165,7 @@ export function messageBlocks(message: AssistantMessage, writing: boolean): Mess
if (part.approval || preview) {
const applied = receipts.get(part.toolCallId) ?? null;
const item: ChangeItem = { toolCallId: part.toolCallId, preview, applied, state: changeState(part, applied) };
// A suggestion from the strip is a block of its own, decided apart from
// the changes before and after it, even where the answer joins them.
const lastId = last?.kind === 'change' ? last.items[last.items.length - 1]?.toolCallId : undefined;
const apart = fromStrip.has(part.toolCallId) || (lastId !== undefined && fromStrip.has(lastId));
if (last?.kind === 'change' && !apart) last.items.push(item);
else blocks.push({ kind: 'change', key: `change-${part.toolCallId}`, items: [item] });
pushChange(blocks, item, fromStrip);
return;
}
const step: ToolStep = {
@@ -1,7 +1,7 @@
import { useEffect } from 'react';
import { useTranslation } from '../../i18n';
import { isApplePlatform } from '../shared/Kbd';
import { isApplePlatform } from '../../utils/applePlatform';
/** Whether a key press is the assistant's shortcut: ⌘K on a Mac, Ctrl+K elsewhere, either works on both. */
export function isAssistantHotkey(e: KeyboardEvent): boolean {
@@ -20,6 +20,18 @@ import { useTripStore } from '../../store/tripStore';
import AssistantShareDialog from './AssistantShareDialog';
import { useAssistantShare } from './useAssistantShare';
const { addToast } = vi.hoisted(() => ({ addToast: vi.fn() }));
// The toasts a component raises, read as (message, type) the way the toast host takes them.
vi.mock('../shared/Toast', async (importOriginal) => ({
...(await importOriginal<typeof import('../shared/Toast')>()),
useToast: () => ({
success: (message: string) => addToast(message, 'success', undefined),
error: (message: string) => addToast(message, 'error', undefined),
warning: (message: string) => addToast(message, 'warning', undefined),
info: (message: string) => addToast(message, 'info', undefined),
}),
}));
const TRIP = 4;
const ASSISTANT = useAssistantStore.getState();
const TRIPS = useTripStore.getState();
@@ -73,8 +85,6 @@ function Harness({ message, writing = false }: { message: AssistantMessage; writ
);
}
const addToast = vi.fn();
function seed({ chat = true, threadId = 7 as number | null } = {}) {
useAuthStore.setState({ user: buildUser({ id: 1 }) });
useTripStore.setState({ trip: buildTrip({ id: TRIP, user_id: 1 }) });
@@ -85,9 +95,7 @@ function seed({ chat = true, threadId = 7 as number | null } = {}) {
});
}
beforeEach(() => {
window.__addToast = addToast as unknown as typeof window.__addToast;
});
beforeEach(() => {});
afterEach(() => {
useAssistantStore.setState(ASSISTANT, true);
@@ -95,7 +103,6 @@ afterEach(() => {
useAuthStore.setState(AUTH, true);
useAddonStore.setState(ADDONS, true);
usePermissionsStore.setState(PERMISSIONS, true);
delete window.__addToast;
vi.restoreAllMocks();
vi.clearAllMocks();
});
@@ -14,6 +14,18 @@ import { TranslationProvider } from '../../i18n/TranslationContext';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useAssistantThread } from './useAssistantThread';
const { addToast } = vi.hoisted(() => ({ addToast: vi.fn() }));
// The toasts a component raises, read as (message, type) the way the toast host takes them.
vi.mock('../shared/Toast', async (importOriginal) => ({
...(await importOriginal<typeof import('../shared/Toast')>()),
useToast: () => ({
success: (message: string) => addToast(message, 'success', undefined),
error: (message: string) => addToast(message, 'error', undefined),
warning: (message: string) => addToast(message, 'warning', undefined),
info: (message: string) => addToast(message, 'info', undefined),
}),
}));
const TRIP = 4;
const INITIAL = useAssistantStore.getState();
@@ -21,7 +33,6 @@ const resume = vi.fn(async () => {});
const cancel = vi.fn();
const deleteThread = vi.fn(async () => {});
const openThread = vi.fn(async () => {});
const addToast = vi.fn();
const wrapper = ({ children }: { children: ReactNode }) => <TranslationProvider>{children}</TranslationProvider>;
@@ -45,13 +56,10 @@ function seed(threadId: number | null, retention: AiStatusResponse['retention'])
});
}
beforeEach(() => {
window.__addToast = addToast as unknown as typeof window.__addToast;
});
beforeEach(() => {});
afterEach(() => {
useAssistantStore.setState(INITIAL, true);
delete window.__addToast;
vi.clearAllMocks();
});
@@ -80,7 +88,7 @@ describe('useAssistantThread', () => {
it('FE-AI-THREAD-003: opens and deletes conversations of this trip only', async () => {
seed(7, { mode: 'days', days: 30 });
const { result } = renderHook(() => useAssistantThread(TRIP), { wrapper });
act(() => result.current.openThread(8));
await act(() => result.current.openThread(8));
expect(openThread).toHaveBeenCalledWith(TRIP, 8);
await act(() => result.current.removeThread());
expect(deleteThread).toHaveBeenCalledWith(TRIP, 7);
@@ -35,15 +35,12 @@ export function useFoundPlacePhoto(place: AssistantFoundPlace, ref: RefObject<El
const enabled = useAuthStore((s) => s.placesPhotosEnabled);
const key = photoKeyOf(place);
const [src, setSrc] = useState<string | null>(() => (key ? proxiedPhoto(getCached(key)?.photoUrl) : null));
const [visible, setVisible] = useState(false);
// Without an IntersectionObserver every card counts as in view.
const [visible, setVisible] = useState(() => typeof IntersectionObserver === 'undefined');
useEffect(() => {
const el = ref.current;
if (!enabled || !key || !el) return;
if (typeof IntersectionObserver === 'undefined') {
setVisible(true);
return;
}
if (!enabled || !key || !el || typeof IntersectionObserver === 'undefined') return;
const io = new IntersectionObserver(
([entry]) => {
if (!entry?.isIntersecting) return;
+11 -3
View File
@@ -39,11 +39,19 @@ function report(tripId: number | null, edges: Edges | null): void {
set(tripId !== null && usable ? { tripId, view: mapViewOf(edges) } : null);
}
/** The trip whose view is reported: only while the assistant can run, so a map without it listens to nothing. */
function useReportedTrip(): number | null {
const available = useAssistantStore((s) => s.status?.available === true);
const tripId = useTripStore((s) => s.trip?.id ?? null);
return available ? tripId : null;
}
/** Leaflet: read from inside the map container, after each move and once on mount. */
export function useReportViewLeaflet(): void {
const map = useMap();
const tripId = useTripStore((s) => s.trip?.id ?? null);
const tripId = useReportedTrip();
useEffect(() => {
if (tripId === null) return;
const read = () => {
const b = map.getBounds();
report(tripId, { south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() });
@@ -71,10 +79,10 @@ interface ViewMap {
/** GL: the same, once the map is ready. */
export function useReportViewGL(mapRef: { readonly current: unknown }, mapReady: boolean): void {
const tripId = useTripStore((s) => s.trip?.id ?? null);
const tripId = useReportedTrip();
useEffect(() => {
const map = mapRef.current as Partial<ViewMap> | null;
if (!mapReady || !map?.on || !map.off || !map.getBounds) return;
if (tripId === null || !mapReady || !map?.on || !map.off || !map.getBounds) return;
const { on, off, getBounds } = { on: map.on.bind(map), off: map.off.bind(map), getBounds: map.getBounds.bind(map) };
const read = () => {
const b = getBounds();
@@ -202,6 +202,43 @@ function AssistantBehaviour() {
);
}
/** The connect panel, opened on purpose or because nothing is connected yet. */
function ConnectPanel({ S }: { S: AiConnections }) {
const { t } = useTranslation();
return (
<div className={`flex flex-col gap-3 ${S.view === 'setup' ? '' : 'border-t border-edge-faint pt-4'}`}>
<AiConnectPanel key={S.panelMode ?? 'any'} scope="user" onConnected={S.onConnected} initialMode={S.panelMode} />
{S.view !== 'setup' && (
<button type="button" onClick={S.closePanel} className={`${SETTINGS_BUTTON} self-start`} style={fs(13, 'body')}>
{t('common.cancel')}
</button>
)}
</div>
);
}
/** How long chats are kept, and for an admin the way to the setting for everyone. */
function SectionFooter({ S }: { S: AiConnections }) {
const { t } = useTranslation();
const retention = S.view !== 'setup' && !!S.retentionText;
if (!S.isAdmin && !retention) return null;
return (
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1">
{S.view !== 'setup' && <SettingsHint>{S.retentionText}</SettingsHint>}
{S.isAdmin && (
<button
type="button"
onClick={S.openAdmin}
className="font-medium text-content-muted underline underline-offset-2 hover:text-content"
style={fs(12, 'body')}
>
{t('assistant.setup.forEveryone')}
</button>
)}
</div>
);
}
/**
* Settings → Integrations → the user's AI connection: connect a provider, see
* which one answers, pick its model, leave again. The assistant runs on it and
@@ -242,42 +279,8 @@ export default function AiConnectionSection({
{assistant && (S.view === 'own' || S.view === 'instance') && <AssistantBehaviour />}
{S.view === 'adminOnly' && <SettingsHint>{t('assistant.setup.adminOnly')}</SettingsHint>}
{S.panel && (
<div className={`flex flex-col gap-3 ${S.view === 'setup' ? '' : 'border-t border-edge-faint pt-4'}`}>
<AiConnectPanel
key={S.panelMode ?? 'any'}
scope="user"
onConnected={S.onConnected}
initialMode={S.panelMode}
/>
{S.view !== 'setup' && (
<button
type="button"
onClick={S.closePanel}
className={`${SETTINGS_BUTTON} self-start`}
style={fs(13, 'body')}
>
{t('common.cancel')}
</button>
)}
</div>
)}
{(S.isAdmin || (S.retentionText && S.view !== 'setup')) && (
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1">
{S.view !== 'setup' && <SettingsHint>{S.retentionText}</SettingsHint>}
{S.isAdmin && (
<button
type="button"
onClick={S.openAdmin}
className="font-medium text-content-muted underline underline-offset-2 hover:text-content"
style={fs(12, 'body')}
>
{t('assistant.setup.forEveryone')}
</button>
)}
</div>
)}
{S.panel && <ConnectPanel S={S} />}
<SectionFooter S={S} />
<ConfirmDialog
isOpen={S.ownActions.confirmingDisconnect}
@@ -70,11 +70,7 @@ export function useAiModels(load: (() => Promise<AiModelInfo[]>) | null, connect
* The choices of a model picker: what the provider offers, the recommended
* one first with its badge, and the current model kept when it is not listed.
*/
export function modelOptions(
models: AiModelInfo[],
current: string,
labels: { free: string; recommended: string }
) {
export function modelOptions(models: AiModelInfo[], current: string, labels: { free: string; recommended: string }) {
const options = models.map((m) => ({
value: m.id,
label: m.label,
@@ -113,7 +109,10 @@ function useOwnConnection(own: AiConnectionView | null, reload: () => Promise<vo
return {
provider,
models: own
? modelOptions(models, own.model, { free: t('assistant.model.free'), recommended: t('assistant.model.recommended') })
? modelOptions(models, own.model, {
free: t('assistant.model.free'),
recommended: t('assistant.model.recommended'),
})
: [],
busy,
setModel: (model: string) => run(() => aiConnectionsRepo.setModel(model), 'common.saved'),
@@ -148,14 +147,10 @@ export function useAiConnections({ phone = false }: { phone?: boolean } = {}) {
/** The panel opened on purpose: to renew a sign-in, replace a key, or connect one's own beside the admin's. */
const [connecting, setConnecting] = useState<AiConnectMode | 'any' | null>(null);
const reload = async () => {
await loadStatus();
setLoaded(true);
};
const loadOnMount = useEffectEvent(() => void reload());
const reload = () => loadStatus().then(() => setLoaded(true));
useEffect(() => {
loadOnMount();
}, []);
void loadStatus().then(() => setLoaded(true));
}, [loadStatus]);
const onConnected = () => {
setConnecting(null);
@@ -181,10 +176,9 @@ export function useAiConnections({ phone = false }: { phone?: boolean } = {}) {
openWindow();
void navigate('/dashboard');
};
tripRepo.active().then(
({ trip }) => (trip ? void navigate(`/trips/${trip.id}?assistant=1`) : withoutTrip()),
withoutTrip
);
tripRepo
.active()
.then(({ trip }) => (trip ? void navigate(`/trips/${trip.id}?assistant=1`) : withoutTrip()), withoutTrip);
};
return {
+2 -1
View File
@@ -4,7 +4,8 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { render } from '../../../tests/helpers/render';
import Kbd, { isApplePlatform } from './Kbd';
import { isApplePlatform } from '../../utils/applePlatform';
import Kbd from './Kbd';
afterEach(() => vi.restoreAllMocks());
-5
View File
@@ -18,8 +18,3 @@ export default function Kbd({ keys, className = '' }: { keys: readonly string[];
</kbd>
);
}
/** Whether shortcuts here read with ⌘ rather than Ctrl. */
export function isApplePlatform(): boolean {
return typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);
}
@@ -6,8 +6,8 @@
* own (setting, lock, reduced motion, the phone's folded chat), the "before"
* view of an applied block, and the pop of a place that arrives.
*/
import type { AssistantMessage, AssistantMessagePart, Trip } from '@trek/shared';
import { act, renderHook } from '@testing-library/react';
import type { AssistantMessage, AssistantMessagePart, Trip } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useSettingsStore } from '../../store/settingsStore';
@@ -228,7 +228,9 @@ describe('useAssistantMapLink: after applying', () => {
expect(selectDay).toHaveBeenCalledOnce();
expect(selectDay).toHaveBeenCalledWith(3);
act(() => vi.advanceTimersByTime(1000));
act(() => {
vi.advanceTimersByTime(1000);
});
expect(result.current.poppedPlaceIds).toEqual([]);
rerender({});
expect(selectDay).toHaveBeenCalledOnce();
+4
View File
@@ -0,0 +1,4 @@
/** Whether shortcuts here read with ⌘ rather than Ctrl. */
export function isApplePlatform(): boolean {
return typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);
}
+237 -168
View File
@@ -172,6 +172,231 @@ function uniqueCallId(call: AiToolCall, used: Set<string>, newId: () => string):
return id;
}
/** What every step of one turn shares. */
interface Turn {
deps: AgentDeps;
input: AgentInput;
w: UiStreamWriter;
signal: AbortSignal;
history: AiChatMessage[];
usage: { inputTokens: number; outputTokens: number };
knownPlaceIds: Set<string>;
/** Whether the turn used its one push (an empty, cut-off or announcing answer). */
nudged: boolean;
}
function outcomeOf(turn: Turn, end: AgentOutcome['end'], code: AssistantErrorCode | null = null): AgentOutcome {
return { end, code, ...turn.usage };
}
function finishTurn(turn: Turn, reason: 'stop' | 'tool-calls' | 'error'): void {
if (turn.deps.quota) turn.w.quota(turn.deps.quota.used, turn.deps.quota.limit);
turn.w.finish(reason);
}
/** What the model answered in one step. */
interface StepAnswer {
text: string;
received: AiToolCall[];
providerState: AiProviderState | null;
cutOff: boolean;
}
/** Streams one answer of the model; a failure or a stop ends the turn here, saved as far as it got. */
async function streamAnswer(turn: Turn, rowId: number): Promise<StepAnswer | AgentOutcome> {
const { deps, input, w, signal } = turn;
const answer: StepAnswer = { text: '', received: [], providerState: null, cutOff: false };
try {
const events = deps.client.stream(
{
system: input.system,
context: input.context,
messages: turn.history,
tools: deps.tools.specs(),
cacheKey: input.cacheKey,
},
signal,
);
for await (const event of events) {
if (event.type === 'text-delta') {
answer.text += event.delta;
w.text(event.delta);
} else if (event.type === 'tool-call') answer.received.push(event.call);
else if (event.type === 'usage') {
turn.usage.inputTokens += event.inputTokens;
turn.usage.outputTokens += event.outputTokens;
} else if (event.type === 'provider-state') answer.providerState = event.state;
else if (event.type === 'finish') answer.cutOff = event.reason === 'length';
}
return answer;
} catch (err) {
const partial: StepRecord = {
parts: answer.text ? [{ type: 'text', text: answer.text }] : [],
providerState: null,
};
if (signal.aborted) {
await deps.store.saveStep(rowId, partial, 'aborted');
w.abort();
return outcomeOf(turn, 'aborted');
}
const code = err instanceof AiProviderError ? err.code : 'generic';
await deps.store.saveStep(rowId, partial, 'error');
w.error(code);
finishTurn(turn, 'error');
return outcomeOf(turn, 'failed', code);
}
}
/**
* Runs the calls of one step in the order the model made them: a read at once,
* a change described and held, a refused call answered with why.
*/
async function runCalls(turn: Turn, calls: AiToolCall[], collected: StepParts): Promise<HeldCall[]> {
const { deps, w, signal } = turn;
const writes = calls.filter((call) => deps.tools.kindOf(call.name) === 'write').length;
const held: HeldCall[] = [];
let reads = 0;
const runOne = async (call: AiToolCall): Promise<void> => {
if (signal.aborted) return;
const kind = deps.tools.kindOf(call.name);
let prepared = kind ? deps.tools.pinArgs(call.name, call.args) : { error: `Unknown tool: ${call.name}` };
const unknownPlace = 'error' in prepared ? null : placeIdRefusal(call.name, prepared.args, turn.knownPlaceIds);
if (unknownPlace) prepared = { error: unknownPlace };
if (kind === 'read' && !('error' in prepared)) reads += 1;
const overCap = kind === 'write' ? writes > MAX_WRITES_PER_STEP : reads > MAX_READS_PER_STEP;
if ('error' in prepared || overCap) {
let refusal = kind === 'write' ? TOO_MANY_CHANGES : TOO_MANY_LOOKUPS;
if ('error' in prepared) refusal = prepared.error;
w.toolInput(call.id, call.name, call.args);
w.toolError(call.id, refusal);
collected.parts.push({
type: 'tool-call',
toolCallId: call.id,
toolName: call.name,
args: call.args,
result: refusal,
isError: true,
});
return;
}
if (kind === 'read') {
await runRead(deps, { id: call.id, name: call.name, args: prepared.args }, w, collected);
return;
}
const id = deps.newId();
const preview = await deps.describe(call.name, id, prepared.args);
held.push({ id, providerCallId: call.id, toolName: call.name, args: prepared.args, preview });
};
// One after another: the stream tells each call before the next one starts.
await calls.reduce<Promise<void>>(async (previous, call) => {
await previous;
await runOne(call);
}, Promise.resolve());
return held;
}
/** Saves a step whose changes wait for the user and announces them; the turn ends there. */
async function holdChanges(turn: Turn, rowId: number, step: StepRecord, held: HeldCall[]): Promise<AgentOutcome> {
const { w } = turn;
for (const change of held) {
const { id, toolName, args } = change;
step.parts.push({ type: 'tool-call', toolCallId: id, toolName, args, approval: { id } });
}
step.parts.push(...held.map((change): AssistantMessagePart => ({ type: 'data-trek-change', data: change.preview })));
await turn.deps.store.holdStep(rowId, step, held);
for (const change of held) {
w.toolInput(change.id, change.toolName, change.args);
w.approvalRequest(change.id);
w.change(change.preview);
}
w.finishStep();
finishTurn(turn, 'tool-calls');
return outcomeOf(turn, 'awaiting');
}
/** Records a step that ran its reads, and hands their answers back for the next one. */
async function carryOn(turn: Turn, rowId: number, step: StepRecord, text: string, calls: AiToolCall[]): Promise<void> {
await turn.deps.store.saveStep(rowId, step, 'complete');
turn.w.finishStep();
turn.history.push({ role: 'assistant', text, toolCalls: calls, providerState: step.providerState });
for (const part of step.parts) {
if (part.type !== 'tool-call') continue;
turn.history.push({
role: 'tool',
toolCallId: part.toolCallId,
name: part.toolName,
output: outputText(part.result),
...(part.isError ? { isError: true } : {}),
});
}
}
/** One step of the turn: the outcome when the turn ends with it, null when the model is asked again. */
async function runStep(turn: Turn, step: number): Promise<AgentOutcome | null> {
const { deps, input, w, signal, history } = turn;
const rowId = await deps.store.openStep();
w.startStep();
const answer = await streamAnswer(turn, rowId);
if ('end' in answer) return answer;
const { text, received, providerState, cutOff } = answer;
const parts: AssistantMessagePart[] = text ? [{ type: 'text', text }] : [];
const empty = received.length === 0 && !text.trim();
const announced = received.length === 0 && step === 0 && ANNOUNCED.test(text.trimEnd());
const push = empty || (received.length === 0 && cutOff) ? GO_ON : announced ? MAKE_THE_CALLS : null;
if (push && !turn.nudged) {
turn.nudged = true;
await deps.store.saveStep(rowId, { parts, providerState }, 'complete');
w.finishStep();
// An empty assistant message is one the providers refuse; only the push goes back.
if (!empty) history.push({ role: 'assistant', text, toolCalls: [], providerState });
history.push({ role: 'user', text: push });
return null;
}
if (empty) {
await deps.store.saveStep(rowId, { parts, providerState }, 'error');
w.error('empty_answer');
finishTurn(turn, 'error');
return outcomeOf(turn, 'failed', 'empty_answer');
}
if (received.length === 0) {
await deps.store.saveStep(rowId, { parts, providerState }, 'complete');
w.finishStep();
finishTurn(turn, 'stop');
return outcomeOf(turn, 'stop');
}
const calls = received.map((call) => ({ ...call, id: uniqueCallId(call, input.usedCallIds, deps.newId) }));
const collected: StepParts = { parts, weather: [], knownPlaceIds: turn.knownPlaceIds };
const held = await runCalls(turn, calls, collected);
// One weather strip per step, whatever the step held: it is told before the changes.
const weather = weatherOf(collected.weather);
if (weather) {
w.weather(weather);
parts.push({ type: 'data-trek-weather', data: weather });
}
// A stopped step keeps what ran; the changes it was about to propose are dropped, never held.
if (signal.aborted) {
await deps.store.saveStep(rowId, { parts, providerState }, 'aborted');
w.abort();
return outcomeOf(turn, 'aborted');
}
if (held.length > 0) return holdChanges(turn, rowId, { parts, providerState }, held);
await carryOn(turn, rowId, { parts, providerState }, text, calls);
return null;
}
/** The steps from `step` on, each after the one before, until one ends the turn or the limit does. */
async function stepsFrom(turn: Turn, step: number): Promise<AgentOutcome> {
if (step >= MAX_STEPS) {
turn.w.error('max_steps');
finishTurn(turn, 'error');
return outcomeOf(turn, 'max_steps', 'max_steps');
}
return (await runStep(turn, step)) ?? stepsFrom(turn, step + 1);
}
/**
* One assistant turn: the model is asked, the reads it calls are run and
* answered, and it is asked again, until it answers without calling a tool,
@@ -183,180 +408,24 @@ function uniqueCallId(call: AiToolCall, used: Set<string>, newId: () => string):
* before its held changes are announced, so a card the client shows always
* exists on the server.
*/
export async function runAgentLoop(
export function runAgentLoop(
deps: AgentDeps,
input: AgentInput,
w: UiStreamWriter,
signal: AbortSignal,
): Promise<AgentOutcome> {
const usage = { inputTokens: 0, outputTokens: 0 };
const history = [...input.history];
const outcome = (end: AgentOutcome['end'], code: AssistantErrorCode | null = null): AgentOutcome => ({
end,
code,
...usage,
});
const finish = (reason: 'stop' | 'tool-calls' | 'error') => {
if (deps.quota) w.quota(deps.quota.used, deps.quota.limit);
w.finish(reason);
};
const knownPlaceIds = new Set(
[...deps.card.places.values()].flatMap((place) => (place.googlePlaceId ? [place.googlePlaceId] : [])),
);
let nudged = false;
for (let step = 0; step < MAX_STEPS; step++) {
const rowId = await deps.store.openStep();
w.startStep();
let text = '';
let providerState: AiProviderState | null = null;
let cutOff = false;
const received: AiToolCall[] = [];
try {
const events = deps.client.stream(
{
system: input.system,
context: input.context,
messages: history,
tools: deps.tools.specs(),
cacheKey: input.cacheKey,
},
signal,
);
for await (const event of events) {
if (event.type === 'text-delta') {
text += event.delta;
w.text(event.delta);
} else if (event.type === 'tool-call') received.push(event.call);
else if (event.type === 'usage') {
usage.inputTokens += event.inputTokens;
usage.outputTokens += event.outputTokens;
} else if (event.type === 'provider-state') providerState = event.state;
else if (event.type === 'finish') cutOff = event.reason === 'length';
}
} catch (err) {
const partial: StepRecord = { parts: text ? [{ type: 'text', text }] : [], providerState: null };
if (signal.aborted) {
await deps.store.saveStep(rowId, partial, 'aborted');
w.abort();
return outcome('aborted');
}
const code = err instanceof AiProviderError ? err.code : 'generic';
await deps.store.saveStep(rowId, partial, 'error');
w.error(code);
finish('error');
return outcome('failed', code);
}
const parts: AssistantMessagePart[] = text ? [{ type: 'text', text }] : [];
const empty = received.length === 0 && !text.trim();
const announced = received.length === 0 && step === 0 && ANNOUNCED.test(text.trimEnd());
const push = empty || (received.length === 0 && cutOff) ? GO_ON : announced ? MAKE_THE_CALLS : null;
if (push && !nudged) {
nudged = true;
await deps.store.saveStep(rowId, { parts, providerState }, 'complete');
w.finishStep();
// An empty assistant message is one the providers refuse; only the push goes back.
if (!empty) history.push({ role: 'assistant', text, toolCalls: [], providerState });
history.push({ role: 'user', text: push });
continue;
}
if (empty) {
await deps.store.saveStep(rowId, { parts, providerState }, 'error');
w.error('empty_answer');
finish('error');
return outcome('failed', 'empty_answer');
}
if (received.length === 0) {
await deps.store.saveStep(rowId, { parts, providerState }, 'complete');
w.finishStep();
finish('stop');
return outcome('stop');
}
const calls = received.map((call) => ({ ...call, id: uniqueCallId(call, input.usedCallIds, deps.newId) }));
const writes = calls.filter((call) => deps.tools.kindOf(call.name) === 'write').length;
const held: HeldCall[] = [];
const collected: StepParts = { parts, weather: [], knownPlaceIds };
let reads = 0;
for (const call of calls) {
if (signal.aborted) break;
const kind = deps.tools.kindOf(call.name);
let prepared = kind ? deps.tools.pinArgs(call.name, call.args) : { error: `Unknown tool: ${call.name}` };
const unknownPlace = 'error' in prepared ? null : placeIdRefusal(call.name, prepared.args, knownPlaceIds);
if (unknownPlace) prepared = { error: unknownPlace };
if (kind === 'read' && !('error' in prepared)) reads += 1;
const overCap = kind === 'write' ? writes > MAX_WRITES_PER_STEP : reads > MAX_READS_PER_STEP;
if ('error' in prepared || overCap) {
let refusal = kind === 'write' ? TOO_MANY_CHANGES : TOO_MANY_LOOKUPS;
if ('error' in prepared) refusal = prepared.error;
w.toolInput(call.id, call.name, call.args);
w.toolError(call.id, refusal);
parts.push({
type: 'tool-call',
toolCallId: call.id,
toolName: call.name,
args: call.args,
result: refusal,
isError: true,
});
continue;
}
if (kind === 'read') {
await runRead(deps, { id: call.id, name: call.name, args: prepared.args }, w, collected);
continue;
}
const id = deps.newId();
const preview = await deps.describe(call.name, id, prepared.args);
held.push({ id, providerCallId: call.id, toolName: call.name, args: prepared.args, preview });
}
// One weather strip per step, whatever the step held: it is told before the changes.
const weather = weatherOf(collected.weather);
if (weather) {
w.weather(weather);
parts.push({ type: 'data-trek-weather', data: weather });
}
// A stopped step keeps what ran; the changes it was about to propose are dropped, never held.
if (signal.aborted) {
await deps.store.saveStep(rowId, { parts, providerState }, 'aborted');
w.abort();
return outcome('aborted');
}
if (held.length > 0) {
for (const change of held) {
const { id, toolName, args } = change;
parts.push({ type: 'tool-call', toolCallId: id, toolName, args, approval: { id } });
}
parts.push(...held.map((change): AssistantMessagePart => ({ type: 'data-trek-change', data: change.preview })));
await deps.store.holdStep(rowId, { parts, providerState }, held);
for (const change of held) {
w.toolInput(change.id, change.toolName, change.args);
w.approvalRequest(change.id);
w.change(change.preview);
}
w.finishStep();
finish('tool-calls');
return outcome('awaiting');
}
await deps.store.saveStep(rowId, { parts, providerState }, 'complete');
w.finishStep();
history.push({ role: 'assistant', text, toolCalls: calls, providerState });
for (const part of parts) {
if (part.type !== 'tool-call') continue;
history.push({
role: 'tool',
toolCallId: part.toolCallId,
name: part.toolName,
output: outputText(part.result),
...(part.isError ? { isError: true } : {}),
});
}
}
w.error('max_steps');
finish('error');
return outcome('max_steps', 'max_steps');
const turn: Turn = {
deps,
input,
w,
signal,
history: [...input.history],
usage: { inputTokens: 0, outputTokens: 0 },
knownPlaceIds,
nudged: false,
};
return stepsFrom(turn, 0);
}
@@ -1,6 +1,6 @@
import type { CardPlace, TripCard } from './assistant-context';
import { redactForProvider } from './assistant-redact';
import { dayLabel } from './tool-call-preview.helpers';
import { dayLabel, type PreviewContext } from './tool-call-preview.helpers';
import {
haversineMetres,
kindOfResult,
@@ -255,6 +255,13 @@ export function activityOf(toolName: string, toolCallId: string, args: Row, card
return label === null && area === null ? null : { toolCallId, label, area };
}
/** What a mention is called in the trip; null when the trip holds no such thing. */
function mentionLabel(ctx: PreviewContext, kind: AssistantMention['kind'], id: number): string | null {
if (kind === 'day') return ctx.card.days.some((day) => day.id === id) ? dayLabel(ctx, id) : null;
if (kind === 'place') return ctx.card.places.get(id)?.name ?? null;
return ctx.card.reservations.get(id)?.title ?? null;
}
/**
* The days, places and bookings the user named with `@`, with the labels the
* trip gives them (never the ones the request sent). One that is not in the
@@ -269,10 +276,7 @@ export function mentionItems(
const seen = new Set<string>();
const ctx = { card, categories: new Map<number, string>(), locale };
for (const { kind, id } of mentions) {
let label: string | null = null;
if (kind === 'day') label = card.days.some((day) => day.id === id) ? dayLabel(ctx, id) : null;
else if (kind === 'place') label = card.places.get(id)?.name ?? null;
else label = card.reservations.get(id)?.title ?? null;
const label = mentionLabel(ctx, kind, id);
const shown = clipped(label, MAX_NAME_CHARS);
if (!shown || seen.has(`${kind}:${id}`)) continue;
seen.add(`${kind}:${id}`);
@@ -0,0 +1,44 @@
import type { AssistantMessagePart } from '@trek/shared';
/** What the model is told about a change the user discarded. */
export const DISCARDED = { applied: false, reason: 'The user discarded this change.' };
/** ... one the user left undecided by asking something else. */
export const CANCELLED = { applied: false, reason: 'The user moved on without deciding.' };
/** ... one that was approved but never ran. */
export const NOT_RUN = { applied: false, reason: 'The change could not be run.' };
/** What became of one decided change, as its message records it. */
export interface Outcome {
approved: boolean;
result: unknown;
isError: boolean;
}
/** The parts of a message with the named held changes marked as left undecided. */
export function withCancelled(
parts: readonly AssistantMessagePart[],
cancelled: ReadonlySet<string>,
): AssistantMessagePart[] {
return parts.map((part) =>
part.type === 'tool-call' && cancelled.has(part.toolCallId)
? { ...part, result: CANCELLED, approval: { id: part.toolCallId, resolution: 'cancelled' as const } }
: part,
);
}
/** The parts of a message with each decided change carrying its outcome. */
export function withOutcomes(
parts: readonly AssistantMessagePart[],
outcomes: ReadonlyMap<string, Outcome>,
): AssistantMessagePart[] {
return parts.map((part) => {
const outcome = part.type === 'tool-call' ? outcomes.get(part.toolCallId) : undefined;
if (part.type !== 'tool-call' || !outcome) return part;
return {
...part,
result: outcome.result,
...(outcome.isError ? { isError: true } : {}),
approval: { id: part.toolCallId, approved: outcome.approved },
};
});
}
@@ -213,10 +213,9 @@ export class AssistantFoundPlacesService {
private async answerOf(threadId: number): Promise<AssistantMessage> {
const rows = await this.messages.listLatest(threadId, THREAD_ROWS);
const calls = new Map<string, AssistantToolCallRow>();
for (const row of rows) {
if (!partsOf(row).some((part) => part.type === 'tool-call' && part.approval)) continue;
for (const call of await this.toolCalls.listByMessage(row.id)) calls.set(call.id, call);
}
const held = rows.filter((row) => partsOf(row).some((part) => part.type === 'tool-call' && part.approval));
const lists = await Promise.all(held.map((row) => this.toolCalls.listByMessage(row.id)));
for (const call of lists.flat()) calls.set(call.id, call);
const messages = toThreadMessages(rows, calls, new Date());
return messages[messages.length - 1];
}
@@ -127,6 +127,8 @@ export class AssistantToolSession {
private readonly tools: ReadonlyMap<string, ListedTool>,
private readonly tripId: number,
private readonly audit: (toolName: string) => void,
/** Whether the model may keep trip notes here: a turn's session, never an undo's. */
private readonly offersNotes: boolean,
) {}
/** The tools as the model is offered them, with the trip notes the server keeps itself. */
@@ -136,11 +138,11 @@ export class AssistantToolSession {
description: tool.description,
parameters: modelSchema(tool.name, tool.inputSchema),
}));
return [...listed, REMEMBER_SPEC];
return this.offersNotes ? [...listed, REMEMBER_SPEC] : listed;
}
kindOf(name: string): AssistantToolKind | null {
if (name === REMEMBER_TOOL) return 'write';
if (name === REMEMBER_TOOL) return this.offersNotes ? 'write' : null;
return this.tools.get(name)?.kind ?? null;
}
@@ -243,7 +245,8 @@ export class AssistantToolsService implements OnApplicationBootstrap {
pinned: PINNED_ARG in properties,
});
}
return new AssistantToolSession(client, server, tools, tripId, (toolName) => this.audit(userId, toolName));
const offersNotes = include === ASSISTANT_TOOL_ALLOWLIST;
return new AssistantToolSession(client, server, tools, tripId, (name) => this.audit(userId, name), offersNotes);
}
/** The audit row an MCP client's call writes, marked as the assistant's. A failed write never fails the call. */
+95 -97
View File
@@ -35,6 +35,7 @@ import { runAgentLoop, type AgentStore, type HeldCall, type StepRecord } from '.
import { AssistantBookingImportService } from './assistant-booking-import.service';
import { mentionItems } from './assistant-cards.helpers';
import { buildTripCard, categoriesFrom, languageName, todayIn, turnContext, type TripCard } from './assistant-context';
import { DISCARDED, NOT_RUN, type Outcome, withCancelled, withOutcomes } from './assistant-decisions.helpers';
import { messageIdOf, outputText, partsOf, toChatHistory, toThreadMessages } from './assistant-history';
import { rememberForTrip } from './assistant-memory';
import { toolOutputForProvider } from './assistant-redact';
@@ -84,22 +85,18 @@ const THREAD_ROWS = 50;
/** Message rows a turn sends the provider. */
const HISTORY_ROWS = 120;
/** What the model and the thread record for a change the user discarded or moved on from. */
const DISCARDED = { applied: false, reason: 'The user discarded this change.' };
const CANCELLED = { applied: false, reason: 'The user moved on without deciding.' };
const NOT_RUN = { applied: false, reason: 'The change could not be run.' };
/** What the read step of an attached file answers when the file could not be read. */
const FILE_UNREADABLE = 'The file could not be read.';
/** The longest file name a message keeps. */
const MAX_FILE_NAME_CHARS = 255;
/** What became of one decided change, as its message records it. */
interface Outcome {
approved: boolean;
result: unknown;
isError: boolean;
/** Where one applied change reports to: the stream, and what its message records. */
interface AppliedSink {
w: UiStreamWriter;
unrun: Set<string>;
outcomes: Map<string, Outcome>;
receipts: AssistantMessagePart[];
}
/**
@@ -624,20 +621,21 @@ export class AssistantService {
private async cancelOpen(threadId: number, now: string): Promise<void> {
const open = await this.toolCalls.listOpen(threadId);
const cancelled = new Set<string>();
for (const call of open) {
if (await this.toolCalls.claimDecision(call.id, threadId, false, dbNow(new Date(0)), now)) cancelled.add(call.id);
}
for (const messageId of new Set(open.map((call) => call.message_id))) {
const row = await this.messages.findInThread(messageId, threadId);
if (!row) continue;
const parts = partsOf(row).map((part) =>
part.type === 'tool-call' && cancelled.has(part.toolCallId)
? { ...part, result: CANCELLED, approval: { id: part.toolCallId, resolution: 'cancelled' as const } }
: part,
);
await this.messages.update(messageId, { parts: encodeParts(parts), status: 'complete' });
}
const claimed = await Promise.all(
open.map((call) => this.toolCalls.claimDecision(call.id, threadId, false, dbNow(new Date(0)), now)),
);
const cancelled = new Set(open.filter((_call, i) => claimed[i]).map((call) => call.id));
await Promise.all(
[...new Set(open.map((call) => call.message_id))].map((id) => this.markCancelled(id, threadId, cancelled)),
);
}
/** Marks the cancelled changes of one message as such, and the message as done. */
private async markCancelled(messageId: number, threadId: number, cancelled: ReadonlySet<string>): Promise<void> {
const row = await this.messages.findInThread(messageId, threadId);
if (!row) return;
const parts = withCancelled(partsOf(row), cancelled);
await this.messages.update(messageId, { parts: encodeParts(parts), status: 'complete' });
}
/**
@@ -660,14 +658,13 @@ export class AssistantService {
const now = new Date();
const notBefore = dbNow(new Date(now.getTime() - APPROVAL_TTL_MS));
const decided = await this.uow.transactional(async () => {
const taken: Array<{ call: AssistantToolCallRow; approved: boolean }> = [];
for (const call of open) {
const approved = wanted.get(call.id) === true;
if (await this.toolCalls.claimDecision(call.id, threadId, approved, notBefore, dbNow(now))) {
taken.push({ call, approved });
}
}
return taken;
const claims = open.map((call) => ({ call, approved: wanted.get(call.id) === true }));
const taken = await Promise.all(
claims.map(({ call, approved }) =>
this.toolCalls.claimDecision(call.id, threadId, approved, notBefore, dbNow(now)),
),
);
return claims.filter((_claim, i) => taken[i]);
});
if (decided.length === 0) {
throw conflict('These changes were decided already or have expired', { code: 'generic' });
@@ -694,49 +691,61 @@ export class AssistantService {
): Promise<void> {
const outcomes = new Map<string, Outcome>();
const receipts: AssistantMessagePart[] = [];
for (const { call, approved } of decided) {
if (!approved) {
w.toolDenied(call.id);
outcomes.set(call.id, { approved: false, result: DISCARDED, isError: false });
continue;
}
const args = decodeJson(ASSISTANT_TOOL_ARGS, call.args, `tool call ${call.id}`);
const ran = await run(call.tool_name, args);
const output = toolOutputForProvider(call.tool_name, ran.value);
const undoable = ran.ok && !!ASSISTANT_UNDO_MAP[writeToolOf(call.tool_name, args)]?.args(args, ran.value);
const undoUntil = undoable ? dbNow(new Date(Date.now() + UNDO_WINDOW_MS)) : null;
await this.inContext(() =>
this.toolCalls.recordOutcome(
call.id,
ran.ok ? 'executed' : 'failed',
encodeJson(ASSISTANT_TOOL_RESULT, ran.value),
undoUntil,
),
);
unrun.delete(call.id);
outcomes.set(call.id, { approved: true, result: output, isError: !ran.ok });
if (!ran.ok) {
w.toolError(call.id, outputText(output));
continue;
}
w.toolOutput(call.id, output);
const preview = decodeJson(ASSISTANT_TOOL_PREVIEW, call.preview, `tool call ${call.id}`);
const applied = appliedChange(
call.tool_name,
call.id,
args,
ran.value,
preview?.dayId ?? null,
utcSuffix(undoUntil),
);
if (applied) {
w.applied(applied);
receipts.push({ type: 'data-trek-applied', data: applied });
}
}
// One after another: a change may build on the one before it.
await decided.reduce<Promise<void>>(async (previous, { call, approved }) => {
await previous;
await this.applyOne(run, call, approved, { w, unrun, outcomes, receipts });
}, Promise.resolve());
await this.recordDecided(threadId, decided[0].call.message_id, outcomes, receipts);
}
/** Runs one decided change, or records it as discarded, and tells the client what became of it. */
private async applyOne(
run: (name: string, args: Record<string, unknown>) => Promise<ToolRun>,
call: AssistantToolCallRow,
approved: boolean,
{ w, unrun, outcomes, receipts }: AppliedSink,
): Promise<void> {
if (!approved) {
w.toolDenied(call.id);
outcomes.set(call.id, { approved: false, result: DISCARDED, isError: false });
return;
}
const args = decodeJson(ASSISTANT_TOOL_ARGS, call.args, `tool call ${call.id}`);
const ran = await run(call.tool_name, args);
const output = toolOutputForProvider(call.tool_name, ran.value);
const undoable = ran.ok && !!ASSISTANT_UNDO_MAP[writeToolOf(call.tool_name, args)]?.args(args, ran.value);
const undoUntil = undoable ? dbNow(new Date(Date.now() + UNDO_WINDOW_MS)) : null;
await this.inContext(() =>
this.toolCalls.recordOutcome(
call.id,
ran.ok ? 'executed' : 'failed',
encodeJson(ASSISTANT_TOOL_RESULT, ran.value),
undoUntil,
),
);
unrun.delete(call.id);
outcomes.set(call.id, { approved: true, result: output, isError: !ran.ok });
if (!ran.ok) {
w.toolError(call.id, outputText(output));
return;
}
w.toolOutput(call.id, output);
const preview = decodeJson(ASSISTANT_TOOL_PREVIEW, call.preview, `tool call ${call.id}`);
const applied = appliedChange(
call.tool_name,
call.id,
args,
ran.value,
preview?.dayId ?? null,
utcSuffix(undoUntil),
);
if (applied) {
w.applied(applied);
receipts.push({ type: 'data-trek-applied', data: applied });
}
}
/**
* The approved changes the turn ended before running (the tool session did
* not open, the stream broke): recorded as failed, so the thread shows them
@@ -751,16 +760,17 @@ export class AssistantService {
): Promise<void> {
const outcomes = new Map<string, Outcome>();
for (const { call, approved } of decided) {
if (!approved) {
outcomes.set(call.id, { approved: false, result: DISCARDED, isError: false });
continue;
}
if (!unrun.has(call.id)) continue;
await this.inContext(() =>
this.toolCalls.recordOutcome(call.id, 'failed', encodeJson(ASSISTANT_TOOL_RESULT, NOT_RUN), null),
);
outcomes.set(call.id, { approved: true, result: NOT_RUN, isError: true });
if (!approved) outcomes.set(call.id, { approved: false, result: DISCARDED, isError: false });
else if (unrun.has(call.id)) outcomes.set(call.id, { approved: true, result: NOT_RUN, isError: true });
}
const failed = decided.filter(({ call, approved }) => approved && unrun.has(call.id));
await Promise.all(
failed.map(({ call }) =>
this.inContext(() =>
this.toolCalls.recordOutcome(call.id, 'failed', encodeJson(ASSISTANT_TOOL_RESULT, NOT_RUN), null),
),
),
);
await this.recordDecided(threadId, decided[0].call.message_id, outcomes, []);
}
@@ -774,16 +784,7 @@ export class AssistantService {
await this.inContext(async () => {
const row = await this.messages.findInThread(messageId, threadId);
if (!row) return;
const parts = partsOf(row).map((part) => {
const outcome = part.type === 'tool-call' ? outcomes.get(part.toolCallId) : undefined;
if (part.type !== 'tool-call' || !outcome) return part;
return {
...part,
result: outcome.result,
...(outcome.isError ? { isError: true } : {}),
approval: { id: part.toolCallId, approved: outcome.approved },
};
});
const parts = withOutcomes(partsOf(row), outcomes);
await this.messages.update(messageId, { parts: encodeParts([...parts, ...receipts]), status: 'complete' });
});
}
@@ -799,12 +800,9 @@ export class AssistantService {
/** The tool call rows behind the held changes of these messages, by id. */
private async callsOf(rows: readonly AssistantMessageRow[]): Promise<Map<string, AssistantToolCallRow>> {
const calls = new Map<string, AssistantToolCallRow>();
for (const row of rows) {
if (!partsOf(row).some((part) => part.type === 'tool-call' && part.approval)) continue;
for (const call of await this.toolCalls.listByMessage(row.id)) calls.set(call.id, call);
}
return calls;
const held = rows.filter((row) => partsOf(row).some((part) => part.type === 'tool-call' && part.approval));
const lists = await Promise.all(held.map((row) => this.toolCalls.listByMessage(row.id)));
return new Map(lists.flat().map((call) => [call.id, call]));
}
/**
+4 -2
View File
@@ -117,8 +117,10 @@ export class CimdClientResolver {
if (cached && cached.expiresAt > Date.now()) return cached.client;
const pending = this.inflight.get(clientId);
if (pending) return pending;
const lookup = this.lookup(url).finally(() => this.inflight.delete(clientId));
if (pending !== undefined) return pending;
const lookup = this.lookup(url).finally(() => {
this.inflight.delete(clientId);
});
this.inflight.set(clientId, lookup);
return lookup;
}
+39 -33
View File
@@ -24,6 +24,7 @@ import { DayAssignments } from '../../src/db/entities/DayAssignments.entity';
import { Places } from '../../src/db/entities/Places.entity';
import { Reservations } from '../../src/db/entities/Reservations.entity';
import { Trips } from '../../src/db/entities/Trips.entity';
import { TURNS_PER_MINUTE } from '../../src/nest/assistant/assistant-tools.constants';
import { AssistantToolsService } from '../../src/nest/assistant/assistant-tools.service';
import { BookingImportService } from '../../src/nest/booking-import/booking-import.service';
import { MapsService } from '../../src/nest/maps/maps.service';
@@ -168,11 +169,14 @@ afterAll(async () => {
const CTX = { locale: 'en', timeZone: 'Europe/Berlin' };
function textParser(res: IncomingMessage, done: (err: Error | null, body: string) => void): void {
let text = '';
res.setEncoding('utf8');
res.on('data', (chunk: string) => (text += chunk));
res.on('end', () => done(null, text));
/** The request with its whole answer read as text, a stream included. */
function asText(req: request.Test): request.Test {
return req.buffer(true).parse((res, done) => {
let text = '';
res.setEncoding('utf8');
res.on('data', (chunk: string) => (text += chunk));
res.on('end', () => done(null, text));
});
}
interface Answer {
@@ -182,12 +186,7 @@ interface Answer {
}
async function post(path: string, userId: number, body: object): Promise<Answer> {
const res = await request(nestApp.getHttpServer())
.post(path)
.set('Cookie', sessionCookie(userId))
.send(body)
.buffer(true)
.parse(textParser);
const res = await asText(request(nestApp.getHttpServer()).post(path).set('Cookie', sessionCookie(userId)).send(body));
return parseAnswer(res.status, String(res.headers['content-type']), res.body as string);
}
@@ -209,14 +208,13 @@ function parseAnswer(status: number, contentType: string, text: string): Answer
/** A file attached in the chat, as the client uploads it (multipart, the context as text fields). */
async function upload(path: string, userId: number, fileName: string): Promise<Answer> {
const res = await request(nestApp.getHttpServer())
const req = request(nestApp.getHttpServer())
.post(path)
.set('Cookie', sessionCookie(userId))
.field('locale', CTX.locale)
.field('timeZone', CTX.timeZone)
.attach('file', Buffer.from('%PDF-1.4\n'), { filename: fileName, contentType: 'application/pdf' })
.buffer(true)
.parse(textParser);
.attach('file', Buffer.from('%PDF-1.4\n'), { filename: fileName, contentType: 'application/pdf' });
const res = await asText(req);
return parseAnswer(res.status, String(res.headers['content-type']), res.body as string);
}
@@ -302,7 +300,10 @@ async function searchTurn(t: { userId: number; base: string }) {
}
function addFound(path: string, userId: number, body: object) {
return request(nestApp.getHttpServer()).post(path).set('Cookie', sessionCookie(userId)).send({ ...CTX, ...body });
return request(nestApp.getHttpServer())
.post(path)
.set('Cookie', sessionCookie(userId))
.send({ ...CTX, ...body });
}
// ── Cases ────────────────────────────────────────────────────────────────────
@@ -556,7 +557,9 @@ describe('Trip assistant e2e', () => {
provider.script = [() => ({ status: 503 })];
const turn = await post(`${t.base}/chat`, t.userId, { ...CTX, threadId: null, text: 'hi' });
expect(turn.status).toBe(200);
expect(turn.chunks.slice(-2)).toEqual([
// The day's count goes out before the end, so it is read apart from it.
const ending = turn.chunks.filter((chunk) => chunk.type !== 'data-trek-quota');
expect(ending.slice(-2)).toEqual([
{ type: 'error', errorText: 'provider_unavailable' },
{ type: 'finish', finishReason: 'error' },
]);
@@ -564,20 +567,20 @@ describe('Trip assistant e2e', () => {
it('ASTE2E-011: turns are rate limited per user', async () => {
const t = await travelerWithTrip();
const statuses: number[] = [];
let last: { code?: unknown } = {};
for (let i = 0; i < 11; i++) {
const res = await request(nestApp.getHttpServer())
.post(`${t.base}/tool-calls/missing/undo`)
.set('Cookie', sessionCookie(t.userId))
.send({});
statuses.push(res.status);
last = res.body as { code?: unknown };
}
expect(statuses.slice(0, 10).every((status) => status === 404)).toBe(true);
expect(statuses[10]).toBe(429);
const responses = await Promise.all(
Array.from({ length: TURNS_PER_MINUTE + 1 }, () =>
request(nestApp.getHttpServer())
.post(`${t.base}/tool-calls/missing/undo`)
.set('Cookie', sessionCookie(t.userId))
.send({}),
),
);
const statuses = responses.map((res) => res.status);
expect(statuses.filter((status) => status === 404)).toHaveLength(TURNS_PER_MINUTE);
const limited = responses.filter((res) => res.status === 429);
expect(limited).toHaveLength(1);
// TREK's own limit, told apart from a provider's `rate_limited`.
expect(last.code).toBe('too_many_requests');
expect((limited[0]?.body as { code?: unknown }).code).toBe('too_many_requests');
});
it('ASTE2E-012: a thread is deleted by its owner, and all go with the trip', async () => {
@@ -683,8 +686,8 @@ describe('Trip assistant e2e', () => {
],
warnings: [],
});
let threadId = 0;
let toolCallId = '';
let threadId: number;
let toolCallId: string;
try {
const turn = await upload(`${t.base}/attachments`, t.userId, 'ticket.pdf');
expect(turn.status).toBe(200);
@@ -854,7 +857,10 @@ describe('Trip assistant e2e', () => {
provider.script = [say('Noted.')];
await post(`${t.base}/chat`, t.userId, { ...CTX, threadId, text: 'Thanks' });
const sent = provider.requests.at(-1)?.messages.map((message) => String(message.content ?? '')).join('\n');
const sent = provider.requests
.at(-1)
?.messages.map((message) => String(message.content ?? ''))
.join('\n');
expect(sent).toContain(`"Ginkaku-ji" was added to day ${day.id}.`);
});
+6 -6
View File
@@ -11,12 +11,12 @@ export type TagRow = EntityDTO<Tags>;
/** A category; global unless `user` names an owner. */
export function makeCategory(orm: FactoryOrm, overrides: EntityData<Categories> = {}): Promise<CategoryRow> {
return createRow(orm, Categories, {
name: `Test Category ${nextSeq('category')}`,
color: '#6366f1',
icon: '📍',
...overrides,
});
return createRow(
orm,
Categories,
{ name: `Test Category ${nextSeq('category')}`, color: '#6366f1', icon: '📍' },
overrides,
);
}
/** A tag owned by `userId`. */
@@ -1,10 +1,10 @@
import { OauthClients } from '../../../../src/db/entities/OauthClients.entity';
import { OauthTokens } from '../../../../src/db/entities/OauthTokens.entity';
import type { OauthClientsRepository } from '../../../../src/db/repositories/OauthClients.repository';
import { dbNow } from '../../../../src/db/types';
import { withRequestContext } from '../../../../src/nest/database/request-context';
import { UnitOfWork } from '../../../../src/nest/database/unit-of-work';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { dbNow } from '../../../../src/db/types';
import { createUser } from '../../../helpers/factories';
import { deleteRows, findRow, insertRow, updateRows } from '../../../helpers/factories/rows';
import { resetTestDb } from '../../../helpers/test-db';
@@ -364,15 +364,16 @@ describe('OauthClientsRepository', () => {
const held = await seedClient({ createdVia: 'cimd' });
const recent = await seedClient({ createdVia: 'cimd' });
const registered = await seedClient({ createdVia: 'settings_ui' });
for (const client of [unused, held, registered]) {
await updateRows(t, OauthClients, { id: client.id }, { created_at: OLD });
}
await Promise.all(
[unused, held, registered].map((client) => updateRows(t, OauthClients, { id: client.id }, { created_at: OLD })),
);
await updateRows(t, OauthClients, { id: recent.id }, { created_at: '2026-10-15 00:00:00' });
await holdToken(held.clientId);
expect(await clients.deleteUnusedCimdBefore(CUTOFF)).toBe(1);
expect(await storedClient(unused.id)).toBeNull();
for (const kept of [held, recent, registered]) expect(await storedClient(kept.id)).not.toBeNull();
const kept = await Promise.all([held, recent, registered].map((client) => storedClient(client.id)));
for (const row of kept) expect(row).not.toBeNull();
expect(await clients.deleteUnusedCimdBefore(CUTOFF)).toBe(0);
});
});
@@ -126,9 +126,11 @@ describe('AssistantAttachmentsController', () => {
it('ASTATT-004: an accepted file starts the turn on the response, for the caller and the trip', async () => {
const t = make();
const res = new FakeResponse();
for (const name of ['Bestätigung.PDF', 'ticket.pkpass', 'trip.ics', 'mail.eml', 'photo.jpg']) {
await t.controller.attach(user, '5', file(name), BODY, res as unknown as Response);
}
await Promise.all(
['Bestätigung.PDF', 'ticket.pkpass', 'trip.ics', 'mail.eml', 'photo.jpg'].map((name) =>
t.controller.attach(user, '5', file(name), BODY, res as unknown as Response),
),
);
expect(t.service.attach).toHaveBeenCalledTimes(5);
const [userId, tripId, body, given, channel] = t.service.attach.mock.calls[0];
expect([userId, tripId, body]).toEqual([7, 5, BODY]);
@@ -5,6 +5,7 @@
* applies it, the same an MCP write runs.
*/
import { AssistantBookingImportService } from '../../../../src/nest/assistant/assistant-booking-import.service';
import { MAX_BOOKINGS_PER_FILE } from '../../../../src/nest/assistant/assistant-tools.constants';
import { BookingImportService } from '../../../../src/nest/booking-import/booking-import.service';
import { DemoService } from '../../../../src/nest/common/demo.service';
import { DomainError } from '../../../../src/nest/common/domain-error';
@@ -41,7 +42,7 @@ async function make(opts: Opts = {}) {
aiAvailable: vi.fn(async () => opts.ai === true),
readsImages: vi.fn(async () => opts.images === true),
preview: vi.fn(async (_files: Express.Multer.File[], _mode: string, _userId: number) => ({
items: Array.from({ length: 14 }, (_, index) => ({ ...ITEM, title: `Leg ${index}` })),
items: Array.from({ length: MAX_BOOKINGS_PER_FILE + 2 }, (_, index) => ({ ...ITEM, title: `Leg ${index}` })),
warnings: [],
})),
confirm: vi.fn(async () => ({ created: [{ id: 81, title: 'ICE 123', type: 'train', day_id: 31 }] })),
@@ -82,12 +83,12 @@ describe('AssistantBookingImportService', () => {
expect(await readable({ ai: true, images: true }, 'scan.jpg')).toBeNull();
});
it('ASTBKI-002: reads with the AI only as a fallback, and keeps a step\'s worth of bookings', async () => {
it("ASTBKI-002: reads with the AI only as a fallback, and keeps a step's worth of bookings", async () => {
const plain = await make();
const items = await plain.service.read(7, file('a.pdf'));
const given = expect.objectContaining({ originalname: 'a.pdf' });
expect(plain.bookingImport.preview).toHaveBeenCalledWith([given], 'no-ai', 7);
expect(items).toHaveLength(12);
expect(items).toHaveLength(MAX_BOOKINGS_PER_FILE);
const ai = await make({ ai: true });
await ai.service.read(7, file('a.pdf'));
expect(ai.bookingImport.preview.mock.calls[0][1]).toBe('fallback-on-empty');
@@ -104,14 +105,15 @@ describe('AssistantBookingImportService', () => {
});
it('ASTBKI-004: refused for a demo account, off the trip, without the right, or when broken', async () => {
for (const opts of [{ demo: true }, { trip: false }, { right: false }]) {
const refused = async (opts: Parameters<typeof make>[0]) => {
const t = await make(opts);
expect(await t.service.apply(7, 5, { ...ITEM })).toEqual({
ok: false,
value: 'You are not allowed to add bookings to this trip.',
});
expect(t.bookingImport.confirm).not.toHaveBeenCalled();
}
};
await Promise.all([{ demo: true }, { trip: false }, { right: false }].map(refused));
const broken = await make();
expect(await broken.service.apply(7, 5, { title: '' })).toEqual({
ok: false,
@@ -101,7 +101,9 @@ async function conversation() {
}
function add(c: { userId: number; tripId: number; threadId: number }, ref: string, dayId = DAY) {
return t.get(AssistantFoundPlacesService).add(c.userId, c.tripId, c.threadId, { ref, dayId, locale: 'en' });
return t
.get(AssistantFoundPlacesService)
.add(c.userId, c.tripId, c.threadId, { ref, dayId, locale: 'en', timeZone: 'UTC' });
}
function calls(threadId: number) {
@@ -118,7 +118,7 @@ describe('toChatHistory', () => {
});
it('ASTHIS-002: cuts the lookups of earlier turns, keeps the current turn whole', () => {
const big = { places: Array.from({ length: 200 }, (_, i) => ({ id: i, name: `Place number ${i}` })) };
const big = { places: Array.from({ length: 500 }, (_, i) => ({ id: i, name: `Place number ${i}` })) };
const rows = [
row('user', [{ type: 'text', text: 'one' }]),
row('assistant', [{ type: 'tool-call', toolCallId: 'a', toolName: 'list_places', args: {}, result: big }]),
@@ -126,7 +126,7 @@ describe('toChatHistory', () => {
row('assistant', [{ type: 'tool-call', toolCallId: 'b', toolName: 'list_places', args: {}, result: big }]),
];
const outputs = toChatHistory(rows, new Map()).filter((m) => m.role === 'tool') as Array<{ output: string }>;
expect(outputs[0].output.length).toBeLessThanOrEqual(2_000);
expect(outputs[0].output.length).toBeLessThanOrEqual(8_000);
expect(JSON.parse(outputs[0].output)).toMatchObject({ truncated: true });
expect(outputs[1].output).toBe(JSON.stringify(big));
});
@@ -0,0 +1,57 @@
/**
* The trip notes the assistant keeps: a note goes below what is there, the
* same note is not added twice, notes that would run past the limit are
* refused whole, and other trips' notes stay as they are.
*/
import { noteOf, REMEMBER_SPEC, rememberForTrip } from '../../../../src/nest/assistant/assistant-memory';
import type { SettingsService } from '../../../../src/nest/settings/settings.service';
import { describe, expect, it, vi } from 'vitest';
function settingsWith(prefs: Record<string, unknown>) {
const upsertSetting = vi.fn(async () => undefined);
const settings = { getUserSettings: vi.fn(async () => prefs), upsertSetting } as unknown as SettingsService;
return { settings, upsertSetting };
}
describe('the trip notes', () => {
it('ASTMEM-001: a note goes below the trip notes there are, and other trips keep theirs', async () => {
const { settings, upsertSetting } = settingsWith({ assistant_memory: { '4': 'Vegetarian', '9': 'Other trip' } });
const run = await rememberForTrip(settings, 1, 4, { note: ' We travel as two. ' });
expect(run).toEqual({ ok: true, value: { notes: 'Vegetarian\nWe travel as two.' } });
expect(upsertSetting).toHaveBeenCalledWith(1, 'assistant_memory', {
'4': 'Vegetarian\nWe travel as two.',
'9': 'Other trip',
});
});
it('ASTMEM-002: the first note starts them, and a note kept already is not added again', async () => {
const first = settingsWith({});
expect(await rememberForTrip(first.settings, 1, 4, { note: 'No museums' })).toEqual({
ok: true,
value: { notes: 'No museums' },
});
expect(first.upsertSetting).toHaveBeenCalledWith(1, 'assistant_memory', { '4': 'No museums' });
const again = settingsWith({ assistant_memory: { '4': 'No museums' } });
expect((await rememberForTrip(again.settings, 1, 4, { note: 'No museums' })).ok).toBe(true);
expect(again.upsertSetting).not.toHaveBeenCalled();
});
it('ASTMEM-003: nothing to keep, or no room left, is refused and writes nothing', async () => {
const empty = settingsWith({});
expect(await rememberForTrip(empty.settings, 1, 4, { note: ' ' })).toMatchObject({ ok: false });
const full = settingsWith({ assistant_memory: { '4': 'x'.repeat(1_990) } });
expect(await rememberForTrip(full.settings, 1, 4, { note: 'One more thing to keep' })).toMatchObject({
ok: false,
});
expect(empty.upsertSetting).not.toHaveBeenCalled();
expect(full.upsertSetting).not.toHaveBeenCalled();
});
it('ASTMEM-004: the note is read trimmed and capped, and the model is offered one short sentence', () => {
expect(noteOf({ note: 42 })).toBeNull();
expect(noteOf({ note: 'y'.repeat(600) })).toHaveLength(500);
expect(REMEMBER_SPEC.parameters).toMatchObject({ required: ['note'], additionalProperties: false });
});
});
@@ -37,12 +37,13 @@ describe('fitToSize', () => {
it('ASTRED-011: cuts arrays and long strings, says so, and stays valid JSON', () => {
const value = {
places: Array.from({ length: 500 }, (_, i) => ({ id: i, name: `Place ${i}`, about: 'x'.repeat(2000) })),
places: Array.from({ length: 500 }, (_, i) => ({ id: i, name: `Place ${i}`, about: 'x'.repeat(6000) })),
};
const fitted = fitToSize(value, 12_000) as { places: Array<{ about: string }>; truncated: boolean };
expect(fitted.truncated).toBe(true);
expect(fitted.places.length).toBeLessThan(500);
expect(fitted.places[0].about.length).toBe(1001);
// A string stops at 4 000 characters and an ellipsis.
expect(fitted.places[0].about.length).toBe(4001);
expect(JSON.stringify(fitted).length).toBeLessThanOrEqual(12_000);
});
@@ -73,7 +73,7 @@ beforeAll(async () => {
orm = app.get(MikroORM);
registry = app.get(McpRegistryService);
tools = app.get(AssistantToolsService);
for (const addon of ['packing', 'budget']) await setAddonEnabled(orm, addon, true);
await Promise.all(['packing', 'budget'].map((addon) => setAddonEnabled(orm, addon, true)));
const { user } = await makeUser(orm);
listed = await listRaw(user.id);
});
@@ -91,7 +91,7 @@ describe('the assistant tool catalog', () => {
expect(checkToolCatalog(clash)).toContain('"import_booking" is the server\'s own step');
});
it('ASTTOOL-006: the server\'s own steps are never offered to the model', () => {
it("ASTTOOL-006: the server's own steps are never offered to the model", () => {
for (const name of ASSISTANT_SERVER_TOOLS) {
expect(ASSISTANT_TOOL_ALLOWLIST.has(name), name).toBe(false);
expect(listed.has(name), name).toBe(false);
@@ -149,7 +149,8 @@ describe('an assistant tool session', () => {
const specs = session.specs();
expect(specs.length).toBeGreaterThan(0);
for (const spec of specs) {
expect(ASSISTANT_TOOL_ALLOWLIST.has(spec.name), spec.name).toBe(true);
// The trip notes are the server's own tool, offered beside the allowlist.
expect(ASSISTANT_TOOL_ALLOWLIST.has(spec.name) || spec.name === 'remember_for_trip', spec.name).toBe(true);
const properties = Object.keys((spec.parameters.properties as Record<string, unknown> | undefined) ?? {});
expect(properties, spec.name).not.toContain('tripId');
const policy = ASSISTANT_ARG_POLICY[spec.name];
@@ -307,6 +307,18 @@ describe('describeToolCall', () => {
expect(elsewhere.dayId).toBeNull();
expect(elsewhere.rows.map((row) => row.labelKey)).toEqual(['type', 'time']);
});
it('ASTPRV-022: a changed expense names what it becomes, a kept note is its own subject', () => {
const expense = describe_('update_budget_item', { itemId: 31, total_price: 60, member_ids: [7, 8] });
expect(expense).toMatchObject({ verbKey: 'updateBudgetItem', subject: '#31', dayId: null });
expect(expense.rows).toEqual([
{ op: 'add', labelKey: 'price', before: null, after: '€60.00' },
{ op: 'add', labelKey: 'members', before: null, after: 'mia, jon' },
]);
expect(describe_('update_budget_item', { itemId: 31, name: 'Dinner for two' }).subject).toBe('Dinner for two');
const note = describe_('remember_for_trip', { note: ' We travel as two. ' });
expect(note).toMatchObject({ verbKey: 'rememberNote', subject: 'We travel as two.', rows: [] });
});
});
describe('appliedChange', () => {
+1 -1
View File
@@ -1527,7 +1527,7 @@ describe('Client ID Metadata Documents', () => {
const result = await validateAuthorizeRequest(
{
...authorizeParams(),
client_id: created.client!.client_id as string,
client_id: created.client?.client_id as string,
redirect_uri: 'https://example.com/callback',
},
user.id,
+2 -2
View File
@@ -398,7 +398,7 @@ const assistant: TranslationStrings = {
'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',
'assistant.change.details': 'Mehr Angaben',
'assistant.change.detailsHide': 'Angaben ausblenden',
};
export default assistant;
+2 -2
View File
@@ -399,7 +399,7 @@ const assistant: TranslationStrings = {
'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',
'assistant.change.details': 'Meer gegevens',
'assistant.change.detailsHide': 'Gegevens verbergen',
};
export default assistant;