mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
ea7120e141
An answer that searched several times showed only its last search, about ten places. The strip now holds the places of all its searches, each once (the same Google or OSM id, or the same name at the same spot), and shows up to 60.
327 lines
11 KiB
TypeScript
327 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: joins the places of all searches into one strip, each once, and steps after it join the row before it', () => {
|
|
const blocks = messageBlocks(
|
|
message([
|
|
read('s1', []),
|
|
places('s1', [found('s1:0'), found('s1:1', { googlePlaceId: 'ChIJa' })], 2),
|
|
read('s2', []),
|
|
places('s2', [found('s2:0', { name: 'Place s1:0' }), found('s2:1', { googlePlaceId: 'ChIJa', lat: 40 })], 3),
|
|
places('s3', [found('s3:0', { lat: 40 })]),
|
|
{ type: 'text', text: 'Three good ones.' },
|
|
]),
|
|
false
|
|
);
|
|
expect(blocks.map((b) => b.kind)).toEqual(['steps', 'places', 'text']);
|
|
expect(blocks[0]).toMatchObject({ steps: [{ toolCallId: 's1' }, { toolCallId: 's2' }] });
|
|
// The same name at the same spot, or the same Google id, shows once.
|
|
expect(blocks[1]).toMatchObject({
|
|
found: { toolCallId: 's3', more: 5, places: [{ ref: 's1:0' }, { ref: 's1:1' }, { ref: 's3:0' }] },
|
|
});
|
|
});
|
|
|
|
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}`);
|
|
}
|
|
});
|
|
});
|