/** * createChatStreamReducer — THE master chat-stream reader (Phase 3 of the * chat unification). One accumulation path for every event source: * * SSE bytes → createSseFrameDecoder ─┐ * NATS chunk → decodeNatsChunk ─┼→ ChatStreamEvent → reducer.apply() * history → decodeHistoricalMessageData (per-item vocabulary) → * envelope grouping → reducer.initializeWithState() * * The reducer absorbs, verbatim, the semantics that used to be spread over * three layers: * - `MessageSegmentAccumulator` stays as the internal per-turn segment * kernel (instantiated here — its goldens remain valid); * - the deleted `useRealtimeChunkProcessor`'s switch (in-stream vs post-END * routing, agent-busy outside a message window, escalated approvals, * compaction upsert, direct-mode barrier) — surfaced as * `ChatReducerEffect`s for consumers that want the callback contract; * - `useNatsChatAdapter`'s message-mutation callbacks (trailing-assistant * replace/append, compaction upsert, cross-message tool merge, approval * flip, participant dedup) — now the reducer's own state transitions via * `./message-mutations`; * - `useSseChatAdapter`/`useChat`'s SSE turn kernel (cumulative text * replace, front-inserted thinking, approval card, `decision_resolved` * receipt, sendIdx-keyed sources/meta maps). * * Idempotency: * - events whose `seq` ≤ the last applied seq are dropped (per instance); * - participant rows additionally dedup via a one-shot optimistic-echo * list and a short same-author content window (seq-less transports); * - value-level no-op merges return prior references (see * `message-mutations.ts`). * * TRANSPORT ORDERING ASSUMPTION (the seq gate, see `apply()`): every * transport feeding this reducer is AT-LEAST-ONCE and IN-ORDER. JetStream * redelivers (hence the gate) but never reorders within a stream, and the * catchup back-fill replays a monotonically increasing range. So `seq <= * lastAppliedSeq` means "already applied", and dropping is correct. * A genuinely out-of-order arrival (seq 5 then 4) would be discarded * PERMANENTLY — accepted deliberately: no transport here reorders, and an * out-of-order buffer would add latency + unbounded state to guard against * a case that cannot occur. If a reordering transport is ever added, this * gate is the place that must change (do not paper over it downstream). * * `resolvePendingApprovalForExecution` (the accumulator's implicit * approve-on-execution) is reachable ONLY on `transport: 'nats'` — the SSE * kernel never routes tool executions through the accumulator. * * PURE + framework-free: no React, no timers, no network. Side effects the * UI needs (approve/reject POSTs) enter as opaque callbacks stamped onto * approval segments. */ import { type AccumulatorCallbacks } from '../utils/message-segment-accumulator'; import { type ScrollAnchor } from '../utils/scroll-anchor'; import type { ChatApprovalStatus, MessageSegment, PendingToolCallData, ToolExecutionSegment, ExecutingToolState } from '../types'; import type { PendingApproval } from '../types/processing.types'; import type { DialogTokenUsage, StreamingPhase, UnifiedChatMessage, UnifiedUsageBreakdown } from '../types/unified-chat-state.types'; import { type ChatStreamEvent } from '../../../chat-protocol/events'; /** Per-turn metadata extracted from the streamed metadata/usage frames * (SSE transport). Canonical home — `use-sse-chat-adapter` re-exports it. */ export interface ChatTurnMeta { provider: string | null; modelLabel: string | null; contextWindowMaxTokens: number | null; /** Input tokens (from usage:start). Includes cached tokens. */ inputTokens: number | null; /** Output tokens (from the trailing usage frame). */ outputTokens: number | null; /** Cache hit % (read / total-input × 100). Only known after stream end. */ cacheHitRatePct: number | null; /** Cross-call usage breakdown extracted from the trailing usage frame. */ breakdown: UnifiedUsageBreakdown | null; /** Per-message viewport-positioning hint. */ scrollAnchor: ScrollAnchor | null; routedComplexity: string | null; routedThinkingBudget: number | null; } /** Single source of truth for a fresh `ChatTurnMeta` row. */ export declare function createEmptyTurnMeta(): ChatTurnMeta; /** SSE per-send maps, keyed by the send counter (`sendIdx`). Each user send * produces ONE server-side sources entry but can fan out to MULTIPLE * assistant messages client-side — the adapter maps every following * assistant message back to its send's entry. */ export interface ChatTurnMetaState { meta: Map; sources: Map; sendCount: number; } export interface ChatReducerState { messages: UnifiedChatMessage[]; streamingPhase: StreamingPhase; turnMeta: ChatTurnMetaState; dialogTokenUsage?: DialogTokenUsage | null; liveModel?: { provider: string | null; modelLabel: string | null; contextWindowMaxTokens: number | null; } | null; approvalStatuses: Record; /** * Hub conversation id for the Product Guide half of this dialog, learned from * a guide metadata frame. The hub mints it and requires it back on every * confirm-tool call, so a host resolving a guide approval card reads it from * here. Null until a guide turn has streamed — an approval that arrives from * history (after a reload) has no live frame to learn it from, which is why * the agent should also expose it per dialog. */ guideConversationId: string | null; } /** Escalated-approval bookkeeping entry (mirrors the legacy processor). */ export interface EscalatedApprovalData { command: string; explanation?: string; approvalType: string; toolCalls?: PendingToolCallData[]; } /** * Callback-visible effect produced by `apply()`. Names + args mirror the * legacy `RealtimeChunkCallbacks` contract 1:1 — the contract the deleted * `useRealtimeChunkProcessor` wrapper exposed, now pinned directly by * `__tests__/chat-stream-reducer-golden.test.ts`. * * `segments-after-approval-result` is the one conditional emission: the * legacy processor emitted a cumulative `onSegmentsUpdate` for a * non-escalated APPROVAL_RESULT only when the consumer had NOT wired * `onApprovalResolved` — the effect sink resolves that at dispatch time. */ export interface ChatReducerEffect { name: 'onStreamStart' | 'onStreamEnd' | 'onMetadata' | 'onSegmentsUpdate' | 'onError' | 'onUserMessage' | 'onTokenUsage' | 'onDirectMessage' | 'onSystemMessage' | 'onEscalatedApproval' | 'onEscalatedApprovalResult' | 'onApprovalResolved' | 'onEscalationOfferResolved' | 'onToolExecuted' | 'onAgentBusy' | 'onDialogClosed' | 'onTicketEvent' | 'segments-after-approval-result'; args: unknown[]; } export interface ChatStreamReducerOptions { /** Which transport's turn kernel drives `apply()`. Default `'nats'`. */ transport?: 'sse' | 'nats'; /** Seed for the approval-status map (request-id → status). */ approvalStatuses?: Record; /** Batch APPROVAL_REQUESTs render as one card (default true) or unfold. */ batchApprovalsEnabled?: boolean; /** Approval types displayed inline; others escalate. Default ['CLIENT']. */ displayApprovalTypes?: string[]; /** Opaque approve/reject handlers stamped onto approval segments. */ callbacks?: AccumulatorCallbacks; /** Engage the direct-mode barrier optimistically (host-known takeover). */ isDirectMode?: boolean; /** * Legacy-processor parity knob: post-MESSAGE_END tool chunks route * cross-message (`onToolExecuted` effect + `applyToolExecutionToMessages`) * only when the consumer wired the cross-message updater; otherwise they * fall through the accumulator like the pre-callback code path. Default * true (both first-party adapters wire it). */ crossMessageToolRouting?: boolean; /** Effect sink for callback-contract consumers (compat wrapper). */ onEffect?: (effect: ChatReducerEffect) => void; /** Notified after every state mutation (the dialog store wires this). */ onChange?: () => void; /** * Whether an ADMIN-authored `MESSAGE_REQUEST` may be consumed as OUR OWN * optimistic echo (see `pushOptimisticSend`). Default `false`. * * Who the local operator is decides this, and it differs per host: * - Hub website chat / ticket CLIENT side: the local user is NEVER the * admin, so an ADMIN-authored inbound row is a technician's reply and * must ALWAYS render. Consuming it as an echo would silently delete a * real message whenever its text happened to match ours. → `false`. * - OpenFrame product app (Mingo + ticket ADMIN side): the operator IS * the admin, so their own sends echo back as ADMIN and MUST be * deduped, or every message renders twice. → `true`. * * Only ever applies to text the host itself registered via * `pushOptimisticSend`, so enabling it cannot drop a message the host * did not just send. */ ownEchoIncludesAdmin?: boolean; /** * The LOCAL user's id, either as a value or as a GETTER resolved at event * time. When it resolves to a value, an inbound `MESSAGE_REQUEST` that * DECLARES a different author (`event.userId !== selfUserId`) may not be * consumed as our own optimistic echo. * * Without it the echo list matches on RAW TEXT alone, which on a shared * ADMIN side (two technicians in one ticket, `ownEchoIncludesAdmin: true`) * can silently delete a colleague's message: if OUR send never echoes back, * its entry stays armed and the next identical text from anyone — canned * replies like "ok" / "done" / "on it" make this routine — is swallowed. * * The guard FAILS OPEN, deliberately: a row that carries NO `userId` is not * "someone else's", it is UNATTRIBUTED, and rejecting it would disable * dedup entirely (→ every send rendered twice) on any transport whose * decoder does not surface the author id. Such a row falls back to the * text + `OWN_ECHO_TTL_MS` behaviour instead, and a one-shot `console.warn` * makes the missing id observable. * * Pass a FUNCTION whenever the id can change or arrive late (auth * rehydration, logout / login-as-another-user without a reload): reducers * are retained for the store's lifetime, so a value captured at creation * time can go stale and silently disable the guard. */ selfUserId?: string | (() => string | undefined); } /** * How long an un-consumed optimistic-echo entry stays armed **on the * UNATTRIBUTED path**. Long enough to cover any realistic send → server-echo * round trip, short enough that a send whose echo never lands cannot swallow * an unrelated identical message later in the conversation. * * A row that is DECLARED ours (`selfUserId` set and `event.userId === * selfUserId`) gets the LONGER `OWN_ECHO_AUTHOR_TTL_MS` instead — see there. */ export declare const OWN_ECHO_TTL_MS = 30000; /** * How long an un-consumed entry stays armed on the AUTHOR-MATCHED path * (`selfUserId` set, inbound row declares the same author). * * The author check rules out CROSS-technician theft, but NOT the same user on * a second tab or device: a row THIS tab did not originate looks exactly like * an author-matched echo. So the entry still needs an upper bound — without * one, a send whose echo never lands (dropped frame, backend text * normalization, reconnect gap) leaves the entry armed for the reducer's * whole lifetime, and the next identical text from the same user on ANOTHER * tab is silently swallowed. With `MAX_PENDING_ECHOES` armed slots and canned * replies ("ok", "done", "on it") being exactly the recurring strings, that is * a routine message-LOSS bug, strictly worse than the duplicate row it trades * against. * * Ten minutes is the bound: generously longer than the slow echo the author * path exists to protect (a JetStream catch-up replay after a network gap * arrives long after the send, and its rows carry `seq`, so the seq-less * content-dedup fallback cannot rescue it), and far shorter than the * "same text again an hour later" window in which a stale entry does damage. * * It is a BACKSTOP, not the usual bound: `turn-end` disarms entries much * sooner (see `purgeEchoesAtTurnEnd`). The ten minutes only ever apply to a * dialog that never reports a turn boundary at all. */ export declare const OWN_ECHO_AUTHOR_TTL_MS: number; /** One armed optimistic-echo entry: the sent text and the wall-clock ms it was * armed at. Exported because the LRU-eviction round trip PARKS these (see * `getPendingEchoes` / `InitializeExtras.pendingEchoes`). */ export interface PendingEcho { text: string; at: number; } export interface InitializeExtras { existingSegments?: MessageSegment[]; pendingApprovals?: Map; executingTools?: Map; escalatedApprovals?: Map; /** * Approval statuses PARKED from a previous instance of this key (LRU * eviction hands them to `onEvict`). Merged with the same state-monotonic * precedence as `mergeApprovalStatuses` — a resolved approval must not come * back as actionable, which is exactly why `resetForDialogSwitch` preserves * this map rather than clearing it. */ approvalStatuses?: Record; /** * Seq cursor PARKED from a previous instance of this key. A recreated * reducer starts at `-Infinity`, so a host that replays from its own cursor * would re-apply already-applied events; restoring the parked value keeps * `apply()`'s idempotency gate intact across an eviction. Only ever moves * the gate FORWARD (a lower value is ignored). */ lastAppliedSeq?: number; /** * Armed optimistic-echo entries PARKED from a previous instance of this key. * Without them, a key LRU-evicted between `pushOptimisticSend` and its * `MESSAGE_REQUEST` echo leaves the replacement reducer with nothing armed, * so the echo renders a DUPLICATE user bubble. Entries already past * `OWN_ECHO_AUTHOR_TTL_MS` are dropped on restore (an expired entry could * only swallow an unrelated identical message), and the list is capped at * `MAX_PENDING_ECHOES` exactly as the live path is. */ pendingEchoes?: readonly PendingEcho[]; /** * Whether the restored thread is a RESUMED dialog (a `MESSAGE_START` already * fired server-side), which makes post-stream continuation chunks append * into the existing bubble instead of taking the cold-start cumulative path. * Defaults to "the restored thread is non-empty" — an eviction restore of a * key that never streamed must NOT claim it did, or a cold text-delta with * no preceding `turn-start` appends into a bubble that was never spawned. * Pass explicitly only to override that derivation. */ resumed?: boolean; /** * Whether the restored tail means the AGENT IS STILL WORKING — as opposed to * blocked on the user. Raises an `idle` reducer to `thinking` on restore * (same rule as a live `onAgentBusy`: an open stream keeps the phase), which * is what puts the activity indicator back. * * Nothing on the wire restores this: the phase machine is driven by events, * and a run's EXECUTING chunk is long past by the time a reload happens while * its EXECUTED one may be minutes away. Derived once by * `extractIncompleteMessageState` so every host agrees on the distinction — * a PENDING approval is the opposite state and must NOT spin. */ agentBusy?: boolean; } export interface BeginSseSendOptions { text: string; hidden?: boolean; userName?: string; assistantName?: string; assistantAvatar?: string; } export interface ChatStreamReducer { /** Apply one normalized stream event. */ apply(event: ChatStreamEvent): void; /** Current immutable state snapshot (stable identity between mutations). */ readonly state: ChatReducerState; /** Full reset — thread, per-turn kernel, dedup sets, seq gate, maps. */ reset(): void; /** * Seed the thread (history hydration / persisted-state rehydration) and * optionally the per-turn kernel + escalated approvals (resume of an * incomplete turn). `messages: null` keeps the current thread. Escalated * entries are re-surfaced via `onEscalatedApproval` effects. Marks the * instance as having streamed so continuation chunks append. */ initializeWithState(messages: UnifiedChatMessage[] | null, extras?: InitializeExtras): void; setMessages(messages: UnifiedChatMessage[]): void; prependMessages(messages: UnifiedChatMessage[]): void; /** Optimistic local send: user bubble (+ echo record) + assistant placeholder. */ pushOptimisticSend(text: string, hidden?: boolean): void; /** Wipe thread + per-turn kernel, keep approval statuses (legacy clear). */ clearThread(): void; /** Per-dialog reset (keeps approval statuses — request-ids are global). */ resetForDialogSwitch(): void; beginSseSend(options: BeginSseSendOptions): void; endSseTurn(): void; failSseTurn(errorMessage: string): void; seedSseMaps(seed: { sources?: Array<[number, unknown[]]>; sendCount?: number; }): void; setPhase(phase: StreamingPhase): void; setApprovalStatus(requestId: string, status: ChatApprovalStatus | null): void; /** * CANONICAL merge of a PERSISTED status map (host-side store, history * hydration, dialog-switch top-up) into this reducer's map. * * Precedence is STATE-MONOTONIC, not source-based, because either side can * be the stale one: * - persisted lags (a snapshot taken before the stream resolved the * request) → the stream's resolved status must win, or a just-approved * card would be downgraded back to `pending` and re-arm its buttons; * - the STREAM lags (this tab saw the request, the operator resolved it in * a SECOND TAB, and the persisted map is the one carrying the truth) → * the persisted resolution must win, or the card stays actionable and * invites a second approve on an already-resolved request. * * With statuses being exactly `pending | approved | rejected | cancelled`, * "resolved beats pending" is a total order that gets both directions * right: take the persisted value when ours is `pending` and theirs isn't; * keep ours otherwise. Persisted entries still fill in every request-id the * stream hasn't seen (other dialogs, pre-session history). */ mergeApprovalStatuses(persisted: Record): void; setDirectMode(isDirectMode: boolean): void; setDialogTokenUsage(usage: DialogTokenUsage | null): void; /** +1/-1 windows during catchup replay (suppresses agent-busy locks). */ adjustAgentBusySuppression(delta: number): void; armAdoptTrailingAssistant(value: boolean): void; projectApprovalResolution(requestId: string, status: ChatApprovalStatus, resolvedByName?: string | null): void; projectToolExecution(segment: ToolExecutionSegment): void; /** * The seq gate's current value (`-Infinity` before anything seq-carrying was * applied). Not part of `state` — it is bookkeeping, not render input — but * a host that PARKS a reducer (LRU eviction, dialog swap) needs it to * restore idempotency on the recreated instance via * `initializeWithState(messages, { lastAppliedSeq })`. */ getLastAppliedSeq(): number; /** * Currently-armed optimistic-echo entries. Same rationale as * `getLastAppliedSeq`: not render input, but a host that PARKS a reducer * needs them to restore echo dedup on the recreated instance via * `initializeWithState(messages, { pendingEchoes })` — otherwise an echo * in flight across the eviction renders a duplicate user bubble. */ getPendingEchoes(): readonly PendingEcho[]; getSegments(): MessageSegment[]; updateApprovalStatus(requestId: string, status: ChatApprovalStatus, resolvedByName?: string | null): MessageSegment[]; getPendingEscalated(): Map; } export declare function createChatStreamReducer(options?: ChatStreamReducerOptions): ChatStreamReducer; //# sourceMappingURL=chat-stream-reducer.d.ts.map