Files
TREK/client/src/components/Assistant/assistantFollowUpRules.test.ts
T
Maurice d6b624e400 fix(assistant): one tap plans the tour for real, and a clear request gets a proposal
- The server joins a take to the answer that drew its proposal; the card only
  looked in later answers, so it never found the take to decide and "Plan
  this" still waited below for a second confirmation.
- An answer that asks the user something offers no fixed chips at all, only
  the model's own answers to its question.
- The model proposes a clear request at once, saying what it assumed, and
  puts the way it recommends first, which the card shows chosen.
2026-10-23 15:44:39 +02:00

125 lines
5.3 KiB
TypeScript

/**
* FE-AI-FOLLOW-001 to FE-AI-FOLLOW-007
*
* The follow-ups a card offers under an answer that ends on it: the ones that
* ask for a change only for someone who may make it, the answer's own when
* none is left for a reader, and the ones with a condition only when a card of
* the answer meets it. The questions the model offered win over all of them; a
* change asks what its own kind offers, the day plan's questions come only
* after a change to the day plan, and an answer that asks the user something
* offers no "tell me more".
*/
import type { AssistantChangePreview, AssistantMessage, TrekCard } from '@trek/shared';
import { afterEach, describe, expect, it } from 'vitest';
import { followUpsFor } from './assistantFollowUpRules';
import { CARD_LOOKS } from './cards/cardLooks';
const answer: AssistantMessage = {
id: 'a1',
role: 'assistant',
status: 'complete',
createdAt: '2026-10-22T08:00:00.000Z',
parts: [
{ type: 'text', text: 'Here is your year.' },
{ type: 'tool-call', toolCallId: 'r1', toolName: 'get_vacay_plan', args: {}, result: {} },
{ type: 'data-trek-card', data: { toolCallId: 'r1', card: { kind: 'vacay' } as TrekCard } },
],
};
afterEach(() => {
delete CARD_LOOKS.vacay.followUps;
});
describe('card follow-ups', () => {
it('FE-AI-FOLLOW-001: a follow-up that asks for a change is offered only to someone who may make it', () => {
CARD_LOOKS.vacay.followUps = [
{ id: 'planDays', labelKey: 'assistant.followUp.next', edits: true },
{ id: 'holidays', labelKey: 'assistant.followUp.more' },
];
expect(followUpsFor(answer, true).map((f) => f.id)).toEqual(['planDays', 'holidays']);
expect(followUpsFor(answer, false).map((f) => f.id)).toEqual(['holidays']);
});
it('FE-AI-FOLLOW-002: with no card follow-up left for a reader, the answer offers its own', () => {
CARD_LOOKS.vacay.followUps = [{ id: 'planDays', labelKey: 'assistant.followUp.next', edits: true }];
expect(followUpsFor(answer, false).map((f) => f.id)).toEqual(['more', 'next']);
});
it('FE-AI-FOLLOW-003: a follow-up with a condition is offered only when a card of the answer meets it', () => {
const asked: TrekCard[] = [];
CARD_LOOKS.vacay.followUps = [
{ id: 'planDays', labelKey: 'assistant.followUp.next', when: () => false },
{
id: 'holidays',
labelKey: 'assistant.followUp.more',
when: (card) => {
asked.push(card);
return true;
},
},
];
expect(followUpsFor(answer, true).map((f) => f.id)).toEqual(['holidays']);
expect(asked).toEqual([{ kind: 'vacay' }]);
});
});
/** An answer of the given parts. */
const of = (parts: AssistantMessage['parts']): AssistantMessage => ({ ...answer, parts });
/** A proposed change, with the card of what it does. */
const change = (card?: TrekCard): AssistantChangePreview => ({
toolCallId: 'c1',
verbKey: 'addPlace',
subject: 'Something',
rows: [],
dayId: null,
placeIds: [],
undoable: true,
...(card ? { card } : {}),
});
describe('the questions an answer offers', () => {
it('FE-AI-FOLLOW-004: the questions the model offered win, sent as written, the last offer only', () => {
const offered = of([
{ type: 'text', text: 'Done.' },
{ type: 'data-trek-follow-ups', data: { questions: ['Old'] } },
{ type: 'data-trek-follow-ups', data: { questions: ['An easier variant', 'What to pack?'] } },
]);
expect(followUpsFor(offered, true).map((f) => f.text)).toEqual(['An easier variant', 'What to pack?']);
});
it('FE-AI-FOLLOW-005: a proposed tour asks about the tour, never to plan the next day the same way', () => {
const tour = { kind: 'tourRoute', source: 'proposal', activity: 'hike' } as TrekCard;
const ids = followUpsFor(of([{ type: 'data-trek-change', data: change(tour) }]), true).map((f) => f.id);
expect(ids).toContain('tourEasier');
expect(ids).not.toContain('nextDay');
});
it('FE-AI-FOLLOW-006: an applied expense offers the general questions, an applied stop the day plan', () => {
const applied = (entity: 'budget' | 'assignment') =>
of([
{ type: 'text', text: 'Added.' },
{
type: 'data-trek-applied',
data: { toolCallId: 'c1', entity, ids: [1], dayId: null, undoUntil: null },
},
]);
expect(followUpsFor(applied('budget'), true).map((f) => f.id)).toEqual(['more', 'next']);
expect(followUpsFor(applied('assignment'), true).map((f) => f.id)).toContain('nextDay');
});
it('FE-AI-FOLLOW-007: an answer that asks the user something waits for their answer, not for "tell me more"', () => {
expect(followUpsFor(of([{ type: 'text', text: 'Should I plan it anyway?' }]), true)).toEqual([]);
// Not the found places' chips either, but the model's own answers to its question.
const asked = of([
{ type: 'data-trek-places', data: { toolCallId: 's', places: [], more: 0 } },
{ type: 'text', text: 'Shall I plan it there?' },
]);
expect(followUpsFor(asked, true)).toEqual([]);
const offered = of([...asked.parts, { type: 'data-trek-follow-ups', data: { questions: ['Yes, plan it'] } }]);
expect(followUpsFor(offered, true).map((f) => f.text)).toEqual(['Yes, plan it']);
expect(followUpsFor(of([{ type: 'text', text: 'Here it is.' }]), true).map((f) => f.id)).toEqual(['more', 'next']);
});
});