import { useEffect, useReducer, useRef } from 'react'; import { destroySocket, getSocket } from '@/lib/socket'; import { MAX_COMMAND_MESSAGE_CHARS, MAX_COMMAND_RESULTS, MAX_EXECUTED_APPROVAL_IDS, MAX_MANIFEST_ITEMS, MAX_MESSAGES, MAX_STREAM_CHARS, MAX_TOOLS, } from './limits'; import { asConversationId, asFiniteNumber, asHarnessSelection, asString, asStringArray, isRecord, } from './runtime-guards'; import type { AgentEndPayload, AgentStartPayload, AgentTextPayload, AgentThinkingPayload, ChatSendCapabilityPayload, ChatSendProtocol, CommandDef, CommandManifest, CommandManifestPayload, ErrorPayload, HarnessSelection, HarnessTurnAckPayload, MessageAckPayload, SessionInfoPayload, SessionUsagePayload, SkillCommandDef, SlashCommandApprovalResultPayload, SlashCommandResultPayload, SystemReloadPayload, ToolEndPayload, ToolStartPayload, } from '@/lib/chat-contract'; export interface ToolCallState { toolCallId: string; toolName: string; status: 'running' | 'success' | 'error' | 'anomaly'; } /** Formats the visible marker prepended to a capped stream buffer once any * original characters have been dropped from it. Reconstructing the prior * tail (see `capAppendStream` below) always slices this exact computed * length off the front of the previous displayed value — it never scans * buffer content for marker-shaped text, so real streamed content that * happens to look like a marker can never be mistaken for one. */ function formatTruncationMarker(dropped: number): string { return `…[truncated ${dropped} characters]…`; } interface CappedStream { /** The full value to store/display — a plain tail when nothing has been * dropped yet, otherwise the marker followed by the retained tail. Always * bounded to at most `max` characters in total. */ displayed: string; /** Total original stream characters dropped so far, cumulative across * every append — never reset while the buffer is still accumulating. */ dropped: number; } /** Appends `addition` to a stream buffer capped at `max` displayed * characters (marker included), honestly disclosing how many original * characters have been dropped so far rather than silently retaining only * the tail. `priorDisplayed`/`priorDropped` come from state; the marker (if * any) already present in `priorDisplayed` is stripped by the exact length * `formatTruncationMarker(priorDropped)` computes, not by pattern-matching. */ function capAppendStream( priorDisplayed: string, priorDropped: number, addition: string, max: number, ): CappedStream { const priorTail = priorDropped > 0 ? priorDisplayed.slice(formatTruncationMarker(priorDropped).length) : priorDisplayed; const combinedTail = priorTail + addition; if (priorDropped === 0 && combinedTail.length <= max) { return { displayed: combinedTail, dropped: 0 }; } // The marker's own text grows (rarely) as `dropped`'s digit count grows, // which shrinks the budget left for the tail, which can in turn increase // `dropped` further — a handful of iterations is always enough to reach a // fixed point for any realistic character count. let dropped = priorDropped; for (let i = 0; i < 8; i += 1) { const budget = Math.max(0, max - formatTruncationMarker(dropped).length); const nextDropped = priorDropped + Math.max(0, combinedTail.length - budget); if (nextDropped === dropped) break; dropped = nextDropped; } const marker = formatTruncationMarker(dropped); const budget = Math.max(0, max - marker.length); const tail = combinedTail.slice(Math.max(0, combinedTail.length - budget)); return { displayed: marker + tail, dropped }; } /** Pushes `item` onto `arr`, dropping the oldest entries once `max` is exceeded. */ function capPush(arr: T[], item: T, max: number): T[] { const next = [...arr, item]; return next.length > max ? next.slice(next.length - max) : next; } /** A valid `toolCallId` from the wire is a non-empty string — a malformed or * absent one (non-string, or empty string) must never be treated as if it * named a real tool call. */ function isValidToolCallId(value: unknown): value is string { return typeof value === 'string' && value.length > 0; } /** Shown when a scoped `error`/`agent:end` cannot be attributed to any * conversation (a malformed/missing conversationId) while a send is still * pending and no conversation has ever been established. There is no valid * identity left to recover the turn under, so it is surfaced as a terminal * startup failure instead of leaving `sending`/`pendingSend` stuck forever. */ const CONVERSATION_START_FAILURE = 'Unable to start this conversation. Please try again.'; /** Shown when the non-evicting executed-approval dedup cache is already at * MAX_EXECUTED_APPROVAL_IDS and a distinct, never-before-seen approval is * denied as a result (see `runApprovedCommand`'s saturation branch below). * The approval is still consumed (the UI lock is released) but the user * must see why the command did not run rather than have it silently * dropped. */ const APPROVAL_LIMIT_MESSAGE = 'Approval limit reached for this session. This command was not run.'; /** Fixed, browser-safe notice surfaced when the harness runtime rejects a turn * (`turn:ack` with `ok:false`). It is deliberately generic: the raw server * `code`/`message`/`error` can carry adapter internals or entropy-source detail, * so no rejection ever leaks its cause into the UI — every distinct rejection * shows this same string. */ const TURN_REJECTED_NOTICE = 'This turn could not be sent. Please try again.'; /** Fixed, browser-safe notice surfaced when a turn is refused because the * idempotency-key mint failed closed (`crypto.randomUUID` absent or throwing). * Like {@link TURN_REJECTED_NOTICE}, it never carries the thrown message. */ const IDEMPOTENCY_UNAVAILABLE_NOTICE = 'This turn could not be sent. Please try again.'; /** The single fixed, browser-safe notice surfaced (with safe code * `send_protocol_unavailable`) when a send is attempted on a connection whose * negotiated send protocol is `unavailable` — the server never advertised a * usable `chat:send-capability`, advertised `unavailable` (e.g. a pi-rpc runtime * in this slice), or the advertisement was rejected (wrong connection id, replay, * or an unknown protocol). It carries no dynamic detail. */ const SEND_PROTOCOL_UNAVAILABLE_NOTICE = 'Chat sending is unavailable on this connection.'; /** Mints a single idempotency key for one accepted `turn:send`, fail-closed. * Returns a fresh RFC-4122 UUID from `crypto.randomUUID`, or `null` when that * source is absent (not a function) or throws — the caller then refuses the turn * rather than falling back to any non-cryptographic source (Math.random, a * clock, or a counter would all be forgeable/collision-prone). Never throws. */ function mintIdempotencyKey(): string | null { try { const c: unknown = globalThis.crypto; if (!isRecord(c) || typeof c.randomUUID !== 'function') return null; const key = (c.randomUUID as () => unknown)(); return typeof key === 'string' && key.length > 0 ? key : null; } catch { return null; } } /** True only for the narrow case a malformed-conversationId `error`/`agent:end` * must be treated as a terminal startup failure: no conversation has ever been * established yet, and a send is still pending one. Once a conversation is * active, or when no send is pending, an unattributable event stays a no-op — * dropping it (rather than guessing which turn it belongs to) is what lets a * later genuinely valid event still recover the turn. */ function isUnrecoverableStartupFailure(state: ChatConnectionState): boolean { return state.conversationId === null && state.pendingSend; } /** Deterministic, unique-per-event fallback id for a malformed `toolCallId`. * Sourced from a monotonically increasing counter carried in state (`toolSeq`) * rather than `tools.length`, so it stays collision-free even once `tools` is * capped at MAX_TOOLS and its length stops changing. */ function nextToolFallbackId(toolSeq: number): string { return `unknown-tool-call-${toolSeq}`; } /** Truncates a server-provided collection to at most `max` entries. `arr` is * `unknown` because it comes straight from a raw socket payload — a * malformed/compromised gateway can send `null` or any non-array value here, * which must normalize to an empty list rather than throw. */ function capList(arr: unknown, max: number): T[] { if (!Array.isArray(arr)) return []; return (arr.length > max ? arr.slice(0, max) : arr) as T[]; } /** Like `capList`, but for a server-provided string collection (e.g. * `system:reload.providers`): a non-string entry is dropped rather than * invalidating the whole list, and the result is capped at `max`. */ function capStringList(arr: unknown, max: number): string[] { if (!Array.isArray(arr)) return []; const strings = arr.filter((item): item is string => typeof item === 'string'); return strings.length > max ? strings.slice(0, max) : strings; } /** Normalizes a raw message:ack payload before it is stored — `messageId` is * given a stable, visible "unknown" fallback rather than storing a raw * non-string value (which would either render as "[object Object]" or, * with an unguarded render site, throw). */ function sanitizeAck(payload: MessageAckPayload, conversationId: string): MessageAckPayload { return { conversationId, messageId: asString(payload.messageId, 'unknown') }; } /** Normalizes a raw command:approval payload before it is stored. A command * string matching `pendingApproval.command` alone is not sufficient proof of * a genuine, executable approval: `success` must be the literal boolean * `true`, and a successful approval requires a non-empty string * `approvalId`. Anything else is normalized to a denial (`success: false`, * no `approvalId`) so the pending request resolves and the approve lock is * released instead of leaving the UI hung on a response it can never trust * enough to enable "Run approved command" for. `expiresAt`/`message` are * retained only when they are strings (message is never rendered as-is). */ function sanitizeApproval( payload: SlashCommandApprovalResultPayload, conversationId: string, command: string, ): SlashCommandApprovalResultPayload { const approvalId = typeof payload.approvalId === 'string' && payload.approvalId.length > 0 ? payload.approvalId : undefined; const success = payload.success === true && approvalId !== undefined; return { conversationId, command, success, approvalId: success ? approvalId : undefined, expiresAt: typeof payload.expiresAt === 'string' ? payload.expiresAt : undefined, message: typeof payload.message === 'string' ? payload.message : undefined, }; } /** Normalizes a raw command:result payload before it is stored — `success` * is coerced to a literal boolean so a truthy non-boolean value (e.g. an * object) can never be displayed as a successful result, and a curated * `message` is bounded to MAX_COMMAND_MESSAGE_CHARS so a hostile/malformed * gateway cannot push an unbounded string into state (the render site in * commands-panel.tsx applies the same bound again as defense-in-depth). */ function sanitizeCommandResult( payload: SlashCommandResultPayload, conversationId: string, ): SlashCommandResultPayload { return { conversationId, command: asString(payload.command, 'unknown'), success: payload.success === true, message: typeof payload.message === 'string' ? payload.message.slice(0, MAX_COMMAND_MESSAGE_CHARS) : undefined, }; } export interface ChatTranscriptMessage { id: string; role: 'user' | 'assistant'; text: string; thinking?: string; } export interface PendingApproval { command: string; args?: string; } /** Receipt captured from an accepted harness `turn:ack` — the minimal record proving the * server accepted this exact turn under its minted idempotency key and selection tuple. */ export interface HarnessTurnReceipt { idempotencyKey: string; receiptId: string; selection: HarnessSelection; } export interface ChatConnectionState { conversationId: string | null; /** True once a message has been sent while no conversation is active yet, so the * first scoped server event naming a conversation may establish it (the gateway * does not guarantee message:ack is the first event for a new conversation). */ pendingSend: boolean; /** True from the moment a message is sent until its turn ends (agent:end), * errors, or the connection drops — guards against a second turn starting * while one is already in flight. */ sending: boolean; ack: MessageAckPayload | null; streaming: boolean; text: string; /** Total original `agent:text` characters dropped so far by the * MAX_STREAM_CHARS cap on `text` — tracked separately from `text` itself * so the honest cumulative count survives across multiple appends without * re-parsing any marker embedded in the displayed string. */ textDroppedChars: number; thinking: string; /** Same accounting as `textDroppedChars`, for `thinking`. */ thinkingDroppedChars: number; tools: ToolCallState[]; usage: SessionUsagePayload | null; sessionInfo: SessionInfoPayload | null; manifest: CommandManifest | null; commandResults: SlashCommandResultPayload[]; approval: SlashCommandApprovalResultPayload | null; pendingApproval: PendingApproval | null; /** True while an approval request has been sent and no response has arrived yet. */ approvalRequestPending: boolean; systemReload: SystemReloadPayload | null; error: string | null; /** How this connection is currently permitted to send, negotiated via the * server-to-client-only `chat:send-capability` advertisement. Starts and resets * to `'unavailable'` on every (re)connect and disconnect — a fresh or dropped * connection has no usable protocol until the server (re-)advertises. This is * the reactive/UI mirror of the synchronous `protocolRef` that `sendMessage` * actually reads; the ref is authoritative because an advertisement and a send * can occur in the same tick before React re-renders. */ sendProtocol: ChatSendProtocol; /** Receipt from the most recently accepted harness `turn:ack`, or null before any * turn has been accepted. A rejected turn:ack surfaces via `error` and leaves this * untouched (a prior accepted receipt is not erased by a later rejection). */ turnReceipt: HarnessTurnReceipt | null; messages: ChatTranscriptMessage[]; /** Monotonically increasing counter used to mint transcript message ids — * never reset while retained messages remain, so ids stay unique across the * MAX_MESSAGES cap boundary (unlike a `messages.length`-derived id, which * plateaus once the array is capped). */ messageSeq: number; /** Monotonically increasing counter used to mint fallback tool-call ids for * malformed (non-string/empty) `toolCallId`s — never reset while retained * tools remain, so fallback ids stay unique across the MAX_TOOLS cap * boundary. */ toolSeq: number; /** Explicit per-turn phase for the CURRENT (most recently sent) turn. Wire * payloads (agent:end/error) carry only a conversationId, not a * turn-scoped correlation id — two different turns on the SAME * conversation are indistinguishable on the wire by conversationId alone, * so this local phase is what actually tells them apart: * * - 'pending': minted by `local/send`; the turn has been sent but has not * yet received its own `agent:start`. A same-conversation `agent:end` * arriving in this phase cannot be trusted as this turn's own — Socket.IO * delivers in order and the Gateway always emits `agent:start` before a * legitimate `agent:end`, so this can only be a stale/duplicate end left * over from an earlier turn (a `message:ack` alone does NOT advance this * phase, precisely to close that hole). A same-conversation `error`, * however, IS trusted in this phase: the Gateway can legitimately emit a * session-creation error before ack/start, and once an earlier turn on * this conversation has already fully settled (via its own terminal * event) ordered delivery guarantees nothing further remains in flight * for it — so any later scoped error can only belong to this turn. * - 'active': set only by this turn's own accepted `agent:start`. Only * from this phase may a scoped `agent:end` settle the turn. * - 'settled': set once this turn's own terminal (`agent:end` or `error`) * has been processed. A further scoped `agent:end`/`error` in this phase * is a true no-op — it must not re-finalize or re-release anything. * * Fails closed: `agent:end` can only ever settle from 'active'. */ turnPhase: 'pending' | 'active' | 'settled'; } export interface ChatConnectionActions { sendMessage: (input: { content: string; selection?: HarnessSelection }) => boolean; abort: () => void; setThinking: (level: string) => void; executeCommand: (input: { command: string; args?: string }) => void; approveCommand: (input: { command: string; args?: string }) => void; runApprovedCommand: () => void; } export interface ChatConnectionValue { state: ChatConnectionState; actions: ChatConnectionActions; } const initialState: ChatConnectionState = { conversationId: null, pendingSend: false, sending: false, ack: null, streaming: false, text: '', textDroppedChars: 0, thinking: '', thinkingDroppedChars: 0, tools: [], usage: null, sessionInfo: null, manifest: null, commandResults: [], approval: null, pendingApproval: null, approvalRequestPending: false, systemReload: null, error: null, sendProtocol: 'unavailable', turnReceipt: null, messages: [], messageSeq: 0, toolSeq: 0, turnPhase: 'settled', }; type Action = | { type: 'server/message:ack'; payload: MessageAckPayload } | { type: 'server/agent:start'; payload: AgentStartPayload } | { type: 'server/agent:text'; payload: AgentTextPayload } | { type: 'server/agent:thinking'; payload: AgentThinkingPayload } | { type: 'server/agent:tool:start'; payload: ToolStartPayload } | { type: 'server/agent:tool:end'; payload: ToolEndPayload } | { type: 'server/agent:end'; payload: AgentEndPayload } | { type: 'server/session:info'; payload: SessionInfoPayload } | { type: 'server/commands:manifest'; payload: CommandManifestPayload } | { type: 'server/command:result'; payload: SlashCommandResultPayload } | { type: 'server/command:approval'; payload: SlashCommandApprovalResultPayload } | { type: 'server/system:reload'; payload: SystemReloadPayload } | { type: 'server/error'; payload: ErrorPayload } | { type: 'server/turn:ack'; payload: HarnessTurnAckPayload } | { type: 'local/send'; content: string } | { type: 'local/capability'; protocol: ChatSendProtocol } | { type: 'local/reset-protocol' } | { type: 'local/send-unavailable' } | { type: 'local/turn-idempotency-unavailable' } | { type: 'local/approve-request'; command: string; args?: string } | { type: 'local/consume-approval' } | { type: 'local/approval-saturated' } | { type: 'local/disconnect' }; /** * Resolves whether a scoped server event (one carrying a conversationId) belongs to * the active conversation. The gateway does not guarantee message:ack is the first * event for a new conversation (session:info, and error on auth/session-creation * failure, can both arrive first) — so while a message is pending and no conversation * is active yet, the first scoped event names the conversation instead of being * dropped. Once a conversation is active, only its own events pass. * * `rawConversationId` is `unknown`, not `string` — every payload is runtime-untrusted * regardless of its compile-time contract, and this is the single place that may * establish `state.conversationId`. A malformed value (non-string/empty) is rejected * via the central `asConversationId` guard and never adopted: the event is treated as * inactive/dropped, leaving state (including `pendingSend`) untouched so a later * genuinely valid event can still establish or recover the turn. */ function resolveScopedConversation( state: ChatConnectionState, rawConversationId: unknown, ): { active: true; state: ChatConnectionState } | { active: false; state: null } { const conversationId = asConversationId(rawConversationId); if (conversationId === null) { return { active: false, state: null }; } if (state.conversationId === conversationId) { return { active: true, state }; } if (state.conversationId === null && state.pendingSend) { // The very first scoped event for a brand-new conversation can only belong // to the currently in-flight turn — no prior same-conversation turn exists to // be confused with. This only establishes conversation identity; it does not // advance `turnPhase` — only this turn's own `agent:start` may do that (see // the 'server/agent:start' case), and only this turn's own `error` may settle // it directly from 'pending' (see the 'server/error' case). return { active: true, state: { ...state, conversationId, pendingSend: false }, }; } return { active: false, state: null }; } type ServerAction = Extract; function isServerAction(action: Action): action is ServerAction { return action.type.startsWith('server/'); } function reduce(state: ChatConnectionState, action: Action): ChatConnectionState { // A malformed packet (the whole payload is null/undefined/a primitive, not // an object) is ignored outright rather than crashing the reducer on the // first field dereference in the case below — every server/* action shape // declares a `payload` field, so this guard covers all of them uniformly. if (isServerAction(action) && !isRecord(action.payload)) { return state; } switch (action.type) { case 'server/message:ack': { const { payload } = action; if (state.conversationId === null) { const conversationId = asConversationId(payload.conversationId); // A malformed first frame (non-string/empty conversationId) is ignored // outright rather than adopted — adopting it would permanently // desynchronize every later scoped event's strict-equality check // against a value that can never again match. Leaving state untouched // (conversationId stays null) lets a later genuinely valid ack/start // still establish the turn. if (conversationId === null) return state; return { ...state, conversationId, pendingSend: false, ack: sanitizeAck(payload, conversationId), // Deliberately does NOT advance `turnPhase` — an ack alone must // never be enough to make a later `agent:end` settle this turn. // Only this turn's own `agent:start` may do that. }; } if (payload.conversationId !== state.conversationId) return state; return { ...state, ack: sanitizeAck(payload, state.conversationId), }; } case 'server/agent:start': { const resolved = resolveScopedConversation(state, action.payload.conversationId); if (!resolved.active) return state; return { ...resolved.state, streaming: true, text: '', thinking: '', textDroppedChars: 0, thinkingDroppedChars: 0, tools: [], error: null, // Only this turn's own accepted agent:start may move it into the // 'active' phase that a real agent:end may later settle. turnPhase: 'active', }; } case 'server/agent:text': { const resolved = resolveScopedConversation(state, action.payload.conversationId); if (!resolved.active) return state; const capped = capAppendStream( resolved.state.text, resolved.state.textDroppedChars, asString(action.payload.text), MAX_STREAM_CHARS, ); return { ...resolved.state, text: capped.displayed, textDroppedChars: capped.dropped }; } case 'server/agent:thinking': { const resolved = resolveScopedConversation(state, action.payload.conversationId); if (!resolved.active) return state; const capped = capAppendStream( resolved.state.thinking, resolved.state.thinkingDroppedChars, asString(action.payload.text), MAX_STREAM_CHARS, ); return { ...resolved.state, thinking: capped.displayed, thinkingDroppedChars: capped.dropped, }; } case 'server/agent:tool:start': { const { payload } = action; const resolved = resolveScopedConversation(state, payload.conversationId); if (!resolved.active) return state; const valid = isValidToolCallId(payload.toolCallId); const toolSeq = resolved.state.toolSeq; const tool: ToolCallState = { // Each malformed toolCallId gets its own fresh fallback id (never the // fixed 'unknown-tool-call' constant) so distinct malformed starts // never collide with each other. toolCallId: valid ? payload.toolCallId : nextToolFallbackId(toolSeq), toolName: asString(payload.toolName, 'Unknown tool'), status: 'running', }; return { ...resolved.state, tools: capPush(resolved.state.tools, tool, MAX_TOOLS), toolSeq: valid ? toolSeq : toolSeq + 1, }; } case 'server/agent:tool:end': { const { payload } = action; const resolved = resolveScopedConversation(state, payload.conversationId); if (!resolved.active) return state; const valid = isValidToolCallId(payload.toolCallId); const toolSeq = resolved.state.toolSeq; const toolCallId = valid ? payload.toolCallId : nextToolFallbackId(toolSeq); const toolName = asString(payload.toolName, 'Unknown tool'); const nextToolSeq = valid ? toolSeq : toolSeq + 1; // A fresh fallback id (malformed toolCallId) can never match an // existing entry, so this only ever finds a genuine prior tool:start. const matchIndex = resolved.state.tools.findIndex((tool) => tool.toolCallId === toolCallId); if (matchIndex === -1) { // A tool:end for an ID we never saw a tool:start for is a protocol // anomaly, not a no-op — surface it visibly instead of silently // dropping it. const anomaly: ToolCallState = { toolCallId, toolName, status: 'anomaly' }; return { ...resolved.state, tools: capPush(resolved.state.tools, anomaly, MAX_TOOLS), toolSeq: nextToolSeq, }; } // Update at most the first matching entry — if `toolCallId` is // (unexpectedly) shared by more than one retained tool, updating every // match would cross-contaminate unrelated statuses (and could silently // overwrite a distinct entry's 'anomaly' status). return { ...resolved.state, tools: resolved.state.tools.map((tool, index) => index === matchIndex ? { ...tool, status: payload.isError ? 'error' : 'success' } : tool, ), toolSeq: nextToolSeq, }; } case 'server/agent:end': { const { payload } = action; const resolved = resolveScopedConversation(state, payload.conversationId); if (!resolved.active) { if (isUnrecoverableStartupFailure(state)) { return { ...state, pendingSend: false, sending: false, streaming: false, approvalRequestPending: false, turnPhase: 'settled', error: CONVERSATION_START_FAILURE, }; } return state; } const next = resolved.state; if (next.turnPhase !== 'active') { // Only an 'active' turn (one that has received its own agent:start) // may be settled by an agent:end. A 'pending' phase means this // turn has not started yet — Socket.IO delivers in order and the // Gateway always emits agent:start before a legitimate agent:end, so // this can only be a stale/duplicate end left over from an earlier // turn. A 'settled' phase means this turn's own end already fired — // this is a redelivered duplicate. Either way this must be a true // no-op: it must not finalize leftover text/thinking into a message // or reset streaming, both of which legitimately belong to the // still in-flight (or already-finalized) current turn. return next; } // Reaching here means turnPhase is 'active' — but AgentEndPayload // carries only a conversationId, no turn/message identity. In-order // delivery proves the boundary enforced above (a same-conversation end // arriving before this turn's own agent:start is provably stale); once // 'active', a genuine end for this turn and a same-conversation // stale/duplicate end are indistinguishable here. This residual is // accepted — full correlation requires a wire turnId, deferred to P5. const hasContent = next.text.length > 0 || next.thinking.length > 0; const messages = hasContent ? capPush( next.messages, { // Sourced from the reducer-owned `messageSeq` counter, not // `messages.length` — the latter plateaus once MAX_MESSAGES is // reached under a flood, producing duplicate ids/React keys. id: `assistant-${payload.conversationId}-${next.messageSeq}`, role: 'assistant' as const, text: next.text, thinking: next.thinking || undefined, }, MAX_MESSAGES, ) : next.messages; return { ...next, streaming: false, sending: false, turnPhase: 'settled', text: '', thinking: '', textDroppedChars: 0, thinkingDroppedChars: 0, usage: payload.usage ?? next.usage, messages, messageSeq: hasContent ? next.messageSeq + 1 : next.messageSeq, }; } case 'server/session:info': { const { payload } = action; const resolved = resolveScopedConversation(state, payload.conversationId); if (!resolved.active) return state; return { ...resolved.state, sessionInfo: { ...payload, // A hostile/malfunctioning gateway can flood this list; cap it // before it ever reaches state so a render site (e.g. the // thinking-level