mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
test(assistant): shared card tests, wiki rows and ratchets after the card packages
This commit is contained in:
@@ -9,9 +9,9 @@
|
||||
"max-params": 35,
|
||||
"trek/store-without-selector": 69,
|
||||
"react-hooks/refs": 557,
|
||||
"react-hooks/set-state-in-effect": 196,
|
||||
"@typescript-eslint/no-floating-promises": 166,
|
||||
"react-hooks/exhaustive-deps": 118,
|
||||
"react-hooks/set-state-in-effect": 195,
|
||||
"@typescript-eslint/no-floating-promises": 165,
|
||||
"react-hooks/exhaustive-deps": 117,
|
||||
"@typescript-eslint/no-unused-vars": 127,
|
||||
"react-hooks/immutability": 28,
|
||||
"max-lines-per-function": 133,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"admin.ts": 152,
|
||||
"atlas.ts": 10,
|
||||
"atlas.ts": 9,
|
||||
"backup.ts": 10,
|
||||
"budget.ts": 40,
|
||||
"budget.ts": 36,
|
||||
"categories.ts": 1,
|
||||
"collab.ts": 9,
|
||||
"collection.ts": 35,
|
||||
@@ -14,7 +14,7 @@
|
||||
"files.ts": 4,
|
||||
"help.ts": 3,
|
||||
"inspector.ts": 4,
|
||||
"journey.ts": 69,
|
||||
"journey.ts": 66,
|
||||
"login.ts": 1,
|
||||
"map.ts": 5,
|
||||
"memories.ts": 8,
|
||||
@@ -35,7 +35,7 @@
|
||||
"shared.ts": 2,
|
||||
"storage.ts": 16,
|
||||
"system_notice.ts": 32,
|
||||
"todo.ts": 4,
|
||||
"todo.ts": 3,
|
||||
"tours.ts": 14,
|
||||
"trip.ts": 10,
|
||||
"undo.ts": 1,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* ask for a change only for someone who may make it, and the answer's own
|
||||
* when none is left for a reader.
|
||||
*/
|
||||
import type { AssistantMessage } from '@trek/shared';
|
||||
import type { AssistantMessage, TrekCard } from '@trek/shared';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { followUpsFor } from './assistantFollowUpRules';
|
||||
@@ -19,7 +19,7 @@ const answer: AssistantMessage = {
|
||||
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' } } },
|
||||
{ type: 'data-trek-card', data: { toolCallId: 'r1', card: { kind: 'vacay' } as TrekCard } },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -304,8 +304,8 @@ describe('messageBlocks: found places and weather', () => {
|
||||
});
|
||||
|
||||
describe('messageBlocks: cards', () => {
|
||||
const ROUTE: TrekCard = { kind: 'tourRoute' };
|
||||
const LIST: TrekCard = { kind: 'tourList' };
|
||||
const ROUTE = { kind: 'tourRoute' } as TrekCard;
|
||||
const LIST = { kind: 'tourList' } as TrekCard;
|
||||
const card = (toolCallId: string, value: TrekCard | null): AssistantMessagePart => ({
|
||||
type: 'data-trek-card',
|
||||
data: { toolCallId, card: value },
|
||||
|
||||
@@ -30,11 +30,15 @@ describe('assistantShowOnMap', () => {
|
||||
|
||||
it('FE-ASSIST-SHOW-003: no target where nothing shows on the map; a place without a spot opens the planner', () => {
|
||||
expect(showOnMapTarget(applied('packing', [2], null))).toBeNull();
|
||||
expect(showOnMapTarget(applied('tour', [2], 3))).toBeNull();
|
||||
expect(showOnMapTarget(applied('tour', [], 3))).toBeNull();
|
||||
expect(showOnMapTarget(applied('memory', [], null))).toBeNull();
|
||||
expect(searchOf(applied('place', [], null))).toBe('');
|
||||
});
|
||||
|
||||
it('FE-ASSIST-SHOW-005: an applied tour goes to its line on the plan, by the place it is', () => {
|
||||
expect(searchOf(applied('tour', [2], 3))).toBe('?place=2');
|
||||
});
|
||||
|
||||
it('FE-ASSIST-SHOW-004: a tab of the trip goes as ?tab= with what the tab reads beside it', () => {
|
||||
const expense = plannerSearchOf({ kind: 'tab', tab: 'finanzplan', params: { expense: '7' } });
|
||||
expect(expense).toBe('?tab=finanzplan&expense=7');
|
||||
|
||||
@@ -17,6 +17,8 @@ vi.mock('./cardViews', async (importOriginal) => {
|
||||
const { createElement } = await import('react');
|
||||
const views = {
|
||||
...actual.CARD_VIEWS,
|
||||
// A kind left without a view, whatever its package draws.
|
||||
vacay: {},
|
||||
tourList: {
|
||||
Block: function Block({ cards }: { cards: readonly { toolCallId: string }[] }) {
|
||||
return createElement('p', null, `${cards.length} tour lists`);
|
||||
@@ -50,12 +52,12 @@ afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe('AssistantCardBlock', () => {
|
||||
it('FE-AI-CARDBLOCK-001: the kind draws the whole block', () => {
|
||||
render(<AssistantCardBlock block={block({ kind: 'tourList' }, 2)} message={MESSAGE} />);
|
||||
render(<AssistantCardBlock block={block({ kind: 'tourList' } as TrekCard, 2)} message={MESSAGE} />);
|
||||
expect(screen.getByText('2 tour lists')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-CARDBLOCK-002: a kind not drawn yet shows nothing', () => {
|
||||
const { container } = render(<AssistantCardBlock block={block({ kind: 'vacay' })} message={MESSAGE} />);
|
||||
const { container } = render(<AssistantCardBlock block={block({ kind: 'vacay' } as TrekCard)} message={MESSAGE} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
@@ -63,7 +65,7 @@ describe('AssistantCardBlock', () => {
|
||||
const logged = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const { container } = render(
|
||||
<div>
|
||||
<AssistantCardBlock block={block({ kind: 'tourRoute' })} message={MESSAGE} />
|
||||
<AssistantCardBlock block={block({ kind: 'tourRoute' } as TrekCard)} message={MESSAGE} />
|
||||
<span>the answer</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -64,9 +64,9 @@ vi.mock('./cardViews', async (importOriginal) => {
|
||||
|
||||
const TRIP = 4;
|
||||
const INITIAL = useAssistantStore.getState();
|
||||
const ROUTE: TrekCard = { kind: 'tourRoute' };
|
||||
const LIST: TrekCard = { kind: 'tourList' };
|
||||
const BROKEN: TrekCard = { kind: 'dayRoute' };
|
||||
const ROUTE = { kind: 'tourRoute' } as TrekCard;
|
||||
const LIST = { kind: 'tourList' } as TrekCard;
|
||||
const BROKEN = { kind: 'dayRoute' } as TrekCard;
|
||||
|
||||
function preview(toolCallId: string, subject: string): AssistantChangePreview {
|
||||
return {
|
||||
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
ASSISTANT_CHANGE_VERB_KEYS,
|
||||
type AssistantApplied,
|
||||
type AssistantChangeVerbKey,
|
||||
type TrekCard,
|
||||
type TrekCardKind,
|
||||
type TrekCardOf,
|
||||
trekCardSchema,
|
||||
} from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
@@ -32,6 +34,10 @@ import type { CardLook } from './cardTypes';
|
||||
import { CARD_VIEWS } from './cardViews';
|
||||
|
||||
const KINDS = trekCardSchema.options.map((option) => option.shape.kind.value) as TrekCardKind[];
|
||||
/** A vacation card cut down to its kind: the layout and map rules read nothing else of it. */
|
||||
const VACAY = { kind: 'vacay' } as TrekCard;
|
||||
/** A day route cut down to its kind, for the same reason. */
|
||||
const DAY_ROUTE = { kind: 'dayRoute' } as TrekCardOf<'dayRoute'>;
|
||||
|
||||
describe('cardLooks', () => {
|
||||
it('FE-AI-LOOK-001: every verb is named by its own key', () => {
|
||||
@@ -46,17 +52,22 @@ describe('cardLooks', () => {
|
||||
expect(STEP_LOOKS.get_trip_summary?.stepKey).toBe('assistant.step.reading');
|
||||
});
|
||||
|
||||
it('FE-AI-LOOK-003: only a place, a stop, a note and a booking show on the map, as before the cards', () => {
|
||||
it('FE-AI-LOOK-003: a place, a stop, a note, a booking and a stay show on the map; a kept note never', () => {
|
||||
const onMap = Object.entries(ENTITY_LOOK).flatMap(([entity, look]) => (look.onMap ? [entity] : []));
|
||||
expect(onMap.sort()).toEqual(['assignment', 'day_note', 'place', 'reservation']);
|
||||
expect(Object.values(ENTITY_LOOK).every((look) => !look.open)).toBe(true);
|
||||
// The core's own; a part of TREK may add its entities (a stay shows its place).
|
||||
expect(onMap).toEqual(expect.arrayContaining(['assignment', 'day_note', 'place', 'reservation', 'accommodation']));
|
||||
expect(onMap).not.toContain('memory');
|
||||
expect(onMap).not.toContain('other');
|
||||
for (const entity of ['place', 'assignment', 'day_note', 'memory', 'other'] as const) {
|
||||
expect(ENTITY_LOOK[entity].open).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-AI-LOOK-004: every kind has a look and a view; a pending view groups by kind and gives way to nothing', () => {
|
||||
expect(Object.keys(CARD_LOOKS).sort()).toEqual([...KINDS].sort());
|
||||
expect(Object.keys(CARD_VIEWS).sort()).toEqual([...KINDS].sort());
|
||||
expect(CARD_RULES.group({ kind: 'vacay' })).toBe('vacay');
|
||||
expect(CARD_RULES.yieldsToChange({ kind: 'expenses' }, { kind: 'payments' })).toBe(false);
|
||||
expect(CARD_RULES.group(VACAY)).toBe('vacay');
|
||||
expect(CARD_RULES.yieldsToChange({ kind: 'costPerPerson' } as TrekCard, VACAY)).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-AI-LOOK-005: a kind draws its lines and points on the map through its look', () => {
|
||||
@@ -66,10 +77,10 @@ describe('cardLooks', () => {
|
||||
],
|
||||
ghosts: (): GhostPoint[] => [{ id: 'via', lat: 45.1, lng: 7.2, name: 'Col', variant: 'point' }],
|
||||
};
|
||||
expect(look.lines?.({ kind: 'dayRoute' }, 'before')).toEqual([
|
||||
expect(look.lines?.(DAY_ROUTE, 'before')).toEqual([
|
||||
{ id: 'day:before', lines: [[[45.1, 7.2]]], color: 'var(--accent)', dashed: true },
|
||||
]);
|
||||
expect(look.ghosts?.({ kind: 'dayRoute' })[0]?.variant).toBe('point');
|
||||
expect(look.ghosts?.(DAY_ROUTE)[0]?.variant).toBe('point');
|
||||
});
|
||||
|
||||
it("FE-AI-LOOK-006: the layout rules and the map read a kind's look, nothing for one that says nothing", () => {
|
||||
@@ -78,19 +89,17 @@ describe('cardLooks', () => {
|
||||
route.yieldsToChange = (_answer, change) => change.kind === 'dayRoute';
|
||||
route.lines = (_card, use) => [{ id: `day:${use}`, lines: [], color: 'var(--accent)' }];
|
||||
try {
|
||||
expect(CARD_RULES.group({ kind: 'dayRoute' })).toBe('routes');
|
||||
expect(CARD_RULES.yieldsToChange({ kind: 'dayRoute' }, { kind: 'dayRoute' })).toBe(true);
|
||||
expect(CARD_MAP_LOOK.lines({ kind: 'dayRoute' }, 'look')).toEqual([
|
||||
{ id: 'day:look', lines: [], color: 'var(--accent)' },
|
||||
]);
|
||||
expect(CARD_RULES.group(DAY_ROUTE)).toBe('routes');
|
||||
expect(CARD_RULES.yieldsToChange(DAY_ROUTE, DAY_ROUTE)).toBe(true);
|
||||
expect(CARD_MAP_LOOK.lines(DAY_ROUTE, 'look')).toEqual([{ id: 'day:look', lines: [], color: 'var(--accent)' }]);
|
||||
} finally {
|
||||
delete route.group;
|
||||
delete route.yieldsToChange;
|
||||
delete route.lines;
|
||||
}
|
||||
expect(CARD_MAP_LOOK.lines({ kind: 'dayRoute' }, 'look')).toEqual([]);
|
||||
expect(CARD_MAP_LOOK.ghosts({ kind: 'vacay' })).toEqual([]);
|
||||
expect(CARD_MAP_LOOK.hidesEffectGhost({ kind: 'vacay' })).toBe(false);
|
||||
expect(CARD_MAP_LOOK.lines(DAY_ROUTE, 'look')).toEqual([]);
|
||||
expect(CARD_MAP_LOOK.ghosts(VACAY)).toEqual([]);
|
||||
expect(CARD_MAP_LOOK.hidesEffectGhost(VACAY)).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-AI-LOOK-007: a verb or entity only a newer server knows looks like a plain update or an other', () => {
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
*
|
||||
* The links the chat opens the planner with while it is on screen: another
|
||||
* tab of the trip switches there and leaves the URL, and the parts' own links
|
||||
* (tours, bookings, road trip) take nothing yet.
|
||||
* (tours, bookings, road trip) are each handed the planner, the workspace and
|
||||
* the query (each part's own test covers what it does with them).
|
||||
*/
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
@@ -12,6 +13,11 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useAssistantEntityLinks } from './useAssistantEntityLinks';
|
||||
|
||||
const parts = vi.hoisted(() => ({ tour: vi.fn(), booking: vi.fn(), roadtrip: vi.fn() }));
|
||||
vi.mock('./deepLinks/useTourDeepLink', () => ({ useTourDeepLink: parts.tour }));
|
||||
vi.mock('./deepLinks/useBookingDeepLink', () => ({ useBookingDeepLink: parts.booking }));
|
||||
vi.mock('./deepLinks/useRoadtripDeepLink', () => ({ useRoadtripDeepLink: parts.roadtrip }));
|
||||
|
||||
type Options = Parameters<typeof useAssistantEntityLinks>[0];
|
||||
|
||||
function setup(path: string) {
|
||||
@@ -37,9 +43,14 @@ describe('useAssistantEntityLinks', () => {
|
||||
expect(hook.result.current.search).toBe('?expense=7');
|
||||
});
|
||||
|
||||
it("FE-PLANNER-ENTITYLINK-002: the parts' own links take nothing yet, and nothing switches without a tab", () => {
|
||||
it("FE-PLANNER-ENTITYLINK-002: each part's own link gets the planner, the workspace and the query; nothing switches without a tab", () => {
|
||||
const { planner, hook } = setup('/trips/5?tour=12');
|
||||
expect(planner.handleTabChange).not.toHaveBeenCalled();
|
||||
expect(hook.result.current.search).toBe('?tour=12');
|
||||
for (const part of [parts.tour, parts.booking, parts.roadtrip]) {
|
||||
const links = part.mock.lastCall?.[0] as { planner: unknown; searchParams: URLSearchParams };
|
||||
expect(links.planner).toBe(planner);
|
||||
expect(links.searchParams.get('tour')).toBe('12');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,8 +71,8 @@ describe('assistantMessages: cards in an answer', () => {
|
||||
expect(messages[0]?.parts.map((p) => p.type)).toEqual(['data-trek-weather']);
|
||||
});
|
||||
|
||||
const ROUTE: TrekCard = { kind: 'tourRoute' };
|
||||
const LIST: TrekCard = { kind: 'tourList' };
|
||||
const ROUTE = { kind: 'tourRoute' } as TrekCard;
|
||||
const LIST = { kind: 'tourList' } as TrekCard;
|
||||
const cardsOf = (message: AssistantMessage | undefined) =>
|
||||
message?.parts.flatMap((p) => (p.type === 'data-trek-card' ? [p.data] : [])) ?? [];
|
||||
|
||||
|
||||
@@ -13,14 +13,15 @@
|
||||
* booking file attached in the chat (the extractor, an external binary, is
|
||||
* stubbed on the booking import), the cards a search and a weather lookup
|
||||
* make (the place search and the forecast are stubbed on their services), a
|
||||
* found place suggested for a day without the model, mentions, and an answer
|
||||
* shared in the trip chat.
|
||||
* found place suggested for a day without the model, mentions, an answer
|
||||
* shared in the trip chat, and a vacation day held with its calendar card.
|
||||
*/
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { AssistantMessages } from '../../src/db/entities/AssistantMessages.entity';
|
||||
import { AssistantThreads } from '../../src/db/entities/AssistantThreads.entity';
|
||||
import { AssistantToolCalls } from '../../src/db/entities/AssistantToolCalls.entity';
|
||||
import { CollabMessages } from '../../src/db/entities/CollabMessages.entity';
|
||||
import { CollabPolls } from '../../src/db/entities/CollabPolls.entity';
|
||||
import { DayAssignments } from '../../src/db/entities/DayAssignments.entity';
|
||||
import { Places } from '../../src/db/entities/Places.entity';
|
||||
import { Reservations } from '../../src/db/entities/Reservations.entity';
|
||||
@@ -32,6 +33,9 @@ import { BookingImportService } from '../../src/nest/booking-import/booking-impo
|
||||
import { MapsService } from '../../src/nest/maps/maps.service';
|
||||
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
|
||||
import { WeatherService } from '../../src/nest/weather/weather.service';
|
||||
import { tourRouteCard } from '../helpers/assistant-tour-cards';
|
||||
import { makeBudgetItem } from '../helpers/factories/budget';
|
||||
import { makeCollabMessage } from '../helpers/factories/collab';
|
||||
import { makePlace } from '../helpers/factories/places';
|
||||
import { countRows, deleteRows, findRow, updateRows } from '../helpers/factories/rows';
|
||||
import { setAddonEnabled, setCollabFeature } from '../helpers/factories/settings';
|
||||
@@ -985,7 +989,7 @@ describe('Trip assistant e2e', () => {
|
||||
const t = await travelerWithTrip();
|
||||
const { turn, toolCallId, threadId } = await proposePlace(t, 'Sanjusangen-do');
|
||||
const proposing = Number(ofType(turn.chunks, 'start')[0].messageId.slice('msg-'.length));
|
||||
const built = { kind: 'tourRoute' } as TrekCard;
|
||||
const built: TrekCard = tourRouteCard();
|
||||
const applied = vi.spyOn(nestApp.get(AssistantCardContext), 'appliedCard').mockReturnValue(built);
|
||||
provider.script = [say('Added it.')];
|
||||
try {
|
||||
@@ -1037,4 +1041,169 @@ describe('Trip assistant e2e', () => {
|
||||
data: { toolCallId: 'call_later', card: null },
|
||||
});
|
||||
});
|
||||
|
||||
it('ASTE2E-023: a vacation day is held on its month with the days left, applied, and told to the own tabs', async () => {
|
||||
await setAddonEnabled(orm, 'vacay', true);
|
||||
const t = await travelerWithTrip();
|
||||
provider.script = [callTools({ id: 'call_day', name: 'toggle_vacay_entry', args: { date: '2026-06-12' } })];
|
||||
const turn = await post(`${t.base}/chat`, t.userId, { ...CTX, threadId: null, text: 'Take Friday off' });
|
||||
const [held] = ofType(turn.chunks, 'tool-approval-request');
|
||||
const [change] = ofType(turn.chunks, 'data-trek-change');
|
||||
expect(change.data).toMatchObject({ toolCallId: held.toolCallId, verbKey: 'addVacationDay', domain: 'vacay' });
|
||||
const card = change.data.card;
|
||||
if (card?.kind !== 'vacay') throw new Error('the held day has no vacation card');
|
||||
expect(card.months).toEqual([{ year: 2026, month: 6 }]);
|
||||
expect(card.proposal).toMatchObject({ date: '2026-06-12', personId: t.userId, outcome: 'add', periodYear: 2026 });
|
||||
expect(card.balances.map((balance) => balance.personId)).toEqual([t.userId]);
|
||||
expect(card.tripDates).toEqual(['2026-06-11', '2026-06-12']);
|
||||
|
||||
const threadId = ofType(turn.chunks, 'data-trek-thread')[0].data.threadId;
|
||||
provider.script = [say('Friday is off.')];
|
||||
broadcasts.broadcastToUser.mockClear();
|
||||
const decided = await post(`${t.base}/threads/${threadId}/decisions`, t.userId, {
|
||||
...CTX,
|
||||
decisions: [{ toolCallId: held.toolCallId, approved: true }],
|
||||
});
|
||||
expect(decided.status).toBe(200);
|
||||
expect(ofType(decided.chunks, 'data-trek-applied')[0].data).toMatchObject({
|
||||
toolCallId: held.toolCallId,
|
||||
entity: 'vacay',
|
||||
ids: [],
|
||||
undoUntil: null,
|
||||
});
|
||||
// The applied card follows the tool's answer, which took the day as the held card foresaw.
|
||||
const [receipt] = ofType(decided.chunks, 'data-trek-card');
|
||||
expect(receipt.data).toMatchObject({
|
||||
toolCallId: held.toolCallId,
|
||||
card: { kind: 'vacay', proposal: { date: '2026-06-12', outcome: 'add', deltas: [{ days: 1 }] } },
|
||||
});
|
||||
expect(broadcasts.broadcastToUser).toHaveBeenCalledWith(t.userId, { type: 'vacay:update' }, undefined);
|
||||
const shown = await get(`${t.base}/threads/${threadId}`, t.userId);
|
||||
expect(shown.status).toBe(200);
|
||||
});
|
||||
|
||||
it('ASTE2E-ATLAS-001: a country mark is held with its flag and counter, applied with the exact count, and told to the own tabs', async () => {
|
||||
await setAddonEnabled(orm, 'atlas', true);
|
||||
const t = await travelerWithTrip();
|
||||
provider.script = [callTools({ id: 'call_jp', name: 'mark_country_visited', args: { country_code: 'jp' } })];
|
||||
const turn = await post(`${t.base}/chat`, t.userId, { ...CTX, threadId: null, text: 'I have been to Japan' });
|
||||
const [held] = ofType(turn.chunks, 'tool-approval-request');
|
||||
const [change] = ofType(turn.chunks, 'data-trek-change');
|
||||
expect(change.data).toMatchObject({
|
||||
toolCallId: held.toolCallId,
|
||||
verbKey: 'genericCreate',
|
||||
subject: 'Japan',
|
||||
tool: 'mark_country_visited',
|
||||
domain: 'atlas',
|
||||
});
|
||||
const card = change.data.card;
|
||||
if (card?.kind !== 'atlasCountry' || !card.change?.countries) throw new Error('the held mark has no atlas card');
|
||||
const before = card.change.countries.before;
|
||||
expect(card).toMatchObject({ code: 'JP', status: null, change: { op: 'mark', after: 'visited' } });
|
||||
expect(card.change.countries.after).toBe(before + 1);
|
||||
|
||||
const threadId = ofType(turn.chunks, 'data-trek-thread')[0].data.threadId;
|
||||
provider.script = [say('Japan is on your map.')];
|
||||
broadcasts.broadcastToUser.mockClear();
|
||||
const decided = await post(`${t.base}/threads/${threadId}/decisions`, t.userId, {
|
||||
...CTX,
|
||||
decisions: [{ toolCallId: held.toolCallId, approved: true }],
|
||||
});
|
||||
expect(decided.status).toBe(200);
|
||||
expect(ofType(decided.chunks, 'data-trek-applied')[0].data).toMatchObject({
|
||||
toolCallId: held.toolCallId,
|
||||
entity: 'atlas',
|
||||
ids: [],
|
||||
});
|
||||
const [receipt] = ofType(decided.chunks, 'data-trek-card');
|
||||
expect(receipt.data).toMatchObject({
|
||||
toolCallId: held.toolCallId,
|
||||
card: { kind: 'atlasCountry', change: { after: 'visited', countries: { before, after: before + 1 } } },
|
||||
});
|
||||
expect(broadcasts.broadcastToUser).toHaveBeenCalledWith(t.userId, { type: 'atlas:changed' }, undefined);
|
||||
const shown = await get(`${t.base}/threads/${threadId}`, t.userId);
|
||||
expect(shown.status).toBe(200);
|
||||
});
|
||||
|
||||
it('ASTE2E-COLLAB-001: a poll is held as the card the Collab tab draws, started on approval, with its applied card', async () => {
|
||||
const t = await travelerWithTrip();
|
||||
const question = 'Where do we eat on Friday?';
|
||||
const args = { question, options: ['Trattoria', 'Night market'] };
|
||||
provider.script = [callTools({ id: 'call_poll', name: 'create_collab_poll', args })];
|
||||
const turn = await post(`${t.base}/chat`, t.userId, { ...CTX, threadId: null, text: 'Ask the group' });
|
||||
const [change] = ofType(turn.chunks, 'data-trek-change');
|
||||
expect(change.data).toMatchObject({
|
||||
toolCallId: 'call_poll',
|
||||
verbKey: 'genericCreate',
|
||||
tool: 'create_collab_poll',
|
||||
domain: 'collab',
|
||||
subject: question,
|
||||
undoable: true,
|
||||
});
|
||||
const held = { kind: 'collabPolls', polls: [{ pollId: null, change: 'create', question }] };
|
||||
expect(change.data.card).toMatchObject(held);
|
||||
expect(await countRows(orm, CollabPolls, { trip: t.tripId })).toBe(0);
|
||||
|
||||
const threadId = ofType(turn.chunks, 'data-trek-thread')[0].data.threadId;
|
||||
provider.script = [say('The poll is up.')];
|
||||
const decided = await post(`${t.base}/threads/${threadId}/decisions`, t.userId, {
|
||||
...CTX,
|
||||
decisions: [{ toolCallId: 'call_poll', approved: true }],
|
||||
});
|
||||
expect(decided.status).toBe(200);
|
||||
const poll = await findRow(orm, CollabPolls, { trip: t.tripId });
|
||||
expect(poll?.question).toBe(question);
|
||||
const [applied] = ofType(decided.chunks, 'data-trek-applied');
|
||||
expect(applied.data).toMatchObject({ entity: 'collab_poll', ids: [poll?.id] });
|
||||
expect(ofType(decided.chunks, 'data-trek-card')[0].data).toMatchObject({
|
||||
toolCallId: 'call_poll',
|
||||
card: { kind: 'collabPolls', polls: [{ pollId: poll?.id, change: 'create', question }] },
|
||||
});
|
||||
expect(broadcasts.broadcast).toHaveBeenCalledWith(t.tripId, 'collab:poll:created', expect.anything());
|
||||
});
|
||||
|
||||
it('ASTE2E-COLLAB-002: a chat read shows the last messages as a card beside the answer, kept with it', async () => {
|
||||
const t = await travelerWithTrip();
|
||||
await makeCollabMessage(orm, t.tripId, t.userId, { text: 'Shall we take the early train?' });
|
||||
provider.script = [
|
||||
callTools({ id: 'call_chat', name: 'list_collab_messages', args: {} }),
|
||||
say('Anna asked about the train.'),
|
||||
];
|
||||
const turn = await post(`${t.base}/chat`, t.userId, { ...CTX, threadId: null, text: 'What is new in the chat?' });
|
||||
const [shownCard] = ofType(turn.chunks, 'data-trek-card');
|
||||
expect(shownCard.data).toMatchObject({
|
||||
toolCallId: 'call_chat',
|
||||
card: { kind: 'collabChat', earlier: 0, messages: [{ text: 'Shall we take the early train?', change: 'none' }] },
|
||||
});
|
||||
const threadId = ofType(turn.chunks, 'data-trek-thread')[0].data.threadId;
|
||||
const kept = await get(`${t.base}/threads/${threadId}`, t.userId);
|
||||
const parts = (kept.body.messages as Array<{ parts: AssistantMessagePart[] }>).flatMap((message) => message.parts);
|
||||
expect(parts.filter((part) => part.type === 'data-trek-card')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ASTE2E-BUD-001: an expense list shows its expenses as a card beside the answer, kept with it', async () => {
|
||||
const t = await travelerWithTrip();
|
||||
await makeBudgetItem(orm, t.tripId, { name: 'Ramen', category: 'food', total_price: 24 });
|
||||
provider.script = [
|
||||
callTools({ id: 'call_costs', name: 'list_budget_items', args: {} }),
|
||||
say('One expense so far.'),
|
||||
];
|
||||
const turn = await post(`${t.base}/chat`, t.userId, { ...CTX, threadId: null, text: 'What did we spend?' });
|
||||
const [shownCard] = ofType(turn.chunks, 'data-trek-card');
|
||||
expect(shownCard.data).toMatchObject({
|
||||
toolCallId: 'call_costs',
|
||||
card: {
|
||||
kind: 'expenses',
|
||||
offset: 0,
|
||||
more: 0,
|
||||
rows: [{ name: 'Ramen', category: 'food', amount: { amount: 24 }, change: 'none' }],
|
||||
},
|
||||
});
|
||||
const threadId = ofType(turn.chunks, 'data-trek-thread')[0].data.threadId;
|
||||
const kept = await get(`${t.base}/threads/${threadId}`, t.userId);
|
||||
const parts = (kept.body.messages as Array<{ parts: AssistantMessagePart[] }>).flatMap((message) => message.parts);
|
||||
const keptCards = parts.filter((part) => part.type === 'data-trek-card');
|
||||
expect(keptCards).toHaveLength(1);
|
||||
expect(keptCards[0]).toMatchObject({ data: { toolCallId: 'call_costs', card: { kind: 'expenses' } } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,7 +72,7 @@ beforeAll(async () => {
|
||||
orm = app.get(MikroORM);
|
||||
registry = app.get(McpRegistryService);
|
||||
tools = app.get(AssistantToolsService);
|
||||
await Promise.all(['packing', 'budget'].map((addon) => setAddonEnabled(orm, addon, true)));
|
||||
await Promise.all(['packing', 'budget', 'collections'].map((addon) => setAddonEnabled(orm, addon, true)));
|
||||
const { user } = await makeUser(orm);
|
||||
listed = await listRaw(user.id);
|
||||
});
|
||||
@@ -182,6 +182,37 @@ describe('the assistant tool catalog', () => {
|
||||
expect(journey.map(groupOfTool)).toEqual(journey.map(() => 'journey'));
|
||||
});
|
||||
|
||||
it('ASTTOOL-020: the Studio book tools sit in the journey group but no trip chat is offered them, the undo restore least of all', () => {
|
||||
const bookTools = [
|
||||
'get_book',
|
||||
'describe_book_page',
|
||||
'list_book_layouts',
|
||||
'create_book',
|
||||
'add_book_pages',
|
||||
'move_book_page',
|
||||
'delete_book_pages',
|
||||
'edit_book_page',
|
||||
'set_book_cover',
|
||||
'restore_book_page',
|
||||
];
|
||||
const entries = bookTools.map((name) => registry.list().find((e) => e.kind === 'tool' && e.name === name));
|
||||
expect(entries.map((entry) => entry && [entry.className, groupOf(entry)])).toEqual(
|
||||
bookTools.map(() => ['JourneyBookMcp', 'journey']),
|
||||
);
|
||||
expect(bookTools.filter((name) => isOfferedTool(name))).toEqual([]);
|
||||
expect(ASSISTANT_DENIED_TOOLS.restore_book_page).toMatch(/undo target only/);
|
||||
expect(bookTools.slice(0, -1).map((name) => ASSISTANT_DENIED_TOOLS[name])).toEqual(
|
||||
bookTools.slice(0, -1).map(() => expect.stringMatching(/Studio chat only/)),
|
||||
);
|
||||
});
|
||||
|
||||
it('ASTTOOL-021: the book photo picker reads in the journey group and waits for the Studio chat like the other book tools', () => {
|
||||
const entry = registry.list().find((e) => e.kind === 'tool' && e.name === 'suggest_book_photos');
|
||||
expect(entry && [entry.className, groupOf(entry)]).toEqual(['JourneyBookPhotosMcp', 'journey']);
|
||||
expect(isOfferedTool('suggest_book_photos')).toBe(false);
|
||||
expect(ASSISTANT_DENIED_TOOLS.suggest_book_photos).toMatch(/Studio chat only/);
|
||||
});
|
||||
|
||||
it('ASTTOOL-017: the hike tools come with the tours, the proposal runs at once and the writes are cards of a tour', () => {
|
||||
const hikeTools = ['propose_hike', 'create_hike', 'update_tour', 'import_tour_gpx'];
|
||||
const entries = hikeTools.map((name) => registry.list().find((e) => e.kind === 'tool' && e.name === name));
|
||||
|
||||
@@ -62,7 +62,7 @@ describe('mergeDomains', () => {
|
||||
expect(ASSISTANT_UNDO_MAP.create_city_walk?.tool).toBe('delete_place');
|
||||
expect(ASSISTANT_UNDO_MAP.create_city_walk?.args({}, { tour: { place_id: 9 } }, null)).toEqual({ placeId: 9 });
|
||||
expect(TOOL_CARDS.update_tour?.undo).toBeUndefined();
|
||||
expect(CARD_PROMPT_LINES).toEqual([]);
|
||||
expect(CARD_PROMPT_LINES).toEqual(DOMAINS.flatMap((d) => d.prompt ?? []));
|
||||
});
|
||||
|
||||
it('ASTREG-002: two packages may fill different fields of one tool, never the same one', () => {
|
||||
|
||||
@@ -221,23 +221,23 @@ describe('describeToolCall', () => {
|
||||
const gone = describeToolCall('delete_day_note', 'tc-8', { dayId: 31, noteId: 41 }, hinted(true, false, null));
|
||||
expect(gone).toMatchObject({ verbKey: 'genericRemove', subject: 'Rail pass', dayId: 31 });
|
||||
expect(gone.rows).toEqual([{ op: 'add', labelKey: 'day', before: null, after: 'Arrival' }]);
|
||||
const vacay = describeToolCall(
|
||||
'toggle_vacay_entry',
|
||||
'tc-9',
|
||||
{ date: '2026-07-01', half: true },
|
||||
hinted(false, false, 'vacay'),
|
||||
);
|
||||
expect(vacay).toMatchObject({ subject: 'toggle vacay entry', domain: 'vacay', undoable: false });
|
||||
expect(vacay.rows).toEqual([
|
||||
{ op: 'add', labelKey: 'day', before: null, after: '2026-07-01' },
|
||||
{ op: 'add', labelKey: 'field', field: 'half', before: null, after: '✓' },
|
||||
]);
|
||||
// A tool of an addon without a card of its own: named by its words, its arguments as rows, its part of TREK.
|
||||
const year = describeToolCall('add_vacay_year', 'tc-9', { year: 2027 }, hinted(false, true, 'vacay'));
|
||||
expect(year).toMatchObject({ verbKey: 'genericCreate', domain: 'vacay', undoable: false });
|
||||
// A vacation day has a card of its own, even before its lookup ran: the day, in the vacation planner.
|
||||
const vacay = describeToolCall('toggle_vacay_entry', 'tc-10', { date: '2026-07-01', half: true }, ctx);
|
||||
expect(vacay).toMatchObject({ verbKey: 'addVacationDay', subject: 'Wed 1 Jul', domain: 'vacay', undoable: false });
|
||||
expect(vacay.rows).toEqual([]);
|
||||
// Without hints the name still tells a removal from an addition.
|
||||
expect(describe_('create_collab_poll', { question: 'Where?' }).verbKey).toBe('genericCreate');
|
||||
expect(describe_('unmark_country_visited', {}).verbKey).toBe('genericRemove');
|
||||
expect(appliedChange('toggle_vacay_entry', 'tc-9', {}, { ok: true }, null, null)).toMatchObject({
|
||||
expect(appliedChange('add_vacay_year', 'tc-9', {}, { years: [2026, 2027] }, null, null)).toMatchObject({
|
||||
entity: 'other',
|
||||
});
|
||||
expect(appliedChange('toggle_vacay_entry', 'tc-10', {}, { action: 'added' }, null, null)).toMatchObject({
|
||||
entity: 'vacay',
|
||||
ids: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('ASTPRV-018: a change to the day plan says what it does to which day; anything else has no effect', () => {
|
||||
@@ -483,7 +483,8 @@ describe('the cards of tours', () => {
|
||||
verbKey: 'importTour',
|
||||
subject: 'ridge.gpx',
|
||||
rows: [{ op: 'add', labelKey: 'type', before: null, after: 'bike' }],
|
||||
undoable: false,
|
||||
// A file of one track can be taken back once it is in; the receipt says whether this one was.
|
||||
undoable: true,
|
||||
});
|
||||
expect(describe_('import_tour_gpx', { gpx }).subject).toBe('Ridge walk');
|
||||
expect(describe_('import_tour_gpx', { gpx: '<gpx><name>Open' }).subject).toBe('GPX');
|
||||
@@ -596,25 +597,22 @@ describe('the cards of tours', () => {
|
||||
});
|
||||
|
||||
it('ASTPRV-031: a package names its ids first; a place id of a tool outside the trip is no trip place', () => {
|
||||
const groups = FOREIGN_ID_GROUPS as Set<string>;
|
||||
const saved = { ...ctx, hintOf: () => ({ destructive: false, creates: true, group: 'collections' }) };
|
||||
const add = () => describeToolCall('add_collection_place', 'tc-31', { placeId: 12 }, saved);
|
||||
// Place 12 is the trip's Nishiki Market until the collections say their ids are their own.
|
||||
expect(add()).toMatchObject({ subject: 'Nishiki Market', placeIds: [12], domain: 'collections' });
|
||||
groups.add('collections');
|
||||
try {
|
||||
const outside = add();
|
||||
expect(outside).toMatchObject({ subject: 'add collection place', placeIds: [] });
|
||||
expect(outside.rows).toEqual([{ op: 'add', labelKey: 'field', field: 'placeId', before: null, after: '12' }]);
|
||||
const named = withCards(
|
||||
'add_collection_place',
|
||||
{ nameArg: (key, value) => (key === 'placeId' ? `Saved place ${value}` : null) },
|
||||
null,
|
||||
add,
|
||||
);
|
||||
expect(named).toMatchObject({ subject: 'Saved place 12', rows: [], placeIds: [] });
|
||||
} finally {
|
||||
groups.delete('collections');
|
||||
}
|
||||
const hinted = (group: string) => ({ ...ctx, hintOf: () => ({ destructive: false, creates: true, group }) });
|
||||
const add = (group: string) => describeToolCall('add_collection_place', 'tc-31', { placeId: 12 }, hinted(group));
|
||||
// Place 12 is the trip's Nishiki Market for a tool of a group whose ids are the trip's.
|
||||
expect(FOREIGN_ID_GROUPS.has('tours')).toBe(false);
|
||||
expect(add('tours')).toMatchObject({ subject: 'Nishiki Market', placeIds: [12], domain: 'tours' });
|
||||
// The collections say their place ids are their own.
|
||||
expect(FOREIGN_ID_GROUPS.has('collections')).toBe(true);
|
||||
const outside = add('collections');
|
||||
expect(outside).toMatchObject({ subject: 'add collection place', placeIds: [] });
|
||||
expect(outside.rows).toEqual([{ op: 'add', labelKey: 'field', field: 'placeId', before: null, after: '12' }]);
|
||||
const named = withCards(
|
||||
'add_collection_place',
|
||||
{ nameArg: (key, value) => (key === 'placeId' ? `Saved place ${value}` : null) },
|
||||
null,
|
||||
() => add('collections'),
|
||||
);
|
||||
expect(named).toMatchObject({ subject: 'Saved place 12', rows: [], placeIds: [] });
|
||||
});
|
||||
});
|
||||
|
||||
+26
-8
@@ -12,6 +12,7 @@ Requires `packing:read` or `packing:write` scope. The Packing addon is listed as
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `list_packing_items` | List the packing items the user may see (another member's personal items stay hidden), with the trip's bags and who looks after each category. Narrow it with `category`, `bag_id` (`null` for the items in no bag) or `status` (`open`, `packed`); `group_by` (`category` or `bag`) only says how the list is meant to be shown. |
|
||||
| `create_packing_item` | Add an item to the packing checklist with optional category. |
|
||||
| `update_packing_item` | Rename an item, change its category, or count how many of it are already packed. |
|
||||
| `set_packing_item_sharing` | Move an item between the three sharing tiers: `common` puts it in the pool the whole trip packs from, `personal` keeps it to its owner, `shared` covers the trip members in `recipient_ids`. Only the item's owner may change this. |
|
||||
@@ -52,15 +53,16 @@ Requires `atlas:read` or `atlas:write` scope.
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `mark_country_visited` | Mark a country as visited using its ISO 3166-1 alpha-2 code (e.g. `"FR"`, `"JP"`). |
|
||||
| `unmark_country_visited` | Remove a country from your visited list. |
|
||||
| `mark_country_visited` | Mark a country as visited using its ISO 3166-1 alpha-2 code (e.g. `"FR"`, `"JP"`). The answer's `after` says where the country stands now and how many countries count as visited. |
|
||||
| `unmark_country_visited` | Remove a country from your visited list. The answer's `after` says where the country stands now (a country your trips put on the map stays) and how many countries count as visited. |
|
||||
| `get_atlas_stats` | Get atlas statistics — visited country counts, region counts, and continent breakdown. |
|
||||
| `list_visited_regions` | List all manually visited sub-country regions for the current user. |
|
||||
| `locate_atlas_region` | Resolve a coordinate to the country and region codes the Atlas map can highlight, which is where the pair `mark_region_visited` expects comes from. A point outside every bundled polygon answers with `null` fields rather than an error. For a postal address use `reverse_geocode` instead. |
|
||||
| `mark_region_visited` | Mark a sub-country region as visited (e.g. `"US-CA"`). |
|
||||
| `unmark_region_visited` | Remove a region from the visited list. Removing the last region of a country also removes the country, unless it was marked visited itself. |
|
||||
| `mark_region_visited` | Mark a sub-country region as visited (e.g. `"US-CA"`). The answer's `after` also counts the visited regions of that country. On all four mark tools `after` is left out when the atlas cannot be read back; the change itself stands. |
|
||||
| `unmark_region_visited` | Remove a region from the visited list. Removing the last region of a country also removes the country, unless it was marked visited itself. The answer's `after` says where that country stands now, how many countries count as visited and how many of its regions are left. |
|
||||
| `get_country_atlas_places` | Get places saved in the user's atlas for a specific country. |
|
||||
| `create_bucket_list_item` | Add a destination to your personal bucket list with optional coordinates, country code, target date and `region_code` (a state or province, hatched on the Atlas map). The same destination for the same target date is rejected as a duplicate. |
|
||||
| `list_bucket_list` | List your bucket list: every wish with its id, name, country and region, target date, notes and when it was reached. The ids are what `update_bucket_list_item` and `delete_bucket_list_item` take. |
|
||||
| `update_bucket_list_item` | Update a bucket list item (name, notes, coordinates, target date). |
|
||||
| `delete_bucket_list_item` | Remove an item from your bucket list. |
|
||||
|
||||
@@ -72,12 +74,13 @@ The addon alone is not enough. Collab has five sub-features an admin switches on
|
||||
|
||||
| Sub-feature | Tools it gates | Resource it gates |
|
||||
|---|---|---|
|
||||
| Notes | `create_collab_note`, `update_collab_note`, `delete_collab_note` | `trek://trips/{tripId}/collab-notes` |
|
||||
| Notes | `list_collab_notes`, `create_collab_note`, `update_collab_note`, `delete_collab_note` | `trek://trips/{tripId}/collab-notes` |
|
||||
| Polls | `list_collab_polls`, `create_collab_poll`, `vote_collab_poll`, `close_collab_poll`, `delete_collab_poll` | `trek://trips/{tripId}/collab/polls` |
|
||||
| Chat | `list_collab_messages`, `send_collab_message`, `delete_collab_message`, `react_collab_message` | `trek://trips/{tripId}/collab/messages` |
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `list_collab_notes` | List the shared notes of a trip, pinned first, with their category, colour, link and files. |
|
||||
| `create_collab_note` | Create a shared note visible to all trip members. Supports title, content, category, and color. |
|
||||
| `update_collab_note` | Edit a collab note's content, category, color, or pin status. |
|
||||
| `delete_collab_note` | Delete a collab note. |
|
||||
@@ -101,6 +104,7 @@ Requires `collections:read` or `collections:write` scope.
|
||||
| `get_collection` | Get one collection with its members, labels, and all saved places, including the average rating and each member's vote. |
|
||||
| `available_collection_users` | List users who can still be invited to a collection (excludes current members and guests). |
|
||||
| `find_place_in_collections` | Answer whether a place is already on one of your lists, across the whole library in one call, naming each list and the status the place has there. Identify the place by `google_place_id` / `google_ftid` from `search_place` or by `lat` + `lng`; a name alone is never matched. |
|
||||
| `get_collection_items` | Read saved places and labels by their own ids across every list you can see, without loading whole lists: each place with its status, labels, rating and notes, and the lists they sit in with their colour, size, labels, members (names and roles only) and whether you may delete places from them. Saved place ids are not trip place ids. Ids you cannot see are left out. |
|
||||
| `create_collection` | Create a new saved-place collection owned by the user. |
|
||||
| `update_collection` | Update a collection's name, description, colour, icon, cover, links, or sort order. Owner/admin only. |
|
||||
| `delete_collection` | Permanently delete a collection and all its saved places. Owner only, and it cannot be undone. |
|
||||
@@ -192,7 +196,8 @@ Requires `journey:read` or `journey:write` scope.
|
||||
| `delete_journey` | Delete a journey. |
|
||||
| `add_journey_trip` | Link an existing trip to a journey. |
|
||||
| `remove_journey_trip` | Remove a trip from a journey. |
|
||||
| `list_journey_entries` | List all entries in a journey (date, text, mood, linked trip). |
|
||||
| `list_journey_entries` | List the entries of a journey in reading order (date, text, mood, linked trip), with the journey's id and title. `from` and `to` (`YYYY-MM-DD`, both included) keep only the entries of those days. |
|
||||
| `get_journey_entry` | One journal entry by its id, as `list_journey_entries` answers it (photos included), with the id and title of its journey. |
|
||||
| `create_journey_entry` | Add an entry with date (required), optional title, story text, time of day, location name, mood, and sort order. `is_draft: true` creates it as a draft that the journey share link leaves out. |
|
||||
| `update_journey_entry` | Edit a journey entry's title, story, date, time of day, place, coordinates, weather, tags, mood, pros/cons list or visibility. `stats_excluded: true` keeps the entry but takes it off the route and out of `get_journey_stats`; `dismissed: true` waves a trip-derived suggestion away without deleting it, so the trip sync does not offer it again. `is_draft` turns the entry into a draft or publishes it. |
|
||||
| `delete_journey_entry` | Remove an entry from a journey. |
|
||||
@@ -211,6 +216,19 @@ Requires `journey:read` or `journey:write` scope.
|
||||
| `delete_journey_share_link` | Revoke the public share link for a journey. Requires `journey:share`. |
|
||||
| `list_journey_recorded_days` | The days of recorded route a journey keeps: zone, distance, pieces, gaps, modes, whether the day was still running, and whose recording it is, without the geometry. Readable without a Dawarich connection. |
|
||||
| `delete_journey_recorded_day` | Drop the recorded route a journey keeps for one day. Entries and texts stay, and Dawarich keeps its own recording. Needs edit access. |
|
||||
| `get_book` | The journey's photo book from the Studio, without its document: version, title, page format and every page with its id, role, photos and first words. Page and element ids for the tools below come from here. Any member may read it. |
|
||||
| `describe_book_page` | One page of the book in words: every element with its id, frame in millimetres and photo or text, then the findings (overlaps, content in the fold or past the trim, empty frames, low print resolution, words past their frame). |
|
||||
| `list_book_layouts` | The layouts a page can take: templates for double spreads and single pages, photo grids, and the book's own saved layouts, with what each does to what is on the page. |
|
||||
| `create_book` | Lay down a blank book (cover, first page, one double spread, last page, back) for a journey that has none, in a page format of your choice. Needs edit access. |
|
||||
| `add_book_pages` | Add up to 10 empty double spreads, always between the first and the last page, optionally on a template or tied to an entry. Needs edit access. |
|
||||
| `move_book_page` | Move one double spread to another place between the first and the last page; the answer says where it stood. A move to where the page already is changes nothing. Needs edit access. |
|
||||
| `delete_book_pages` | Delete up to 10 double spreads. The cover, first, last and back pages stay. Needs edit access. |
|
||||
| `edit_book_page` | Change one page with up to 20 operations, all or nothing: a layout, a gallery photo into a frame, its crop and look, swapping photos, texts and captions, moving and resizing, removing, the page colour. Locked elements are left alone, and photos come only from the journey's gallery. Needs edit access. |
|
||||
| `set_book_cover` | The cover's layout, photo, title and subtitle. Needs edit access. |
|
||||
| `suggest_book_photos` | Photos of the journey's gallery for a page or the cover, grouped by day and place, best first: near duplicates (bursts and near-identical pictures) are folded onto the sharpest, and possibly blurry or low-resolution photos are named rather than picked. Narrow it to one entry or day. Sharpness and likeness are measured once per photo in the background; it cannot judge faces or what a photo shows. Any member may use it. |
|
||||
| `restore_book_page` | Put the pages an earlier change answered under `previous_spreads` back as they were, all in one call, only while the book is still at the version that change answered. Photos must be in the gallery or the book, and locked elements stay as they are. Meant for undo. Needs edit access. |
|
||||
|
||||
Every book write is one save of the whole book against its version: nothing is written when any part is refused, and every member's open Studio is told the book changed, the asking one included.
|
||||
|
||||
### Dawarich _(Dawarich addon required)_
|
||||
|
||||
@@ -297,7 +315,7 @@ These tools work without an open browser. The external assistant chooses places
|
||||
| `calculate_roadtrip` | Calculated days, arrivals, departures, automatic pauses, driving warnings and range warnings; optional geometry. A booking's terminals and a hire car's desks come back as stops carrying `carrier` (its role, type and timetable); their `assignmentId` and `placeId` are synthetic negative numbers that belong to no assignment and no place, so never pass them to the assignment or place tools. With `roadtrip_hotel_bookends` on, a day after a booked night starts at that stay and a day before one ends there: those stops carry `bookend` (phase, accommodation, reservation, check-in and check-out flags, the check-out time as a label), the stay's real `placeId` and a synthetic negative `assignmentId`, and cannot be reordered, edited or given via points | `trips:read` |
|
||||
| `get_roadtrip_settings` | Shared driving preferences for the specified trip; a missing `roadtrip_hotel_bookends` means off | `trips:read` |
|
||||
| `update_roadtrip_settings` | Patch shared trip driving preferences, preserving other settings | `trips:write` |
|
||||
| `search_roadtrip_corridor` | Fuel, charging, rest areas, campsites, food, sights or hotels along a day | `trips:read` |
|
||||
| `search_roadtrip_corridor` | Fuel, charging, rest areas, campsites, food, sights or hotels along a day, each hit with the two stops of the day it lies between (`between`) | `trips:read` |
|
||||
| `update_route_via` | Move an existing routing handle and optionally change its outgoing leg | `trips:write` |
|
||||
| `list_route_vias` | A day's via points, or the whole trip's together with its followed tracks | `trips:read` |
|
||||
| `add_route_via`, `add_route_vias` | Add one via point, or a whole chain on one day. With `roadtrip_hotel_bookends` on, a via after the last stop of a day that ends at a booked night is kept but not used, because the drive to the hotel keeps its own road | `trips:write` |
|
||||
@@ -321,7 +339,7 @@ The via tools manage scenic detours and followed tracks. `list_day_boundaries`,
|
||||
|
||||
`get_roadtrip_hazards` takes `tripId`. It does not reroute the trip. The shared `roadtrip_show_hazards` setting controls the online map overlay.
|
||||
|
||||
Google Maps directions links can be reviewed with `preview_google_maps_route` and saved with `import_google_maps_route`. Import creates places and visits together in the supplied order, with trip access and place/day permission checks. Unresolved stops must be omitted from the confirmed input. The Google road geometry is not copied.
|
||||
Google Maps directions links can be reviewed with `preview_google_maps_route` and saved with `import_google_maps_route`. Import creates places and visits together in the supplied order, with trip access and place/day permission checks, and answers with the ids of the places and visits it created (`place_ids`, `assignment_ids`). Unresolved stops must be omitted from the confirmed input. The Google road geometry is not copied.
|
||||
|
||||
`get_roadtrip_charging_info` accepts tripId and placeId. It requires trip access and the Road trip addon. It reports matching failures, unavailable feeds, freshness, known free capacity and unknown status counts separately. Published tariff components include currency, tax handling and conditions; they do not estimate a user's roaming price. Data source: [MobiData BW OCPDB](https://api.mobidata-bw.de/), with per-source attribution from its public source registry.
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ Compound tools collapse multi-step workflows into a single atomic transaction. I
|
||||
|---|---|---|
|
||||
| `create_and_assign_place` | `create_place` + `assign_place_to_day` | Create a place and assign it to a day. Takes the same place fields as `create_place`, `stop_type` included. Returns `{ place, assignment }`. Requires `places:write`. |
|
||||
| `create_place_accommodation` | `create_place` + `create_accommodation` | Create a place and book it as an accommodation. Returns `{ place, accommodation }`. Requires `trips:write`. |
|
||||
| `create_budget_item_with_members` | `create_budget_item` + `set_budget_item_members` | Create a budget item and set splitting members. If `userIds` is omitted, behaves like `create_budget_item`. Returns `{ item }`. Requires `budget:write`. |
|
||||
| `create_budget_item_with_members` | `create_budget_item` + `set_budget_item_members` | Create a budget item and set splitting members. If `userIds` is omitted or empty, the cost is split across all trip members, like `create_budget_item` without `member_ids`. Returns `{ item }`. Requires `budget:write`. |
|
||||
|
||||
### Trips
|
||||
|
||||
@@ -114,6 +114,7 @@ Requires `trips:read` or `trips:write` scope.
|
||||
| `create_accommodation` | Add an accommodation (hotel, Airbnb, etc.) linked to a place and a check-in/check-out date range. |
|
||||
| `update_accommodation` | Update fields on an existing accommodation including dates, times, confirmation, and notes. |
|
||||
| `delete_accommodation` | Delete an accommodation record from a trip. |
|
||||
| `list_accommodations` | Where the trip sleeps, stay by stay: the place with its address and position, the check-in and check-out days and times, and the booking linked to it. Requires `trips:read`. |
|
||||
|
||||
### Transport
|
||||
|
||||
@@ -147,6 +148,7 @@ Requires `reservations:read` or `reservations:write` scope.
|
||||
| `reorder_reservations` | Reorder reservations within a day. |
|
||||
| `set_reservation_travelers` | Set who is travelling on a booking, from the trip roster (members and guests). Replaces the list; an empty array clears it. Ids that are not on the trip come back under `ignored_user_ids` rather than being attached. |
|
||||
| `link_hotel_accommodation` | Set or update a hotel reservation's check-in/out day links and place. |
|
||||
| `list_reservations` | The bookings of one trip with their readable details (`details`: airline, flight or train number, platform, seat, the legs of a stopover booking, check-in and check-out times, price, and `has_leg_reference` when a leg holds a booking reference of its own, never the reference itself) in place of the raw metadata. `kinds` keeps `transport` or `booking`, `dayId` the bookings on that day. Requires `reservations:read`. |
|
||||
| `list_upcoming_reservations` | The next bookings across all of the user's trips, soonest first, for "what is coming up?" when no particular trip is in question. Hotel stays appear as their check-in and check-out moments. Cancelled bookings, planned public-transit journeys and archived trips are left out. `limit` defaults to 6, at most 50. Requires `reservations:read`. |
|
||||
|
||||
### Budget
|
||||
@@ -155,7 +157,7 @@ Requires `budget:read` or `budget:write` scope. The Budget addon must be enabled
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `list_budget_items` | The trip's expenses with their ids, a page at a time (`offset`, `limit` up to 100, 50 by default). A page of long expenses ends earlier, so a client goes on at `next_offset` until it is `null`. Per expense: name, category, price and currency, date, the start of its note, the linked place or booking, who paid how much and who shares it. Next to the page the trip currency, the total and the totals per category over all expenses, and `unconverted_item_ids` for foreign expenses no rate converts. |
|
||||
| `list_budget_items` | The trip's expenses with their ids, a page at a time (`offset`, `limit` up to 100, 50 by default). A page of long expenses ends earlier, so a client goes on at `next_offset` until it is `null`. Per expense: name, category, price and currency, date, the start of its note, the linked place or booking, who paid how much and who shares it, what a foreign expense comes to in the trip currency (`trip_amount`) and how many receipts it has (`receipt_count`). Next to the page the trip currency, the total and the totals per category over all expenses, and `unconverted_item_ids` for foreign expenses no rate converts. |
|
||||
| `get_budget_per_person` | What each person's shares of the expenses add up to and how much of that they paid, in the trip currency, as the per-person summary in the app shows it. |
|
||||
| `create_budget_item` | Add an expense with name, category, and price. |
|
||||
| `update_budget_item` | Update an expense's details, split (persons/days), or notes. `reservation_id` / `place_id` link it to a booking or place of the same trip; `null` unlinks it. |
|
||||
|
||||
Reference in New Issue
Block a user