import type { MessageContent } from '../types'; /** * Reconciliation between persisted dialog history (processed GraphQL pages) * and the realtime messages a chat client accumulated from streaming chunks. * * This is the missing middle piece of the chat pipeline this lib already * owns: `processHistoricalMessages*` produces one stream, the realtime chunk * processor / segment accumulator produces the other, and every host (Mingo, * tickets, openframe-chat) needs to merge them whenever history is (re)fetched. * Hand-rolled versions of this merge have produced both duplicated turns * (synthetic kept alongside its persisted twin) and lost turns (synthetic * trimmed against a stale snapshot that didn't contain its persisted twin * yet) — the freshness rule below is the invariant that prevents both. * * Pure on purpose: the host owns WHEN to merge (react-query wiring, store * writes), this module owns HOW. */ /** Minimal structural shape the merge needs — hosts pass their own message * type and get it back. */ export interface MergeableChatMessage { id: string; role: string; content: MessageContent; timestamp?: Date; /** Highest CONTENT chunk streamSeq that composed this message (text / tool / * approval / error / compaction — never the non-persisted MESSAGE_END / * TOKEN_USAGE control chunks). Hosts stamp it on realtime synthetics so the * merge can decide coverage per-message: a synthetic is in history once a * persisted row of the SAME role reaches its seq. (Regular `user-` * MESSAGE_REQUEST synthetics are the exception — the backend persists their * rows without a seq, so they are deduped by content, not seq; see the merge.) * * Stamp it on HISTORY rows too (from the persisted `lastChunkStreamSeq`) * when available: the merge then computes a PER-ROLE max from history and a * synthetic is "covered" only when a persisted row of its own role actually * reached its seq. Without per-row history seqs the merge falls back to the * single global `historyMaxStreamSeq`, which a later/other-role row can push * past a synthetic whose turn is NOT in the snapshot (interrupted / * async-persisted) — dropping a message the user saw with no replay to * restore it. Optional — absent on hosts that don't stamp it (those keep the * global-seq / wall-clock behaviour). */ streamSeq?: number; } /** Ids minted client-side by realtime chunk processors * (`assistant--…` placeholder bubbles, `user--…` peer messages, * `direct--…` technician direct messages, `system--…` system notices, * `error-` stream errors). They never match the Mongo ObjectIds history * returns for the same turns. This is the cross-host contract every minting * site (lib `use-chat`, Mingo / tickets chunk processors, openframe-chat) * must keep matching — exported so it lives in exactly one place. * `direct-`/`system-` are persisted (as ADMIN/SYSTEM history rows) and so are * replayed by JetStream on reconnect; without them here a replayed direct * message renders twice (its persisted twin + the fresh synthetic). * `welcome-` and `optimistic-` ids are intentionally NOT listed: welcome * bubbles are never persisted server-side, and optimistic user messages are * deduped by content below. */ export declare const SYNTHETIC_REALTIME_ID_PREFIXES: readonly ["assistant-", "user-", "direct-", "system-", "error-"]; /** Flattens DESC-sorted message pages (newest page first, newest message * first within a page) into one chronological list. */ export declare function flattenMessagePagesChronological(pages: readonly { messages: readonly T[]; }[] | undefined): T[]; /** Max `lastChunkStreamSeq` across history pages — the history half of the * merge's seq-coverage signal (`HistoryMergeInput.historyMaxStreamSeq`), * also used by hosts as the JetStream replay start offset. 0 = unstamped. */ export declare function maxPersistedStreamSeq(pages: readonly { messages: readonly { lastChunkStreamSeq?: number | null; }[]; }[] | undefined): number; export interface HistoryMergeInput { /** Processed history in chronological order (all fetched pages). */ processedHistory: M[]; /** Raw history message ids across all fetched pages, when they can differ * from processed ids (processing may merge/rename). Optional — pass when * available so raw-id duplicates are filtered too. */ rawHistoryIds?: ReadonlySet; /** Messages currently in the host store for this dialog (realtime + prior merges). */ existingMessages: M[]; /** Id of the in-flight streaming synthetic, if any. Never dropped. * IMPORTANT: hosts must not pass a STALE id here (e.g. a streaming entry * left behind by unmounting mid-stream) — gate it on the server-side * stream state when available, or the synthetic will be exempted forever. */ streamingMessageId: string | null; /** Epoch ms when the history pages were fetched (react-query `dataUpdatedAt`). * Wall-clock freshness fallback used when seq coverage (below) is unknown: * a synthetic created AFTER this instant cannot be represented in the * snapshot, so it must be kept. NOTE this heuristic is blind to chunk * REPLAY, which re-mints synthetics for old turns with fresh timestamps — * pass the seq fields whenever the host tracks them. */ historyFetchedAt: number; /** Max `lastChunkStreamSeq` across the raw history pages, when the backend * stamps it (see `maxPersistedStreamSeq`). Together with * `realtimeSeenStreamSeq` this gives an exact coverage signal that * replaces the wall-clock heuristic. */ historyMaxStreamSeq?: number; /** Highest stream seq this client has consumed for the dialog (live or * replayed chunks). */ realtimeSeenStreamSeq?: number; } export declare function mergeHistoryWithRealtime(input: HistoryMergeInput): M[]; export interface HistoryPrependResult { newMessages: M[]; boundaryMessageId?: string; boundaryUpdates?: { content: MessageContent; }; } /** Pagination path (an older page arrived via fetchNextPage): everything on * screen stays; collect only the messages above the first already-known id, * plus a content refresh for that boundary message if it changed. Returns * null when there is nothing to apply. */ export declare function computeHistoryPrepend(processedHistory: M[], existingMessages: M[]): HistoryPrependResult | null; //# sourceMappingURL=history-merge.d.ts.map