mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}.`);
|
||||
});
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user