import { bookElementIdSchema, bookSpreadIdSchema } from '../book/book-ops.schema'; import { idParamSchema, idSchema } from '../common/primitives.schema'; import { assistantAttachmentIdSchema } from './assistant-attachments.schema'; import { assistantJourneySurfaceSchema } from './assistant-scope.schema'; import { assistantPageRenderSchema, assistantPageViewSchema } from './assistant-vision.schema'; import { assistantCardSchema, assistantCardWriteSchema, ATLAS_APPLIED_ENTITIES, ATLAS_VERB_KEYS, BOOK_APPLIED_ENTITIES, BOOK_DESIGN_VERB_KEYS, BOOK_VERB_KEYS, BOOKING_APPLIED_ENTITIES, BOOKING_VERB_KEYS, BUDGET_APPLIED_ENTITIES, BUDGET_VERB_KEYS, COLLAB_APPLIED_ENTITIES, COLLAB_VERB_KEYS, COLLECTION_APPLIED_ENTITIES, COLLECTION_VERB_KEYS, JOURNEY_APPLIED_ENTITIES, JOURNEY_VERB_KEYS, PACKING_APPLIED_ENTITIES, PACKING_VERB_KEYS, ROADTRIP_APPLIED_ENTITIES, ROADTRIP_VERB_KEYS, storedTrekCardSchema, TOUR_APPLIED_ENTITIES, TOUR_VERB_KEYS, trekCardSchema, VACAY_APPLIED_ENTITIES, VACAY_VERB_KEYS, type AssistantCard, } from './cards'; 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: ` 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', // The model answered with nothing, not even after it was asked to go on. 'empty_answer', 'offline', 'no_bookings_found', // An attached GPX, KML or KMZ file without a single track or route to take in as a tour. 'no_tracks_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; /** * What a change does, one per writing tool. The client labels the card with * `assistant.change.verb.`. These are the core verbs; each part of TREK * adds its own in its cards contract (`cards/.contract.ts`). */ export const CORE_VERB_KEYS = [ 'addPlace', 'addPlaceToDay', 'updatePlace', 'assignPlace', 'moveAssignment', 'removeFromDay', 'addDayNote', 'updateDayNote', 'addPackingItem', 'updatePackingItem', 'addTodo', 'addBudgetItem', 'updateBudgetItem', 'addReservation', 'importBooking', 'rememberNote', // The text of a file attached in the chat, sent to the AI provider once the user agrees. 'readAttachment', // A file attached in the chat, kept in the trip's Files, on its own or with a booking, an expense or a place. 'keepFile', // A tour: a hike or a city walk created, a hike changed, tours taken in from a GPX file. 'addTour', 'updateTour', 'importTour', // Any other tool: by what its annotations say it does. 'genericCreate', 'genericUpdate', 'genericRemove', ] as const; export const ASSISTANT_CHANGE_VERB_KEYS = [ ...CORE_VERB_KEYS, ...TOUR_VERB_KEYS, ...ROADTRIP_VERB_KEYS, ...BUDGET_VERB_KEYS, ...BOOKING_VERB_KEYS, ...COLLAB_VERB_KEYS, ...COLLECTION_VERB_KEYS, ...ATLAS_VERB_KEYS, ...VACAY_VERB_KEYS, ...JOURNEY_VERB_KEYS, ...PACKING_VERB_KEYS, ...BOOK_VERB_KEYS, ...BOOK_DESIGN_VERB_KEYS, ] as const; export const assistantChangeVerbKeySchema = z.enum(ASSISTANT_CHANGE_VERB_KEYS); export type AssistantChangeVerbKey = z.infer; /** The field a change row shows, labelled `assistant.change.label.`. */ export const ASSISTANT_CHANGE_LABEL_KEYS = [ 'name', 'description', 'address', 'coordinates', 'category', 'day', 'time', 'endTime', 'notes', 'website', 'phone', 'email', 'openingHours', 'price', 'currency', 'stopType', 'text', // A day note's heading and its body; `text` and `time` stay for rows already stored. 'title', 'content', 'icon', 'color', 'position', 'quantity', 'weight', 'bag', 'packed', 'dueDate', 'priority', 'assignee', 'type', 'location', 'link', 'confirmation', 'checkIn', 'checkOut', 'stay', 'members', 'route', // The hardest SAC grade a hike's way may take, T1 to T6. 'difficulty', // The recorded track a tour was done on; the part of the packing list a tour points at. 'track', 'packing', // The minutes of breaks a tour plans on top of its moving time. 'breaks', // A new order of a day's places or of the days, as the names in that order. 'order', // An argument of a tool without a card of its own; the row names it in `field`. 'field', // Where the text of an attached file goes once the user agrees: the model and its host. 'sentTo', // The booking, expense or place a kept file is attached to. 'attachedTo', ] as const; export const assistantChangeLabelKeySchema = z.enum(ASSISTANT_CHANGE_LABEL_KEYS); export type AssistantChangeLabelKey = z.infer; 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; /** The part of the map the planner shows, so "here" and "nearby" mean what is on screen. */ export const assistantMapViewSchema = z .object({ south: latSchema, west: lngSchema, north: latSchema, east: lngSchema }) .strict(); export type AssistantMapView = z.infer; /** * What the user is looking at when they ask: the selected day, or the places * they asked about, and the part of the map on screen. */ export const assistantFocusSchema = z .object({ dayId: idSchema.optional(), placeIds: z.array(idSchema).max(20).optional(), view: assistantMapViewSchema.optional(), /** In the Studio: the page (spread) on screen, and the elements selected on it. */ spreadId: bookSpreadIdSchema.optional(), elementIds: z.array(bookElementIdSchema).max(30).optional(), }) .strict(); export type AssistantFocus = z.infer; /** 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; /** 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(32_000), focus: assistantFocusSchema.optional(), mentions: z.array(assistantMentionSchema).max(50).optional(), /** The client draws a Studio page when the turn asks for one (the Studio's chat, assistant-vision.schema.ts). */ renders: z.boolean().optional(), /** A journey's chat only: the page the user asks from, the Studio's when left out. A trip's chat ignores it. */ surface: assistantJourneySurfaceSchema.optional(), }) .strict(); export type AssistantChatRequest = z.infer; /** * 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; /** 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; /** POST /threads/:threadId/decisions: every change the step holds, applied or discarded. */ export const assistantDecisionsRequestSchema = assistantClientCtxSchema .extend({ decisions: z.array(assistantDecisionSchema).min(1).max(50), /** As on a chat turn: the client draws a Studio page when the turn asks for one. */ renders: z.boolean().optional(), /** As on a chat turn: the page of a journey the decision is made on, which picks the tools the turn goes on with. */ surface: assistantJourneySurfaceSchema.optional(), }) .strict(); export type AssistantDecisionsRequest = z.infer; /** 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; /** POST /tool-calls/:toolCallId/undo. No fields, for the same reason as stop. */ export const assistantUndoRequestSchema = z.object({}).strict(); export type AssistantUndoRequest = z.infer; /** * 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; /** * POST /threads/:threadId/found-places/tour: "+ Tour" on a place a search * found, while a saved tour is open in the planner. Held like "+ Day 3": the * place comes from the stored search results, the tour must be one of this * trip's, and the server picks where along it the stop goes. */ export const assistantInsertFoundPlaceRequestSchema = assistantClientCtxSchema .extend({ ref: z.string().min(1).max(80), tourId: idSchema, }) .strict(); export type AssistantInsertFoundPlaceRequest = z.infer; /** The route files the chat takes in as tours (P7), beside the booking files. */ export const ASSISTANT_TOUR_FILE_EXTENSIONS = ['.gpx', '.kml', '.kmz'] as const; /** Whether a file the user attached is a route file, by its name. */ export function isAssistantTourFile(fileName: string): boolean { const dot = fileName.lastIndexOf('.'); const ext = dot < 0 ? '' : fileName.slice(dot).toLowerCase(); return (ASSISTANT_TOUR_FILE_EXTENSIONS as readonly string[]).includes(ext); } /** * POST /threads/:threadId/proposals: take a proposal an answer showed as a * card ("Plan this"). `toolCallId` names the read whose card it was; the * server holds the matching change like a found place, from what it stored, * and answers like the found places route. */ export const assistantTakeProposalRequestSchema = assistantClientCtxSchema .extend({ toolCallId: z.string().min(1).max(64), }) .strict(); export type AssistantTakeProposalRequest = z.infer; // ── 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; /** 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(), // The argument a `field` row shows, as the tool names it. field: z.string().max(60).optional(), }); export type AssistantChangeRow = z.infer; /** A held writing call as the server writes it: every verb and card one this build knows. */ export const assistantChangePreviewWriteSchema = 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(), // The tool behind a card without one of its own, and the part of TREK it works in. tool: z.string().max(80).optional(), domain: z.string().max(40).optional(), // What the change will do, drawn as a card; absent on older rows and on cards without one. card: trekCardSchema.nullable().optional(), // The proposal a change held by "Plan this" was taken from (its call id); absent on every other change. takenFrom: assistantTakeProposalRequestSchema.shape.toolCallId.optional(), }); /** * A held writing call as the user sees it before deciding, and as it is read * back: a verb a later build added reads as a plain update and a card this * build cannot draw as none, so an older client or a rolled back server still * shows the change instead of dropping it. */ export const assistantChangePreviewSchema = assistantChangePreviewWriteSchema.extend({ verbKey: assistantChangeVerbKeySchema.catch('genericUpdate'), card: storedTrekCardSchema.optional(), }); export type AssistantChangePreview = z.infer; /** What an applied change can touch in the core; each part of TREK adds its own in its cards contract. */ export const CORE_APPLIED_ENTITIES = [ 'place', 'assignment', 'day_note', 'packing', 'todo', 'budget', 'reservation', 'memory', 'tour', // A file kept in the trip's Files. 'file', 'other', ] as const; const assistantAppliedEntitySchema = z.enum([ ...CORE_APPLIED_ENTITIES, ...TOUR_APPLIED_ENTITIES, ...ROADTRIP_APPLIED_ENTITIES, ...BUDGET_APPLIED_ENTITIES, ...BOOKING_APPLIED_ENTITIES, ...COLLAB_APPLIED_ENTITIES, ...COLLECTION_APPLIED_ENTITIES, ...ATLAS_APPLIED_ENTITIES, ...VACAY_APPLIED_ENTITIES, ...JOURNEY_APPLIED_ENTITIES, ...PACKING_APPLIED_ENTITIES, ...BOOK_APPLIED_ENTITIES, ]); /** An applied change as the server writes it: what it touched, and until when it can be undone. */ const assistantAppliedWriteSchema = z.object({ toolCallId: z.string(), entity: assistantAppliedEntitySchema, ids: z.array(idSchema), dayId: idSchema.nullable(), undoUntil: z.string().nullable(), }); /** An applied change as it is read back: an entity a later build added reads as `other`, with nothing to open. */ export const assistantAppliedSchema = assistantAppliedWriteSchema.extend({ entity: assistantAppliedEntitySchema.catch('other'), }); export type AssistantApplied = z.infer; // ── 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; /** 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(20), more: z.number().int().nonnegative(), }); export type AssistantPlaces = z.infer; /** * 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; export const assistantWeatherSchema = z.object({ days: z.array(assistantWeatherDaySchema).min(1).max(16), }); export type AssistantWeather = z.infer; /** * The tool the model ends an answer with to offer what the user may ask * next. The server answers it itself and never shows it as a step. */ export const ASSISTANT_FOLLOW_UPS_TOOL = 'suggest_follow_ups'; /** The longest follow-up question the chat shows on one chip. */ export const MAX_FOLLOW_UP_CHARS = 80; /** How many follow-up questions an answer offers at most. */ export const MAX_FOLLOW_UPS = 3; /** The questions the model offers to ask next, fitting the conversation, in the user's language. */ export const assistantFollowUpsSchema = z.object({ questions: z.array(z.string().min(1).max(MAX_FOLLOW_UP_CHARS)).min(1).max(MAX_FOLLOW_UPS), }); export type AssistantFollowUps = z.infer; /** * The step a provider's own web search shows as in the chat: a tool call the * server records for the step list, never sent back to the model as one. */ export const ASSISTANT_WEB_SEARCH_STEP = 'web_search'; /** How many pages one answer names as its sources at most. */ export const MAX_ASSISTANT_SOURCES = 12; /** * The pages an answer drew on from the provider's web search, in the order * they were cited, each once: a link to open and its title. */ export const assistantSourcesSchema = z.object({ sources: z .array( z.object({ url: z .string() .max(2000) .regex(/^https?:\/\//), title: z.string().max(200), }), ) .min(1) .max(MAX_ASSISTANT_SOURCES), }); export type AssistantSources = z.infer; /** 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; /** * 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; /** 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(50), }); export type AssistantMentions = z.infer; // ── Threads and messages ──────────────────────────────────────────────────── export const assistantThreadSummarySchema = z.object({ id: idSchema, title: z.string(), lastMessageAt: z.string(), }); export type AssistantThreadSummary = z.infer; /** GET /threads: the user's latest threads in this trip. */ export const assistantThreadListResponseSchema = z.object({ threads: z.array(assistantThreadSummarySchema) }); export type AssistantThreadListResponse = z.infer; /** * 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; /** * The parts of a message, with the change, receipt and card parts read or * written as given. Generic over the three schemas so the emitted declaration * keeps each one's own Zod type instead of a precomputed object type, which a * consumer without strictNullChecks would read differently from its own infer. */ function messagePartSchema< Change extends z.ZodType, Applied extends z.ZodType, Card extends z.ZodType, >(change: Change, applied: Applied, card: Card) { return z.discriminatedUnion('type', [ z.object({ type: z.literal('text'), text: z.string() }), // A file the user handed the assistant. Only its description is kept here, never its content; the // server keeps the file under `attachmentId` (left out on messages from before it kept files). z.object({ type: z.literal('attachment'), name: z.string(), mime: z.string(), size: z.number().int().nonnegative(), attachmentId: assistantAttachmentIdSchema.optional(), }), 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: change }), z.object({ type: z.literal('data-trek-applied'), data: applied }), z.object({ type: z.literal('data-trek-places'), data: assistantPlacesSchema }), z.object({ type: z.literal('data-trek-weather'), data: assistantWeatherSchema }), // What the model offers to ask next, at the end of an answer. z.object({ type: z.literal('data-trek-follow-ups'), data: assistantFollowUpsSchema }), // The pages the answer cites from the provider's web search. z.object({ type: z.literal('data-trek-sources'), data: assistantSourcesSchema }), // The card of one tool call: a read's beside the answer, an applied write's on the message that proposed it. z.object({ type: z.literal('data-trek-card'), data: card }), // Only on a user message. z.object({ type: z.literal('data-trek-mentions'), data: assistantMentionsSchema }), // A Studio page the turn looked at: what it was and its description, never the image. z.object({ type: z.literal('data-trek-page-view'), data: assistantPageViewSchema }), ]); } /** One stored part of a message, in the order the model produced it, as a reader takes it. */ export const assistantMessagePartSchema = messagePartSchema( assistantChangePreviewSchema, assistantAppliedSchema, assistantCardSchema, ); export type AssistantMessagePart = z.infer; /** The parts as the server writes them: a verb, entity or card this build cannot name is a bug in the writer. */ export const assistantMessagePartWriteSchema = messagePartSchema( assistantChangePreviewWriteSchema, assistantAppliedWriteSchema, assistantCardWriteSchema, ); /** * A message's parts as they are read back: a `data-trek-*` part this build * does not know, or one that no longer reads, is dropped, so a part a later * build added never blanks the message around it. Any other broken part * still fails the whole list. A known part reads through its tolerant fields * (a later verb, entity or card kind); parts are written strictly, with * `assistantMessagePartWriteSchema`. */ export const assistantStoredPartsSchema = z .array( z.union([ assistantMessagePartSchema, z.object({ type: z.string().startsWith('data-trek-') }).transform(() => null), ]), ) .transform((parts) => parts.filter((part): part is AssistantMessagePart => part !== null)); 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; /** 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; /** 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; /** POST /tool-calls/:toolCallId/undo */ export const assistantUndoResponseSchema = z.object({ undone: z.literal(true) }); export type AssistantUndoResponse = z.infer; // ── 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 }), z.object({ type: z.literal('data-trek-follow-ups'), data: assistantFollowUpsSchema }), z.object({ type: z.literal('data-trek-sources'), data: assistantSourcesSchema }), z.object({ type: z.literal('data-trek-card'), data: assistantCardSchema }), // 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(), }), // A Studio page the turn asks the Studio tab to draw and upload; the message never keeps it. z.object({ type: z.literal('data-trek-render-request'), data: assistantPageRenderSchema, transient: z.literal(true).optional(), }), z.object({ type: z.literal('data-trek-page-view'), data: assistantPageViewSchema }), 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;