Files
TREK/client/src/components/Assistant/assistantParts.test.ts
T
Maurice b64da26651 feat(assistant): trip assistant chat with your own AI connection
Adds an in-app assistant that reads and plans the open trip. Users connect
their own provider (Sign in with ChatGPT, or an Anthropic, OpenAI, Gemini or
OpenRouter key) or use the one the admin set up, including a custom
OpenAI-compatible server. It is an addon and only shows while a connection
is usable.

The chat runs on the existing MCP tools, pinned to the trip. Every change is
held as a change card the user applies or discards, and applied changes can
be undone. On the desktop it is a floating window that can be moved and
resized; on the phone it is fullscreen. Search results show as place cards
that can go onto a day, weather lookups as a strip, proposed places as
ghost markers on the map with a before/after switch, and an answer can be
shared into the trip chat. Booking files can be dropped in and are read
into change cards.
2026-10-22 21:30:12 +00:00

325 lines
11 KiB
TypeScript

/**
* FE-AI-PARTS-001 to FE-AI-PARTS-014
*
* How an answer is laid out: text, the steps the assistant looked up, the
* places and the weather they found, and one card per batch of proposed
* changes, each change in the state its approval and receipt put it in.
*/
import {
ASSISTANT_CHANGE_LABEL_KEYS,
type AssistantApplied,
type AssistantChangePreview,
type AssistantFoundPlace,
type AssistantMessage,
type AssistantMessagePart,
type AssistantWeatherDay,
} from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { CHANGE_LABELS, hasUndecided, messageBlocks, suggestedFromStrip, undoLeftMs } from './assistantParts';
const AT = '2026-10-21T08:00:00.000Z';
function message(parts: AssistantMessagePart[]): AssistantMessage {
return { id: 'm1', role: 'assistant', status: 'complete', createdAt: AT, parts };
}
function preview(toolCallId: string): AssistantChangePreview {
return {
toolCallId,
verbKey: 'addTodo',
subject: `Todo ${toolCallId}`,
rows: [],
dayId: null,
placeIds: [],
undoable: true,
};
}
function applied(toolCallId: string, undoUntil: string | null = null): AssistantApplied {
return { toolCallId, entity: 'todo', ids: [1], dayId: null, undoUntil };
}
const read = (id: string, result?: unknown): AssistantMessagePart => ({
type: 'tool-call',
toolCallId: id,
toolName: 'list_places',
args: {},
...(result !== undefined ? { result } : {}),
});
const write = (
id: string,
approval: { approved?: boolean; resolution?: 'cancelled' | 'expired' } = {},
outcome: { result?: unknown; isError?: boolean } = {}
): AssistantMessagePart => ({
type: 'tool-call',
toolCallId: id,
toolName: 'create_todo',
args: {},
approval: { id, ...approval },
...outcome,
});
describe('messageBlocks', () => {
it('FE-AI-PARTS-001: keeps text, gathers adjacent reading steps and drops empty text', () => {
const blocks = messageBlocks(
message([
{ type: 'text', text: 'Let me look.' },
read('r1', { ok: 1 }),
read('r2', { ok: 2 }),
{ type: 'text', text: ' ' },
{ type: 'text', text: 'Found two.' },
]),
false
);
expect(blocks.map((b) => b.kind)).toEqual(['text', 'steps', 'text']);
expect(blocks[1]).toMatchObject({
kind: 'steps',
steps: [
{ toolCallId: 'r1', running: false },
{ toolCallId: 'r2', running: false },
],
});
});
it('FE-AI-PARTS-002: a step without its result runs only while the answer is being written', () => {
const parts = [read('r1')];
expect(messageBlocks(message(parts), true)[0]).toMatchObject({ steps: [{ running: true }] });
expect(messageBlocks(message(parts), false)[0]).toMatchObject({ steps: [{ running: false }] });
});
it('FE-AI-PARTS-003: the changes of one step share a card, each with its preview, wherever the preview sits', () => {
const blocks = messageBlocks(
message([
write('w1'),
{ type: 'data-trek-change', data: preview('w1') },
write('w2'),
{ type: 'data-trek-change', data: preview('w2') },
]),
false
);
expect(blocks).toHaveLength(1);
expect(blocks[0]).toMatchObject({
kind: 'change',
items: [
{ toolCallId: 'w1', state: 'pending', preview: { subject: 'Todo w1' } },
{ toolCallId: 'w2', state: 'pending', preview: { subject: 'Todo w2' } },
],
});
});
it('FE-AI-PARTS-004: reads each change state off its approval, result and receipt', () => {
const parts: AssistantMessagePart[] = [
write('applying', { approved: true }),
write('done', { approved: true }, { result: { id: 1 } }),
write('receipt', { approved: true }),
{ type: 'data-trek-applied', data: applied('receipt') },
write('broke', { approved: true }, { result: 'nope', isError: true }),
write('no', { approved: false }),
write('late', { resolution: 'expired' }),
];
const [card] = messageBlocks(message(parts), false);
expect(card?.kind === 'change' && card.items.map((i) => [i.toolCallId, i.state])).toEqual([
['applying', 'applying'],
['done', 'applied'],
['receipt', 'applied'],
['broke', 'failed'],
['no', 'discarded'],
['late', 'expired'],
]);
expect(card?.kind === 'change' && card.items[2]?.applied).toEqual(applied('receipt'));
});
it('FE-AI-PARTS-005: text between two batches of changes splits them into two cards', () => {
const blocks = messageBlocks(message([write('w1'), { type: 'text', text: 'And one more.' }, write('w2')]), false);
expect(blocks.map((b) => b.kind)).toEqual(['change', 'text', 'change']);
});
});
function found(ref: string, extra: Partial<AssistantFoundPlace> = {}): AssistantFoundPlace {
return {
ref,
name: `Place ${ref}`,
address: null,
lat: 41.89,
lng: 12.47,
kind: 'food',
rating: null,
openNow: null,
googlePlaceId: null,
osmId: null,
photoKey: null,
inTripPlaceId: null,
...extra,
};
}
const places = (toolCallId: string, list: AssistantFoundPlace[], more = 0): AssistantMessagePart => ({
type: 'data-trek-places',
data: { toolCallId, places: list, more },
});
function sky(date: string, tempMax: number): AssistantWeatherDay {
return { dayId: null, date, main: 'Clear', tempMax, tempMin: null, rainPct: null, kind: 'forecast' };
}
/** A change of the model that adds a place to day 3: a new one at a spot, or the trip's own by id. */
function adds(id: string, spot: { lat: number; lng: number } | null, placeId: number | null): AssistantMessagePart {
return {
type: 'data-trek-change',
data: {
...preview(id),
effect: {
kind: 'add',
dayId: 3,
fromDayId: null,
placeId,
assignmentId: null,
place: spot && { name: 'New', categoryId: null, ...spot },
},
},
};
}
describe('messageBlocks: found places and weather', () => {
it('FE-AI-PARTS-008: shows only the newest search, and steps after it join the row before it', () => {
const blocks = messageBlocks(
message([
read('s1', []),
places('s1', [found('s1:0')]),
read('s2', []),
places('s2', [found('s2:0'), found('s2:1', { lat: 40 })], 3),
{ type: 'text', text: 'Two good ones.' },
]),
false
);
expect(blocks.map((b) => b.kind)).toEqual(['steps', 'places', 'text']);
expect(blocks[0]).toMatchObject({ steps: [{ toolCallId: 's1' }, { toolCallId: 's2' }] });
expect(blocks[1]).toMatchObject({
found: { toolCallId: 's2', more: 3, places: [{ ref: 's2:0' }, { ref: 's2:1' }] },
});
});
it('FE-AI-PARTS-009: drops the places a change of the model proposes, and the strip if none is left', () => {
const blocks = messageBlocks(
message([
places('s1', [found('s1:0'), found('s1:1', { lat: 40, inTripPlaceId: 8 }), found('s1:2', { lat: 39 })]),
write('w1'),
adds('w1', { lat: 41.89, lng: 12.47 }, null),
write('w2'),
adds('w2', null, 8),
]),
false
);
expect(blocks[0]).toMatchObject({ kind: 'places', found: { places: [{ ref: 's1:2' }] } });
const none = messageBlocks(
message([places('s1', [found('s1:0')]), write('w1'), adds('w1', { lat: 41.89, lng: 12.47 }, null)]),
false
);
expect(none.map((b) => b.kind)).toEqual(['change']);
});
it('FE-AI-PARTS-010: a place suggested from the strip stays on it and reads as suggested', () => {
const msg = message([
places('s1', [found('s1:0'), found('s1:1', { lat: 40 })]),
{
type: 'tool-call',
toolCallId: 'f1',
toolName: 'add_found_place',
args: { via: 'create_and_assign_place', dayId: 3, name: 'Place s1:0', lat: 41.89, lng: 12.47 },
approval: { id: 'f1' },
},
adds('f1', { lat: 41.89, lng: 12.47 }, null),
]);
const [strip] = messageBlocks(msg, false);
expect(strip).toMatchObject({ kind: 'places', found: { places: [{ ref: 's1:0' }, { ref: 's1:1' }] } });
expect(suggestedFromStrip(msg, found('s1:0'))).toBe(true);
expect(suggestedFromStrip(msg, found('s1:1', { lat: 40 }))).toBe(false);
const inTrip = message([
{
type: 'tool-call',
toolCallId: 'f2',
toolName: 'add_found_place',
args: { placeId: 8 },
approval: { id: 'f2' },
},
]);
expect(suggestedFromStrip(inTrip, found('x', { inTripPlaceId: 8 }))).toBe(true);
expect(suggestedFromStrip(inTrip, found('x', { inTripPlaceId: 9 }))).toBe(false);
});
it('FE-AI-PARTS-014: each suggestion from the strip is a card of its own, apart from the changes around it', () => {
const fromStrip = (id: string, approval: { approved?: boolean } = {}): AssistantMessagePart => ({
type: 'tool-call',
toolCallId: id,
toolName: 'add_found_place',
args: { placeId: 8 },
approval: { id, ...approval },
});
const blocks = messageBlocks(
message([write('w1', { approved: true }), fromStrip('f1', { approved: true }), fromStrip('f2'), write('w2')]),
false
);
expect(blocks.map((b) => (b.kind === 'change' ? b.items.map((i) => i.toolCallId) : b.kind))).toEqual([
['w1'],
['f1'],
['f2'],
['w2'],
]);
});
it('FE-AI-PARTS-011: the weather of all steps is one row, one entry per date, where the first look sat', () => {
const blocks = messageBlocks(
message([
read('w1', {}),
{ type: 'data-trek-weather', data: { days: [sky('2026-11-03', 18), sky('2026-11-02', 20)] } },
{ type: 'text', text: 'Mild.' },
{ type: 'data-trek-weather', data: { days: [sky('2026-11-03', 15)] } },
]),
false
);
expect(blocks.map((b) => b.kind)).toEqual(['steps', 'weather', 'text']);
expect(blocks[1]).toMatchObject({
kind: 'weather',
days: [
{ date: '2026-11-02', tempMax: 20 },
{ date: '2026-11-03', tempMax: 15 },
],
});
});
});
describe('hasUndecided', () => {
it('FE-AI-PARTS-012: is true only while a change waits for the user', () => {
expect(hasUndecided([message([write('w1')])])).toBe(true);
expect(hasUndecided([message([write('w1', { approved: true })])])).toBe(false);
expect(hasUndecided([message([write('w1', { resolution: 'expired' })])])).toBe(false);
expect(hasUndecided([message([read('r1')])])).toBe(false);
});
});
describe('undoLeftMs', () => {
it('FE-AI-PARTS-006: counts down to the end of the undo window and stops at zero', () => {
const now = Date.parse(AT);
expect(undoLeftMs(applied('a', '2026-10-21T08:10:00.000Z'), now)).toBe(10 * 60_000);
expect(undoLeftMs(applied('a', '2026-10-21T07:59:00.000Z'), now)).toBe(0);
expect(undoLeftMs(applied('a', null), now)).toBe(0);
expect(undoLeftMs(null, now)).toBe(0);
});
});
describe('CHANGE_LABELS', () => {
it('FE-AI-PARTS-013: names a route', () => {
expect(CHANGE_LABELS.route.labelKey).toBe('assistant.change.label.route');
});
it('FE-AI-PARTS-007: names every field a change row can carry', () => {
for (const key of ASSISTANT_CHANGE_LABEL_KEYS) {
expect(CHANGE_LABELS[key].labelKey).toBe(`assistant.change.label.${key}`);
}
});
});