/** * createChatDialogStore — framework-free registry of `ChatStreamReducer` * instances keyed by `(dialogId, side)`. Reducer state PERSISTS across * component unmounts (the store outlives React), and the store is the * `useSyncExternalStore` source for the `useChatStreamReducer` wrapper. * * CROSS-SIDE PROJECTION: exactly two operations fan out across sides of the * same dialogId — * 1. approval RESOLUTION by requestId (`approval-resolved` events), and * 2. tool-execution MERGE by execId (`tool-execution` events) * — implemented as idempotent pure projections over the other side's state * (recomputed from state, never re-injected into the other side's seq * stream; per-side seq gates stay untouched). Everything else — text, * thinking, participant rows — is strictly per-side. * * LIFECYCLE / MEMORY: the reducer map is LRU-capped (`maxReducers`, default * `DEFAULT_MAX_REDUCERS`) and evicted through the same path as `remove()`. * Without a cap an agent cycling hundreds of dialogs would retain a reducer * (thread + accumulator) per dialog for the process's lifetime; under SSR, * where the store is module-scoped, that growth is shared across every * request. `getSnapshot` is PURE — it never creates an entry — and * `getServerSnapshot` always returns the shared frozen `EMPTY_STATE`. * * EVICTION SAFETY (LRU recency alone is NOT liveness): * 1. RETAINED keys are never evicted. `retain(dialogId, side)` returns a * release fn; the `useChatStreamReducer` hook calls it for as long as it * is mounted, so a quiet-but-visible panel can't be dropped just because * ten other keys were touched. NON-React hosts must call it too. * Because the hook retains in an EFFECT but takes its reducer during * RENDER, a key created by a public `getReducer` is protected from * creation until its FIRST `retain()` (bounded by * `MAX_PENDING_RETAIN_KEYS`) — see `pendingRetain`. * 2. A reducer whose `streamingPhase !== 'idle'` is never evicted — dropping * mid-turn would recreate an EMPTY reducer on the next `getReducer` * while the host's seq cursor still says "caught up", i.e. the thread * visually vanishes with no path back. * 3. Eviction hands the host the dropped instance's PARKED state * (`onEvict(..., { messages, approvalStatuses, lastAppliedSeq, * pendingEchoes })`). A recreated reducer is pristine, so re-seeding * messages alone would * resurrect resolved approvals as actionable and reset the seq gate to * `-Infinity`; the parked payload is what makes the round-trip lossless. * 4. Eviction NEVER notifies synchronously. `getReducer` is called during * render (by the hook), and notifying there means updating other * components mid-render. The notify is deferred to a microtask. */ import { type ChatReducerState, type ChatStreamReducer, type ChatStreamReducerOptions, type PendingEcho } from './chat-stream-reducer'; import type { ChatStreamEvent } from '../../../chat-protocol/events'; export type ChatDialogSide = string; /** Factory for a key's reducer options, consulted ONCE at creation. A plain * `() => options` thunk remains assignable. */ export type CreateReducerOptionsFn = (dialogId: string, side: ChatDialogSide) => ChatStreamReducerOptions; export interface ChatDialogStore { /** Create-or-get the reducer for `(dialogId, side)`. `createOptions` is * consulted ONLY when the instance is first created. */ getReducer(dialogId: string, side?: ChatDialogSide, createOptions?: CreateReducerOptionsFn): ChatStreamReducer; /** Apply one event to a side, then fan out the cross-side projections to * the other sides of the same dialog. */ apply(dialogId: string, side: ChatDialogSide, event: ChatStreamEvent): void; /** Run adapter commands against a reducer; subscribers are notified. */ mutate(dialogId: string, side: ChatDialogSide, fn: (reducer: ChatStreamReducer) => T): T; subscribe(listener: () => void): () => void; /** * Referentially-stable snapshot for `useSyncExternalStore`. PURE — an * unknown key returns the shared frozen `EMPTY_STATE` rather than * creating a reducer (a create-on-read would let SSR renders accumulate * reducers in a process-global map, and would make React's snapshot read * a mutation). */ getSnapshot(dialogId: string, side?: ChatDialogSide): ChatReducerState; /** `useSyncExternalStore`'s server getter — always `EMPTY_STATE`. There is * no per-request store on the server, so a live thread cannot be rendered * server-side; the client hydrates from the real reducer. */ getServerSnapshot(): ChatReducerState; /** Drop a side's reducer (or every side of the dialog when omitted). */ remove(dialogId: string, side?: ChatDialogSide): void; /** * PIN `(dialogId, side)` against LRU eviction while a consumer is live. * Returns the release fn (idempotent; refcounted, so nested retains are * safe). Creation is NOT implied — retaining an absent key simply means the * key is protected once it exists. */ retain(dialogId: string, side?: ChatDialogSide): () => void; /** * POLICY retain: keep exactly `keys` pinned by this store-level set and * release every key the set previously pinned. Independent of the refcounted * `retain()` handles a host's components hold — the two compose. * * Exists because "retain this set, release the rest" is generic store * policy that every multi-panel host otherwise re-derives (diffing its own * key list against a map of release fns). Retains BEFORE releasing, so a key * present in both the old and the new set never momentarily drops to zero * retains and becomes evictable mid-swap. */ setRetained(keys: Array<{ dialogId: string; side?: ChatDialogSide; }>): void; } export declare const DEFAULT_DIALOG_SIDE: ChatDialogSide; /** Most-recently-used reducer keys retained before eviction. Ten covers * every realistic multi-panel / dialog-switching session while bounding an * agent that cycles hundreds of dialogs. */ export declare const DEFAULT_MAX_REDUCERS = 10; /** * Hard cap on keys held in the "created by `getReducer`, `retain()` still * pending" protection set — the ONLY thing bounding the map while retains are * outstanding, since that protection ends at the retain rather than on a timer. * * Deliberately far above `maxReducers`: a host may legitimately mount MORE * panels than the LRU cap (once their effects run, retained keys already let * the map exceed it), so capping the pending set at `maxReducers` would blank * exactly the panels round 10 fixed. It is a backstop against a caller that * reads a reducer once and drops it — or a concurrent render React discards * before any effect runs — not a per-commit budget. Past it, the oldest pending * key falls through to ordinary LRU. */ export declare const MAX_PENDING_RETAIN_KEYS = 256; export interface CreateChatDialogStoreOptions { /** LRU cap on retained reducers (keys are `(dialogId, side)` pairs). * Values < 1 are clamped to 1. Default `DEFAULT_MAX_REDUCERS`. */ maxReducers?: number; /** * Options applied to any reducer this store creates WITHOUT an explicit * per-call `createOptions` — i.e. every reducer first materialized by * `apply()` or `mutate()`. Hosts with non-default reducer semantics * (`ownEchoIncludesAdmin`, `selfUserId`, approve/reject `callbacks`, …) * MUST set this: creation options are consulted once, so a bare * apply-created instance would keep those semantics missing for its whole * lifetime, including for a later `getReducer(..., options)` caller. */ defaultCreateOptions?: CreateReducerOptionsFn; /** * Called when LRU eviction actually DROPS a key. The store knows this * exactly; without publishing it, hosts are left inferring eviction by * comparing reducer OBJECT IDENTITY across `getReducer` calls and then * suppressing the "pristine empty" snapshot that a silently-recreated * reducer returns — archaeology about a fact this callback states outright. * * Fires only for LRU eviction. Host-initiated `remove()` needs no * notification: the caller already knows which key it dropped. * * Called AFTER the key is gone, from `getReducer` (i.e. potentially during a * React render) — treat it as a "schedule a refetch/rehydrate" signal, not a * place to set state synchronously. * * `parked` carries the dropped reducer's non-refetchable state so the host * can restore it on the recreated instance (`initializeWithState(messages, * { approvalStatuses, lastAppliedSeq, pendingEchoes })`). Refetching * messages alone is NOT * equivalent: a recreated reducer starts with `approvalStatuses = {}` and * `lastAppliedSeq = -Infinity`, so a resolved approval whose APPROVAL_RESULT * row is not in the refetched history page re-renders as ACTIONABLE (the * exact hazard `resetForDialogSwitch` preserves that map to avoid), and a * replay from the host's own cursor re-applies events the dropped instance * had already consumed. */ onEvict?: (dialogId: string, side: ChatDialogSide, parked: EvictedReducerState) => void; } /** * Snapshot of an LRU-evicted reducer's state, captured just before the * instance is dropped. Everything here either cannot be refetched * (`lastAppliedSeq`) or is not reliably present in a refetched history page * (`approvalStatuses`); `messages` rides along so a host that keeps its own * copy does not have to. */ export interface EvictedReducerState { messages: ChatReducerState['messages']; approvalStatuses: ChatReducerState['approvalStatuses']; /** `-Infinity` when the instance never applied a seq-carrying event. */ lastAppliedSeq: number; /** * Optimistic-echo entries still armed at eviction time. A key dropped * between `pushOptimisticSend` and its `MESSAGE_REQUEST` echo would * otherwise leave the recreated reducer with nothing armed, and the echo * renders a DUPLICATE user bubble. Replay via * `initializeWithState(messages, { pendingEchoes })`; the reducer drops * entries already past `OWN_ECHO_AUTHOR_TTL_MS`. Empty for a key with no * send in flight (the common case). */ pendingEchoes: readonly PendingEcho[]; } export declare function createChatDialogStore(options?: CreateChatDialogStoreOptions): ChatDialogStore; //# sourceMappingURL=chat-dialog-store.d.ts.map