mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
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.
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
data: {"type":"start","messageId":"msg-12"}
|
||||
|
||||
data: {"type":"data-trek-thread","data":{"threadId":7,"title":"Fit the Golden Pavilion into Friday"}}
|
||||
|
||||
data: {"type":"start-step"}
|
||||
|
||||
data: {"type":"tool-input-start","toolCallId":"toolu_01","toolName":"get_trip_summary"}
|
||||
|
||||
data: {"type":"tool-input-available","toolCallId":"toolu_01","toolName":"get_trip_summary","input":{}}
|
||||
|
||||
data: {"type":"tool-output-available","toolCallId":"toolu_01","output":{"title":"Kyoto in June","days":[{"id":31,"date":"2026-06-11","places":3},{"id":32,"date":"2026-06-12","places":1}]}}
|
||||
|
||||
data: {"type":"finish-step"}
|
||||
|
||||
data: {"type":"start-step"}
|
||||
|
||||
data: {"type":"text-start","id":"txt-0"}
|
||||
|
||||
data: {"type":"text-delta","id":"txt-0","delta":"Friday has room in the afternoon. "}
|
||||
|
||||
data: {"type":"text-delta","id":"txt-0","delta":"I would add Kinkaku-ji there."}
|
||||
|
||||
data: {"type":"text-end","id":"txt-0"}
|
||||
|
||||
data: {"type":"tool-input-start","toolCallId":"3f2b8c1e-6d4a-4f7b-9e2c-5a1d8b7c6e40","toolName":"create_and_assign_place"}
|
||||
|
||||
data: {"type":"tool-input-available","toolCallId":"3f2b8c1e-6d4a-4f7b-9e2c-5a1d8b7c6e40","toolName":"create_and_assign_place","input":{"dayId":32,"name":"Kinkaku-ji","address":"1 Kinkakujicho, Kita Ward, Kyoto","lat":35.0394,"lng":135.7292}}
|
||||
|
||||
data: {"type":"tool-approval-request","approvalId":"3f2b8c1e-6d4a-4f7b-9e2c-5a1d8b7c6e40","toolCallId":"3f2b8c1e-6d4a-4f7b-9e2c-5a1d8b7c6e40"}
|
||||
|
||||
data: {"type":"data-trek-change","data":{"toolCallId":"3f2b8c1e-6d4a-4f7b-9e2c-5a1d8b7c6e40","verbKey":"addPlaceToDay","subject":"Kinkaku-ji","rows":[{"op":"add","labelKey":"day","before":null,"after":"Day 2, Fri 12 Jun"},{"op":"add","labelKey":"address","before":null,"after":"1 Kinkakujicho, Kita Ward, Kyoto"}],"dayId":32,"placeIds":[],"undoable":true}}
|
||||
|
||||
data: {"type":"data-trek-quota","data":{"used":3,"limit":50}}
|
||||
|
||||
data: {"type":"finish","finishReason":"tool-calls"}
|
||||
|
||||
data: [DONE]
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { assistantShareRequestSchema } from './assistant-share.schema';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('assistantShareRequestSchema', () => {
|
||||
it('takes one to 5000 characters and trims them', () => {
|
||||
expect(assistantShareRequestSchema.parse({ text: ' Day 2 is set. ' })).toEqual({ text: 'Day 2 is set.' });
|
||||
expect(assistantShareRequestSchema.safeParse({ text: 'x' }).success).toBe(true);
|
||||
expect(assistantShareRequestSchema.safeParse({ text: 'x'.repeat(5000) }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses empty or blank text and text past the chat limit', () => {
|
||||
expect(assistantShareRequestSchema.safeParse({ text: '' }).success).toBe(false);
|
||||
expect(assistantShareRequestSchema.safeParse({ text: ' \n\t ' }).success).toBe(false);
|
||||
expect(assistantShareRequestSchema.safeParse({ text: 'x'.repeat(5001) }).success).toBe(false);
|
||||
expect(assistantShareRequestSchema.safeParse({}).success).toBe(false);
|
||||
});
|
||||
|
||||
it('takes nothing but the text, so the origin is never the client to set', () => {
|
||||
expect(assistantShareRequestSchema.safeParse({ text: 'hi', origin: 'assistant' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* POST /api/trips/:tripId/assistant/threads/:threadId/messages/:messageId/share:
|
||||
* posts the text of an assistant answer to the trip's chat, marked as shared
|
||||
* from the assistant. The text is what the user saw in the preview; the
|
||||
* server checks that the answer is theirs and in this trip, not the words.
|
||||
* It answers with the chat message (`collabMessageResponseSchema`).
|
||||
*/
|
||||
export const assistantShareRequestSchema = z
|
||||
.object({
|
||||
text: z.string().trim().min(1).max(5000),
|
||||
})
|
||||
.strict();
|
||||
export type AssistantShareRequest = z.infer<typeof assistantShareRequestSchema>;
|
||||
@@ -0,0 +1,430 @@
|
||||
/// <reference types="node" />
|
||||
import {
|
||||
ASSISTANT_CHANGE_LABEL_KEYS,
|
||||
assistantAddFoundPlaceRequestSchema,
|
||||
assistantAddFoundPlaceResponseSchema,
|
||||
assistantAttachmentRequestSchema,
|
||||
assistantChangePreviewSchema,
|
||||
assistantChatRequestSchema,
|
||||
assistantDecisionsRequestSchema,
|
||||
assistantFoundPlaceSchema,
|
||||
assistantMessageSchema,
|
||||
assistantPlacesSchema,
|
||||
assistantStopRequestSchema,
|
||||
assistantWeatherSchema,
|
||||
UI_STREAM_DONE,
|
||||
uiStreamChunkSchema,
|
||||
type UiStreamChunk,
|
||||
} from './assistant.schema';
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const ctx = { locale: 'de', timeZone: 'Europe/Berlin' };
|
||||
|
||||
/** The `data:` payloads of an SSE body, in order. */
|
||||
const dataEvents = (body: string) =>
|
||||
body
|
||||
.split('\n\n')
|
||||
.filter((event) => event.startsWith('data: '))
|
||||
.map((event) => event.slice('data: '.length));
|
||||
|
||||
const fixture = readFileSync(fileURLToPath(new URL('./__fixtures__/ui-stream.v1.txt', import.meta.url)), 'utf8');
|
||||
|
||||
describe('assistantChatRequestSchema', () => {
|
||||
it('takes a first message without a thread and trims the text', () => {
|
||||
const parsed = assistantChatRequestSchema.parse({ ...ctx, threadId: null, text: ' Plan day 2 ' });
|
||||
expect(parsed.text).toBe('Plan day 2');
|
||||
});
|
||||
|
||||
it('refuses blank or overlong text, a bad thread id and unknown keys', () => {
|
||||
expect(assistantChatRequestSchema.safeParse({ ...ctx, threadId: null, text: ' ' }).success).toBe(false);
|
||||
expect(assistantChatRequestSchema.safeParse({ ...ctx, threadId: null, text: 'x'.repeat(4001) }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(assistantChatRequestSchema.safeParse({ ...ctx, threadId: 0, text: 'hi' }).success).toBe(false);
|
||||
expect(assistantChatRequestSchema.safeParse({ ...ctx, threadId: null, text: 'hi', tripId: 9 }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('carries a focus of at most 20 places', () => {
|
||||
const many = Array.from({ length: 21 }, (_, i) => i + 1);
|
||||
expect(
|
||||
assistantChatRequestSchema.safeParse({ ...ctx, threadId: 3, text: 'hi', focus: { dayId: 4, placeIds: [1] } })
|
||||
.success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
assistantChatRequestSchema.safeParse({ ...ctx, threadId: 3, text: 'hi', focus: { placeIds: many } }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('needs the locale and time zone', () => {
|
||||
expect(assistantChatRequestSchema.safeParse({ threadId: null, text: 'hi' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assistantAttachmentRequestSchema', () => {
|
||||
it('reads the multipart text fields, the thread id from its text', () => {
|
||||
expect(assistantAttachmentRequestSchema.parse({ ...ctx })).toEqual(ctx);
|
||||
expect(assistantAttachmentRequestSchema.parse({ ...ctx, threadId: '12' })).toEqual({ ...ctx, threadId: 12 });
|
||||
});
|
||||
|
||||
it('refuses a bad thread id, unknown fields and a missing context', () => {
|
||||
for (const threadId of ['0', '-1', 'abc', '1.5']) {
|
||||
expect(assistantAttachmentRequestSchema.safeParse({ ...ctx, threadId }).success, threadId).toBe(false);
|
||||
}
|
||||
expect(assistantAttachmentRequestSchema.safeParse({ ...ctx, text: 'hi' }).success).toBe(false);
|
||||
expect(assistantAttachmentRequestSchema.safeParse({ threadId: '3' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assistantDecisionsRequestSchema', () => {
|
||||
it('takes one to twelve decisions', () => {
|
||||
const decision = { toolCallId: 'a', approved: true };
|
||||
expect(assistantDecisionsRequestSchema.safeParse({ ...ctx, decisions: [decision] }).success).toBe(true);
|
||||
expect(assistantDecisionsRequestSchema.safeParse({ ...ctx, decisions: [] }).success).toBe(false);
|
||||
expect(
|
||||
assistantDecisionsRequestSchema.safeParse({ ...ctx, decisions: Array.from({ length: 13 }, () => decision) })
|
||||
.success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('never takes arguments for a call, only the decision', () => {
|
||||
expect(
|
||||
assistantDecisionsRequestSchema.safeParse({
|
||||
...ctx,
|
||||
decisions: [{ toolCallId: 'a', approved: true, args: { name: 'x' } }],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assistantStopRequestSchema', () => {
|
||||
it('is an empty JSON object', () => {
|
||||
expect(assistantStopRequestSchema.safeParse({}).success).toBe(true);
|
||||
expect(assistantStopRequestSchema.safeParse({ force: true }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assistantChangePreviewSchema', () => {
|
||||
const preview = {
|
||||
toolCallId: 'c1',
|
||||
verbKey: 'addPlace',
|
||||
subject: 'Kinkaku-ji',
|
||||
rows: [{ op: 'add', labelKey: 'address', before: null, after: 'Kyoto' }],
|
||||
dayId: null,
|
||||
placeIds: [],
|
||||
undoable: true,
|
||||
};
|
||||
|
||||
it('labels rows from the closed key list only', () => {
|
||||
expect(assistantChangePreviewSchema.safeParse(preview).success).toBe(true);
|
||||
expect(
|
||||
assistantChangePreviewSchema.safeParse({ ...preview, rows: [{ ...preview.rows[0], labelKey: 'evil' }] }).success,
|
||||
).toBe(false);
|
||||
expect(assistantChangePreviewSchema.safeParse({ ...preview, verbKey: 'deleteTrip' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('holds at most twelve rows', () => {
|
||||
const rows = ASSISTANT_CHANGE_LABEL_KEYS.slice(0, 13).map((labelKey) => ({
|
||||
op: 'add',
|
||||
labelKey,
|
||||
before: null,
|
||||
after: 'x',
|
||||
}));
|
||||
expect(assistantChangePreviewSchema.safeParse({ ...preview, rows }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assistantMessageSchema', () => {
|
||||
it('reads a stored message with an expired approval', () => {
|
||||
const parsed = assistantMessageSchema.safeParse({
|
||||
id: '12',
|
||||
role: 'assistant',
|
||||
status: 'awaiting_approval',
|
||||
createdAt: '2026-10-21T08:00:00.000Z',
|
||||
parts: [
|
||||
{ type: 'text', text: 'Adding it.' },
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'c1',
|
||||
toolName: 'create_place',
|
||||
args: { name: 'Kinkaku-ji' },
|
||||
approval: { id: 'c1', resolution: 'expired' },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it('reads a user message that attached a file, by its name, type and size only', () => {
|
||||
const message = { id: '13', role: 'user', status: 'complete', createdAt: '2026-10-21T08:00:00.000Z' };
|
||||
const attachment = { type: 'attachment', name: 'ticket.pdf', mime: 'application/pdf', size: 2048 };
|
||||
expect(assistantMessageSchema.safeParse({ ...message, parts: [attachment] }).success).toBe(true);
|
||||
const negative = { ...attachment, size: -1 };
|
||||
expect(assistantMessageSchema.safeParse({ ...message, parts: [negative] }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uiStreamChunkSchema', () => {
|
||||
it('reads an unknown error code as generic', () => {
|
||||
expect(uiStreamChunkSchema.parse({ type: 'error', errorText: 'something_new' })).toEqual({
|
||||
type: 'error',
|
||||
errorText: 'generic',
|
||||
});
|
||||
expect(uiStreamChunkSchema.parse({ type: 'error', errorText: 'daily_limit' })).toEqual({
|
||||
type: 'error',
|
||||
errorText: 'daily_limit',
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a chunk type it does not know', () => {
|
||||
expect(uiStreamChunkSchema.safeParse({ type: 'reasoning-delta', id: 'r', delta: 'x' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ui-stream.v1 fixture', () => {
|
||||
const events = dataEvents(fixture);
|
||||
const chunks = events.slice(0, -1).map((event) => uiStreamChunkSchema.parse(JSON.parse(event)));
|
||||
const ofType = <T extends UiStreamChunk['type']>(type: T) =>
|
||||
chunks.filter((c): c is Extract<UiStreamChunk, { type: T }> => c.type === type);
|
||||
|
||||
it('is a stream of valid chunks closed by [DONE]', () => {
|
||||
expect(events.at(-1)).toBe(UI_STREAM_DONE);
|
||||
expect(chunks[0]?.type).toBe('start');
|
||||
expect(chunks.at(-1)).toEqual({ type: 'finish', finishReason: 'tool-calls' });
|
||||
});
|
||||
|
||||
it('names the new thread before anything else is said', () => {
|
||||
expect(chunks[1]?.type).toBe('data-trek-thread');
|
||||
});
|
||||
|
||||
it('asks for approval with the call id and previews that same call', () => {
|
||||
const [request] = ofType('tool-approval-request');
|
||||
const [change] = ofType('data-trek-change');
|
||||
expect(request).toBeDefined();
|
||||
expect(request?.approvalId).toBe(request?.toolCallId);
|
||||
expect(change?.data.toolCallId).toBe(request?.toolCallId);
|
||||
expect(ofType('tool-input-available').map((c) => c.toolCallId)).toContain(request?.toolCallId);
|
||||
});
|
||||
|
||||
it('closes every text block it opens', () => {
|
||||
const opened = ofType('text-start').map((c) => c.id);
|
||||
expect(opened.length).toBeGreaterThan(0);
|
||||
expect(ofType('text-end').map((c) => c.id)).toEqual(opened);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mentions in a chat request', () => {
|
||||
const base = { ...ctx, threadId: 3, text: 'Move @Louvre to @Friday' };
|
||||
|
||||
it('names up to ten days, places or bookings by id, never by label', () => {
|
||||
const mentions = [
|
||||
{ kind: 'place', id: 4 },
|
||||
{ kind: 'day', id: 9 },
|
||||
{ kind: 'reservation', id: 2 },
|
||||
];
|
||||
expect(assistantChatRequestSchema.safeParse({ ...base, mentions }).success).toBe(true);
|
||||
const eleven = Array.from({ length: 11 }, () => ({ kind: 'place', id: 4 }));
|
||||
expect(assistantChatRequestSchema.safeParse({ ...base, mentions: eleven }).success).toBe(false);
|
||||
const labelled = [{ kind: 'place', id: 4, label: 'Louvre' }];
|
||||
expect(assistantChatRequestSchema.safeParse({ ...base, mentions: labelled }).success).toBe(false);
|
||||
expect(assistantChatRequestSchema.safeParse({ ...base, mentions: [{ kind: 'trip', id: 4 }] }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assistantAddFoundPlaceRequestSchema', () => {
|
||||
const parse = (body: Record<string, unknown>) => assistantAddFoundPlaceRequestSchema.safeParse({ ...ctx, ...body });
|
||||
|
||||
it('names a found place by its ref and the day it goes on', () => {
|
||||
expect(parse({ ref: 'toolu_01:2', dayId: 32 }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses an empty or overlong ref, a bad day and anything about the place itself', () => {
|
||||
expect(parse({ ref: '', dayId: 32 }).success).toBe(false);
|
||||
expect(parse({ ref: 'x'.repeat(81), dayId: 32 }).success).toBe(false);
|
||||
expect(parse({ ref: 'a:1', dayId: 0 }).success).toBe(false);
|
||||
expect(parse({ ref: 'a:1', dayId: 3, name: 'Elsewhere' }).success).toBe(false);
|
||||
expect(assistantAddFoundPlaceRequestSchema.safeParse({ ref: 'a:1', dayId: 3 }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the effect of a change', () => {
|
||||
const preview = {
|
||||
toolCallId: 'c1',
|
||||
verbKey: 'addPlaceToDay',
|
||||
subject: 'Kinkaku-ji',
|
||||
rows: [],
|
||||
dayId: 32,
|
||||
placeIds: [],
|
||||
undoable: true,
|
||||
};
|
||||
const ghost = { name: 'Kinkaku-ji', lat: 35.0394, lng: 135.7292, categoryId: null };
|
||||
const add = { kind: 'add', dayId: 32, fromDayId: null, placeId: null, assignmentId: null, place: ghost };
|
||||
const withEffect = (effect: unknown) => assistantChangePreviewSchema.safeParse({ ...preview, effect }).success;
|
||||
|
||||
it('is optional, so previews stored before it still read', () => {
|
||||
expect(assistantChangePreviewSchema.safeParse(preview).success).toBe(true);
|
||||
expect(withEffect(null)).toBe(true);
|
||||
});
|
||||
|
||||
it('places a new place on its day, or moves an existing one between days', () => {
|
||||
expect(withEffect(add)).toBe(true);
|
||||
const existing = { placeId: 7, assignmentId: 12, place: null };
|
||||
expect(withEffect({ kind: 'move', dayId: 32, fromDayId: 31, ...existing })).toBe(true);
|
||||
expect(withEffect({ kind: 'remove', dayId: 31, fromDayId: null, ...existing })).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a position off the globe, an overlong name and an unknown kind', () => {
|
||||
expect(withEffect({ ...add, place: { ...ghost, lat: 91 } })).toBe(false);
|
||||
expect(withEffect({ ...add, place: { ...ghost, lng: -181 } })).toBe(false);
|
||||
expect(withEffect({ ...add, place: { ...ghost, name: 'x'.repeat(201) } })).toBe(false);
|
||||
expect(withEffect({ ...add, kind: 'swap' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assistantFoundPlaceSchema', () => {
|
||||
const place = {
|
||||
ref: 'toolu_01:0',
|
||||
name: 'Trattoria da Enzo',
|
||||
address: 'Via dei Vascellari 29, Roma',
|
||||
lat: 41.8885,
|
||||
lng: 12.4771,
|
||||
kind: 'food',
|
||||
rating: 4.6,
|
||||
openNow: null,
|
||||
googlePlaceId: 'ChIJ-abc_123',
|
||||
osmId: null,
|
||||
photoKey: 'ChIJ-abc_123',
|
||||
inTripPlaceId: null,
|
||||
};
|
||||
const withPlace = (patch: Record<string, unknown>) => assistantFoundPlaceSchema.safeParse({ ...place, ...patch });
|
||||
|
||||
it('reads a place with a source id as its photo key, or coordinates', () => {
|
||||
expect(withPlace({}).success).toBe(true);
|
||||
expect(withPlace({ photoKey: 'coords:41.8885,12.4771' }).success).toBe(true);
|
||||
expect(withPlace({ photoKey: 'coords:-33.9,-70.6' }).success).toBe(true);
|
||||
expect(withPlace({ photoKey: null }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('never takes a URL or anything with a path as the photo key', () => {
|
||||
const notKeys = [
|
||||
'https://evil.example/p.jpg',
|
||||
'//evil.example/p.jpg',
|
||||
'/api/maps/place-photo/x/bytes',
|
||||
'a/b',
|
||||
'coords:1,2;x',
|
||||
'',
|
||||
];
|
||||
for (const photoKey of notKeys) expect(withPlace({ photoKey }).success, photoKey).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a Google id that is not one, a rating past five and an unknown kind', () => {
|
||||
expect(withPlace({ googlePlaceId: 'a b' }).success).toBe(false);
|
||||
expect(withPlace({ rating: 5.1 }).success).toBe(false);
|
||||
expect(withPlace({ kind: 'casino' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('holds at most eight places to a step and counts the rest', () => {
|
||||
const places = Array.from({ length: 9 }, (_, i) => ({ ...place, ref: `toolu_01:${i}` }));
|
||||
const step = (body: Record<string, unknown>) =>
|
||||
assistantPlacesSchema.safeParse({ toolCallId: 'toolu_01', ...body });
|
||||
expect(step({ places: places.slice(0, 8), more: 1 }).success).toBe(true);
|
||||
expect(step({ places, more: 0 }).success).toBe(false);
|
||||
expect(step({ places: [], more: -1 }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assistantWeatherSchema', () => {
|
||||
const day = {
|
||||
dayId: 32,
|
||||
date: '2026-06-12',
|
||||
main: 'Clear',
|
||||
tempMax: 24.5,
|
||||
tempMin: 16,
|
||||
rainPct: 10,
|
||||
kind: 'forecast',
|
||||
};
|
||||
|
||||
it('reads one to sixteen days, a climate day without a low or a rain chance', () => {
|
||||
expect(assistantWeatherSchema.safeParse({ days: [day] }).success).toBe(true);
|
||||
const climate = { ...day, dayId: null, tempMin: null, rainPct: null, kind: 'climate' };
|
||||
expect(assistantWeatherSchema.safeParse({ days: [climate] }).success).toBe(true);
|
||||
expect(assistantWeatherSchema.safeParse({ days: [] }).success).toBe(false);
|
||||
expect(assistantWeatherSchema.safeParse({ days: Array.from({ length: 17 }, () => day) }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a date that is not a calendar date and a rain chance past 100', () => {
|
||||
expect(assistantWeatherSchema.safeParse({ days: [{ ...day, date: '12.06.2026' }] }).success).toBe(false);
|
||||
expect(assistantWeatherSchema.safeParse({ days: [{ ...day, rainPct: 101 }] }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the new message parts', () => {
|
||||
const message = { id: '14', status: 'complete', createdAt: '2026-10-21T08:00:00.000Z' };
|
||||
const rainyDay = { dayId: 32, date: '2026-06-12', main: 'Rain', tempMax: 18, tempMin: 12, rainPct: 80 };
|
||||
|
||||
it('keeps found places and the weather on an answer', () => {
|
||||
const parts = [
|
||||
{ type: 'data-trek-places', data: { toolCallId: 'toolu_01', places: [], more: 0 } },
|
||||
{ type: 'data-trek-weather', data: { days: [{ ...rainyDay, kind: 'forecast' }] } },
|
||||
];
|
||||
expect(assistantMessageSchema.safeParse({ ...message, role: 'assistant', parts }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the checked mentions of a question with their labels', () => {
|
||||
const item = { kind: 'place', id: 4, label: 'Louvre' };
|
||||
const parts = [
|
||||
{ type: 'text', text: 'Is @Louvre open on Monday?' },
|
||||
{ type: 'data-trek-mentions', data: { items: [item] } },
|
||||
];
|
||||
expect(assistantMessageSchema.safeParse({ ...message, role: 'user', parts }).success).toBe(true);
|
||||
const tooLong = [{ type: 'data-trek-mentions', data: { items: [{ ...item, label: 'x'.repeat(121) }] } }];
|
||||
expect(assistantMessageSchema.safeParse({ ...message, role: 'user', parts: tooLong }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('never stores the live activity of a step', () => {
|
||||
const activity = { type: 'data-trek-activity', data: { toolCallId: 'toolu_01', label: null, area: null } };
|
||||
expect(assistantMessageSchema.safeParse({ ...message, role: 'assistant', parts: [activity] }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('answers a suggested found place with the message that holds it', () => {
|
||||
const answer = { ...message, role: 'assistant', status: 'awaiting_approval', parts: [] };
|
||||
expect(assistantAddFoundPlaceResponseSchema.safeParse({ message: answer }).success).toBe(true);
|
||||
expect(assistantAddFoundPlaceResponseSchema.safeParse({}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the new stream chunks', () => {
|
||||
const activity = (data: Record<string, unknown>) =>
|
||||
uiStreamChunkSchema.safeParse({ type: 'data-trek-activity', data: { toolCallId: 'toolu_01', ...data } }).success;
|
||||
|
||||
it('streams the live activity of a step, marked transient, with a point or a box', () => {
|
||||
const point = { type: 'point', lat: 41.89, lng: 12.47, radiusM: 1500 };
|
||||
const bbox = { type: 'bbox', south: 41.8, west: 12.4, north: 41.95, east: 12.6 };
|
||||
for (const area of [point, bbox, null]) expect(activity({ label: 'Trastevere', area })).toBe(true);
|
||||
const chunk = { type: 'data-trek-activity', transient: true, data: { toolCallId: 't', label: null, area: null } };
|
||||
expect(uiStreamChunkSchema.safeParse(chunk).success).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses an activity with a radius past 50 km or a label past 80 characters', () => {
|
||||
const area = { type: 'point', lat: 41.89, lng: 12.47, radiusM: 50_001 };
|
||||
expect(activity({ label: null, area })).toBe(false);
|
||||
expect(activity({ label: 'x'.repeat(81), area: null })).toBe(false);
|
||||
});
|
||||
|
||||
it('streams found places and the weather as they are stored', () => {
|
||||
const places = { type: 'data-trek-places', data: { toolCallId: 'toolu_01', places: [], more: 3 } };
|
||||
const day = { dayId: null, date: '2026-06-12', main: 'Clouds', tempMax: 21, tempMin: null, rainPct: null };
|
||||
const weather = { type: 'data-trek-weather', data: { days: [{ ...day, kind: 'current' }] } };
|
||||
expect(uiStreamChunkSchema.safeParse(places).success).toBe(true);
|
||||
expect(uiStreamChunkSchema.safeParse(weather).success).toBe(true);
|
||||
});
|
||||
|
||||
it('names the found-place refusal by its code', () => {
|
||||
expect(uiStreamChunkSchema.parse({ type: 'error', errorText: 'decide_first' })).toEqual({
|
||||
type: 'error',
|
||||
errorText: 'decide_first',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,507 @@
|
||||
import { idParamSchema, idSchema } from '../common/primitives.schema';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Trip assistant contract for /api/trips/:tripId/assistant.
|
||||
*
|
||||
* The assistant works the trip through the same MCP tools any client uses.
|
||||
* Reading tools run at once; every writing call is held as a change preview
|
||||
* the user applies or discards, and only then runs, with the arguments the
|
||||
* server stored, never ones the client sends back. A file attached in the
|
||||
* chat (`attachments`) is read by the server itself: the step is the call
|
||||
* `read_booking_file`, and each booking it finds is held as an
|
||||
* `import_booking` change like any other. Neither is a tool the model knows.
|
||||
*
|
||||
* `chat`, `attachments` and `threads/:threadId/decisions` answer with a stream of UI message
|
||||
* chunks (`uiStreamChunkSchema`, a subset of the UI Message Stream v1 plus the
|
||||
* `data-trek-*` parts), one `data: <json>` event each, closed by
|
||||
* `data: [DONE]`. `__fixtures__/ui-stream.v1.txt` is one such stream; the
|
||||
* server's writer and the client's parser are both tested against it.
|
||||
*/
|
||||
|
||||
/** Why a turn could not run or stopped, as the `error` chunk and the JSON errors name it. */
|
||||
export const ASSISTANT_ERROR_CODES = [
|
||||
'not_connected',
|
||||
'busy',
|
||||
'daily_limit',
|
||||
'rate_limited',
|
||||
'needs_reauth',
|
||||
'usage_limit',
|
||||
'not_eligible',
|
||||
'invalid_key',
|
||||
'provider_unavailable',
|
||||
'max_steps',
|
||||
'offline',
|
||||
'no_bookings_found',
|
||||
'import_unavailable',
|
||||
// A found place was suggested while the thread still holds changes nobody decided on.
|
||||
'decide_first',
|
||||
// TREK's own per-user limit on turns and changes, apart from the provider's `rate_limited`.
|
||||
'too_many_requests',
|
||||
'generic',
|
||||
] as const;
|
||||
export const assistantErrorCodeSchema = z.enum(ASSISTANT_ERROR_CODES);
|
||||
export type AssistantErrorCode = z.infer<typeof assistantErrorCodeSchema>;
|
||||
|
||||
/**
|
||||
* What a change does, one per writing tool. The client labels the card with
|
||||
* `assistant.change.verb.<key>`.
|
||||
*/
|
||||
export const ASSISTANT_CHANGE_VERB_KEYS = [
|
||||
'addPlace',
|
||||
'addPlaceToDay',
|
||||
'updatePlace',
|
||||
'assignPlace',
|
||||
'moveAssignment',
|
||||
'removeFromDay',
|
||||
'addDayNote',
|
||||
'updateDayNote',
|
||||
'addPackingItem',
|
||||
'updatePackingItem',
|
||||
'addTodo',
|
||||
'addBudgetItem',
|
||||
'addReservation',
|
||||
'importBooking',
|
||||
] as const;
|
||||
export const assistantChangeVerbKeySchema = z.enum(ASSISTANT_CHANGE_VERB_KEYS);
|
||||
export type AssistantChangeVerbKey = z.infer<typeof assistantChangeVerbKeySchema>;
|
||||
|
||||
/** The field a change row shows, labelled `assistant.change.label.<key>`. */
|
||||
export const ASSISTANT_CHANGE_LABEL_KEYS = [
|
||||
'name',
|
||||
'description',
|
||||
'address',
|
||||
'coordinates',
|
||||
'category',
|
||||
'day',
|
||||
'time',
|
||||
'endTime',
|
||||
'notes',
|
||||
'website',
|
||||
'phone',
|
||||
'email',
|
||||
'openingHours',
|
||||
'price',
|
||||
'currency',
|
||||
'stopType',
|
||||
'text',
|
||||
'icon',
|
||||
'color',
|
||||
'position',
|
||||
'quantity',
|
||||
'weight',
|
||||
'bag',
|
||||
'packed',
|
||||
'dueDate',
|
||||
'priority',
|
||||
'assignee',
|
||||
'type',
|
||||
'location',
|
||||
'link',
|
||||
'confirmation',
|
||||
'checkIn',
|
||||
'checkOut',
|
||||
'stay',
|
||||
'members',
|
||||
'route',
|
||||
] as const;
|
||||
export const assistantChangeLabelKeySchema = z.enum(ASSISTANT_CHANGE_LABEL_KEYS);
|
||||
export type AssistantChangeLabelKey = z.infer<typeof assistantChangeLabelKeySchema>;
|
||||
|
||||
const latSchema = z.number().min(-90).max(90);
|
||||
const lngSchema = z.number().min(-180).max(180);
|
||||
|
||||
// ── Requests ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A day, place or booking the user named with `@` in the message. The server
|
||||
* checks each against the trip and takes the label from its own data, never
|
||||
* from the request; one that is not in the trip is dropped without a word.
|
||||
*/
|
||||
export const ASSISTANT_MENTION_KINDS = ['day', 'place', 'reservation'] as const;
|
||||
export const assistantMentionSchema = z
|
||||
.object({
|
||||
kind: z.enum(ASSISTANT_MENTION_KINDS),
|
||||
id: idSchema,
|
||||
})
|
||||
.strict();
|
||||
export type AssistantMention = z.infer<typeof assistantMentionSchema>;
|
||||
|
||||
/** What the user is looking at when they ask: the selected day, or the places they asked about. */
|
||||
export const assistantFocusSchema = z
|
||||
.object({
|
||||
dayId: idSchema.optional(),
|
||||
placeIds: z.array(idSchema).max(20).optional(),
|
||||
})
|
||||
.strict();
|
||||
export type AssistantFocus = z.infer<typeof assistantFocusSchema>;
|
||||
|
||||
/** The language to answer in and the zone "today" is read in, sent with every turn. */
|
||||
export const assistantClientCtxSchema = z.object({
|
||||
locale: z.string().min(2).max(10),
|
||||
timeZone: z.string().min(1).max(64),
|
||||
});
|
||||
export type AssistantClientCtx = z.infer<typeof assistantClientCtxSchema>;
|
||||
|
||||
/** POST /chat: a new message, in an existing thread or (threadId null) a new one. */
|
||||
export const assistantChatRequestSchema = assistantClientCtxSchema
|
||||
.extend({
|
||||
threadId: idSchema.nullable(),
|
||||
text: z.string().trim().min(1).max(4000),
|
||||
focus: assistantFocusSchema.optional(),
|
||||
mentions: z.array(assistantMentionSchema).max(10).optional(),
|
||||
})
|
||||
.strict();
|
||||
export type AssistantChatRequest = z.infer<typeof assistantChatRequestSchema>;
|
||||
|
||||
/**
|
||||
* POST /attachments, multipart: the file in the field `file`, these as text
|
||||
* fields beside it. A new thread when threadId is left out. Multipart sends
|
||||
* every field as a string, so the id is read from its text.
|
||||
*/
|
||||
export const assistantAttachmentRequestSchema = assistantClientCtxSchema
|
||||
.extend({
|
||||
threadId: idParamSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
export type AssistantAttachmentRequest = z.infer<typeof assistantAttachmentRequestSchema>;
|
||||
|
||||
/** One decision on a held change. */
|
||||
export const assistantDecisionSchema = z
|
||||
.object({
|
||||
toolCallId: z.string().min(1).max(64),
|
||||
approved: z.boolean(),
|
||||
})
|
||||
.strict();
|
||||
export type AssistantDecision = z.infer<typeof assistantDecisionSchema>;
|
||||
|
||||
/** POST /threads/:threadId/decisions: every change the step holds, applied or discarded. */
|
||||
export const assistantDecisionsRequestSchema = assistantClientCtxSchema
|
||||
.extend({
|
||||
decisions: z.array(assistantDecisionSchema).min(1).max(12),
|
||||
})
|
||||
.strict();
|
||||
export type AssistantDecisionsRequest = z.infer<typeof assistantDecisionsRequestSchema>;
|
||||
|
||||
/** POST /stop. No fields: the body is JSON only so the request cannot be sent cross-site as a form. */
|
||||
export const assistantStopRequestSchema = z.object({}).strict();
|
||||
export type AssistantStopRequest = z.infer<typeof assistantStopRequestSchema>;
|
||||
|
||||
/** POST /tool-calls/:toolCallId/undo. No fields, for the same reason as stop. */
|
||||
export const assistantUndoRequestSchema = z.object({}).strict();
|
||||
export type AssistantUndoRequest = z.infer<typeof assistantUndoRequestSchema>;
|
||||
|
||||
/**
|
||||
* POST /threads/:threadId/found-places: suggest a place from a search result
|
||||
* for a day. `ref` names the place in the thread's stored places part; the
|
||||
* server holds the change like any other and runs no model turn for it.
|
||||
*/
|
||||
export const assistantAddFoundPlaceRequestSchema = assistantClientCtxSchema
|
||||
.extend({
|
||||
ref: z.string().min(1).max(80),
|
||||
dayId: idSchema,
|
||||
})
|
||||
.strict();
|
||||
export type AssistantAddFoundPlaceRequest = z.infer<typeof assistantAddFoundPlaceRequestSchema>;
|
||||
|
||||
// ── Change previews ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* What a change does to the day plan, so the map can show it before it is
|
||||
* applied: the day it lands on (for a removal, the day it leaves), the day a
|
||||
* move comes from, and for a place that does not exist yet its name and
|
||||
* position. Null for a change that does not touch the plan.
|
||||
*/
|
||||
export const assistantChangeEffectSchema = z.object({
|
||||
kind: z.enum(['add', 'remove', 'move']),
|
||||
dayId: idSchema,
|
||||
fromDayId: idSchema.nullable(),
|
||||
placeId: idSchema.nullable(),
|
||||
assignmentId: idSchema.nullable(),
|
||||
place: z
|
||||
.object({
|
||||
name: z.string().max(200),
|
||||
lat: latSchema,
|
||||
lng: lngSchema,
|
||||
categoryId: idSchema.nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
export type AssistantChangeEffect = z.infer<typeof assistantChangeEffectSchema>;
|
||||
|
||||
/** One line of a change card, in plain words: names, dates and times, never ids. */
|
||||
export const assistantChangeRowSchema = z.object({
|
||||
op: z.enum(['add', 'change', 'move']),
|
||||
labelKey: assistantChangeLabelKeySchema,
|
||||
before: z.string().nullable(),
|
||||
after: z.string(),
|
||||
});
|
||||
export type AssistantChangeRow = z.infer<typeof assistantChangeRowSchema>;
|
||||
|
||||
/** A held writing call as the user sees it before deciding. */
|
||||
export const assistantChangePreviewSchema = z.object({
|
||||
toolCallId: z.string(),
|
||||
verbKey: assistantChangeVerbKeySchema,
|
||||
// What the change is about: the place, note or item name.
|
||||
subject: z.string(),
|
||||
rows: z.array(assistantChangeRowSchema).max(12),
|
||||
// Where "Show on map" goes after the change was applied.
|
||||
dayId: idSchema.nullable(),
|
||||
placeIds: z.array(idSchema),
|
||||
// Whether applying it can be undone for a while (a create with a matching delete).
|
||||
undoable: z.boolean(),
|
||||
// Left out by servers and stored previews from before the map showed changes.
|
||||
effect: assistantChangeEffectSchema.nullable().optional(),
|
||||
});
|
||||
export type AssistantChangePreview = z.infer<typeof assistantChangePreviewSchema>;
|
||||
|
||||
/** An applied change: what it touched, and until when it can be undone. */
|
||||
export const assistantAppliedSchema = z.object({
|
||||
toolCallId: z.string(),
|
||||
entity: z.enum(['place', 'assignment', 'day_note', 'packing', 'todo', 'budget', 'reservation']),
|
||||
ids: z.array(idSchema),
|
||||
dayId: idSchema.nullable(),
|
||||
undoUntil: z.string().nullable(),
|
||||
});
|
||||
export type AssistantApplied = z.infer<typeof assistantAppliedSchema>;
|
||||
|
||||
// ── Cards in an answer ──────────────────────────────────────────────────────
|
||||
|
||||
/** The kind a found place is drawn as, read from whatever its source calls it. */
|
||||
export const ASSISTANT_FOUND_PLACE_KINDS = [
|
||||
'lodging',
|
||||
'food',
|
||||
'drinks',
|
||||
'sight',
|
||||
'shopping',
|
||||
'transport',
|
||||
'beach',
|
||||
'nature',
|
||||
'activity',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* One place a search step found, normalized by the server from the tool's
|
||||
* result. It never carries a URL: the client asks TREK's photo proxy with
|
||||
* `photoKey` (a source id or `coords:lat,lng`) and shows only a same-origin
|
||||
* answer. `openNow` is set only from the source's own flag and only while
|
||||
* the trip is running; `inTripPlaceId` names the trip's place when it is the
|
||||
* same one.
|
||||
*/
|
||||
export const assistantFoundPlaceSchema = z.object({
|
||||
// `${toolCallId}:${index}`, the handle a suggestion for a day names it by.
|
||||
ref: z.string().max(80),
|
||||
name: z.string().max(120),
|
||||
address: z.string().max(200).nullable(),
|
||||
lat: latSchema,
|
||||
lng: lngSchema,
|
||||
kind: z.enum(ASSISTANT_FOUND_PLACE_KINDS).nullable(),
|
||||
rating: z.number().min(0).max(5).nullable(),
|
||||
openNow: z.boolean().nullable(),
|
||||
googlePlaceId: z.string().regex(/^[\w-]{1,200}$/).nullable(),
|
||||
osmId: z.string().max(40).nullable(),
|
||||
photoKey: z.string().regex(/^([\w-]{1,200}|coords:-?\d{1,3}(\.\d{1,12})?,-?\d{1,3}(\.\d{1,12})?)$/).nullable(),
|
||||
inTripPlaceId: idSchema.nullable(),
|
||||
});
|
||||
export type AssistantFoundPlace = z.infer<typeof assistantFoundPlaceSchema>;
|
||||
|
||||
/** The places one search step found: at most eight, `more` counts the rest. */
|
||||
export const assistantPlacesSchema = z.object({
|
||||
toolCallId: z.string().max(64),
|
||||
places: z.array(assistantFoundPlaceSchema).max(8),
|
||||
more: z.number().int().nonnegative(),
|
||||
});
|
||||
export type AssistantPlaces = z.infer<typeof assistantPlacesSchema>;
|
||||
|
||||
/**
|
||||
* The weather the steps of an answer looked up, one entry per date.
|
||||
* Temperatures are °C; the client converts them to the user's unit. `main`
|
||||
* is the condition the weather icon is chosen by.
|
||||
*/
|
||||
export const assistantWeatherDaySchema = z.object({
|
||||
dayId: idSchema.nullable(),
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
main: z.string().max(20),
|
||||
tempMax: z.number(),
|
||||
tempMin: z.number().nullable(),
|
||||
rainPct: z.number().int().min(0).max(100).nullable(),
|
||||
kind: z.enum(['forecast', 'climate', 'current']),
|
||||
});
|
||||
export type AssistantWeatherDay = z.infer<typeof assistantWeatherDaySchema>;
|
||||
|
||||
export const assistantWeatherSchema = z.object({
|
||||
days: z.array(assistantWeatherDaySchema).min(1).max(16),
|
||||
});
|
||||
export type AssistantWeather = z.infer<typeof assistantWeatherSchema>;
|
||||
|
||||
/** Where a running step looks: a point with a radius, or a box. */
|
||||
export const assistantActivityAreaSchema = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal('point'),
|
||||
lat: latSchema,
|
||||
lng: lngSchema,
|
||||
radiusM: z.number().int().min(50).max(50_000),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('bbox'),
|
||||
south: latSchema,
|
||||
west: lngSchema,
|
||||
north: latSchema,
|
||||
east: lngSchema,
|
||||
}),
|
||||
]);
|
||||
export type AssistantActivityArea = z.infer<typeof assistantActivityAreaSchema>;
|
||||
|
||||
/**
|
||||
* What a running step is doing, for the live line and the map: the place or
|
||||
* query it names (shortened), and the area it searches when that is small
|
||||
* enough to show. Only streamed, never stored.
|
||||
*/
|
||||
export const assistantActivitySchema = z.object({
|
||||
toolCallId: z.string().max(64),
|
||||
label: z.string().max(80).nullable(),
|
||||
area: assistantActivityAreaSchema.nullable(),
|
||||
});
|
||||
export type AssistantActivity = z.infer<typeof assistantActivitySchema>;
|
||||
|
||||
/** The mentions of a user message as the server checked them, with the labels it took from the trip. */
|
||||
export const assistantMentionsSchema = z.object({
|
||||
items: z
|
||||
.array(
|
||||
z.object({
|
||||
kind: z.enum(ASSISTANT_MENTION_KINDS),
|
||||
id: idSchema,
|
||||
label: z.string().max(120),
|
||||
}),
|
||||
)
|
||||
.max(10),
|
||||
});
|
||||
export type AssistantMentions = z.infer<typeof assistantMentionsSchema>;
|
||||
|
||||
// ── Threads and messages ────────────────────────────────────────────────────
|
||||
|
||||
export const assistantThreadSummarySchema = z.object({
|
||||
id: idSchema,
|
||||
title: z.string(),
|
||||
lastMessageAt: z.string(),
|
||||
});
|
||||
export type AssistantThreadSummary = z.infer<typeof assistantThreadSummarySchema>;
|
||||
|
||||
/** GET /threads: the user's latest threads in this trip. */
|
||||
export const assistantThreadListResponseSchema = z.object({ threads: z.array(assistantThreadSummarySchema) });
|
||||
export type AssistantThreadListResponse = z.infer<typeof assistantThreadListResponseSchema>;
|
||||
|
||||
/**
|
||||
* A server-side approval gate on a tool call. `resolution` closes a request
|
||||
* nobody decided: it expired after 24 hours, or the turn was cancelled.
|
||||
*/
|
||||
export const assistantToolApprovalSchema = z.object({
|
||||
id: z.string(),
|
||||
approved: z.boolean().optional(),
|
||||
resolution: z.enum(['cancelled', 'expired']).optional(),
|
||||
});
|
||||
export type AssistantToolApproval = z.infer<typeof assistantToolApprovalSchema>;
|
||||
|
||||
/** One stored part of a message, in the order the model produced it. */
|
||||
export const assistantMessagePartSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('text'), text: z.string() }),
|
||||
// A file the user handed the assistant. Only its description is kept, never its content.
|
||||
z.object({
|
||||
type: z.literal('attachment'),
|
||||
name: z.string(),
|
||||
mime: z.string(),
|
||||
size: z.number().int().nonnegative(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('tool-call'),
|
||||
toolCallId: z.string(),
|
||||
toolName: z.string(),
|
||||
args: z.record(z.string(), z.unknown()),
|
||||
result: z.unknown().optional(),
|
||||
isError: z.boolean().optional(),
|
||||
approval: assistantToolApprovalSchema.optional(),
|
||||
}),
|
||||
z.object({ type: z.literal('data-trek-change'), data: assistantChangePreviewSchema }),
|
||||
z.object({ type: z.literal('data-trek-applied'), data: assistantAppliedSchema }),
|
||||
z.object({ type: z.literal('data-trek-places'), data: assistantPlacesSchema }),
|
||||
z.object({ type: z.literal('data-trek-weather'), data: assistantWeatherSchema }),
|
||||
// Only on a user message.
|
||||
z.object({ type: z.literal('data-trek-mentions'), data: assistantMentionsSchema }),
|
||||
]);
|
||||
export type AssistantMessagePart = z.infer<typeof assistantMessagePartSchema>;
|
||||
|
||||
export const assistantMessageSchema = z.object({
|
||||
id: z.string(),
|
||||
role: z.enum(['user', 'assistant']),
|
||||
parts: z.array(assistantMessagePartSchema),
|
||||
status: z.enum(['complete', 'awaiting_approval', 'aborted', 'error']),
|
||||
createdAt: z.string(),
|
||||
});
|
||||
export type AssistantMessage = z.infer<typeof assistantMessageSchema>;
|
||||
|
||||
/** GET /threads/:threadId: the thread with its latest 50 messages, oldest first. */
|
||||
export const assistantThreadResponseSchema = z.object({
|
||||
thread: assistantThreadSummarySchema,
|
||||
messages: z.array(assistantMessageSchema),
|
||||
});
|
||||
export type AssistantThreadResponse = z.infer<typeof assistantThreadResponseSchema>;
|
||||
|
||||
/** POST /threads/:threadId/found-places: the new assistant message holding the suggested change. */
|
||||
export const assistantAddFoundPlaceResponseSchema = z.object({ message: assistantMessageSchema });
|
||||
export type AssistantAddFoundPlaceResponse = z.infer<typeof assistantAddFoundPlaceResponseSchema>;
|
||||
|
||||
/** POST /tool-calls/:toolCallId/undo */
|
||||
export const assistantUndoResponseSchema = z.object({ undone: z.literal(true) });
|
||||
export type AssistantUndoResponse = z.infer<typeof assistantUndoResponseSchema>;
|
||||
|
||||
// ── Stream ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** The marker the last `data:` event of a stream carries instead of a chunk. */
|
||||
export const UI_STREAM_DONE = '[DONE]';
|
||||
|
||||
/**
|
||||
* The chunks of an assistant stream. A client ignores a chunk whose type it
|
||||
* does not know, so new types can be added without breaking older clients.
|
||||
* An error names an `AssistantErrorCode`; one this client does not know reads
|
||||
* as `generic`.
|
||||
*/
|
||||
export const uiStreamChunkSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('start'), messageId: z.string() }),
|
||||
z.object({ type: z.literal('start-step') }),
|
||||
z.object({ type: z.literal('text-start'), id: z.string() }),
|
||||
z.object({ type: z.literal('text-delta'), id: z.string(), delta: z.string() }),
|
||||
z.object({ type: z.literal('text-end'), id: z.string() }),
|
||||
z.object({ type: z.literal('tool-input-start'), toolCallId: z.string(), toolName: z.string() }),
|
||||
z.object({
|
||||
type: z.literal('tool-input-available'),
|
||||
toolCallId: z.string(),
|
||||
toolName: z.string(),
|
||||
input: z.record(z.string(), z.unknown()),
|
||||
}),
|
||||
// approvalId is the toolCallId: one id addresses the call and its decision.
|
||||
z.object({ type: z.literal('tool-approval-request'), approvalId: z.string(), toolCallId: z.string() }),
|
||||
z.object({ type: z.literal('tool-output-available'), toolCallId: z.string(), output: z.unknown() }),
|
||||
z.object({ type: z.literal('tool-output-error'), toolCallId: z.string(), errorText: z.string() }),
|
||||
z.object({ type: z.literal('tool-output-denied'), toolCallId: z.string() }),
|
||||
z.object({ type: z.literal('data-trek-thread'), data: z.object({ threadId: idSchema, title: z.string() }) }),
|
||||
z.object({ type: z.literal('data-trek-change'), data: assistantChangePreviewSchema }),
|
||||
z.object({ type: z.literal('data-trek-applied'), data: assistantAppliedSchema }),
|
||||
z.object({ type: z.literal('data-trek-places'), data: assistantPlacesSchema }),
|
||||
z.object({ type: z.literal('data-trek-weather'), data: assistantWeatherSchema }),
|
||||
// Live progress of a running step; the message never keeps it.
|
||||
z.object({
|
||||
type: z.literal('data-trek-activity'),
|
||||
data: assistantActivitySchema,
|
||||
transient: z.literal(true).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('data-trek-quota'),
|
||||
data: z.object({ used: z.number().int().nonnegative(), limit: z.number().int().nonnegative() }),
|
||||
}),
|
||||
z.object({ type: z.literal('finish-step') }),
|
||||
z.object({ type: z.literal('finish'), finishReason: z.enum(['stop', 'tool-calls', 'error']).optional() }),
|
||||
z.object({ type: z.literal('error'), errorText: assistantErrorCodeSchema.catch('generic') }),
|
||||
z.object({ type: z.literal('abort') }),
|
||||
]);
|
||||
export type UiStreamChunk = z.infer<typeof uiStreamChunkSchema>;
|
||||
Reference in New Issue
Block a user