/* eslint-disable @typescript-eslint/no-explicit-any */ import { create } from "zustand"; import { devtools, persist, createJSONStorage } from "zustand/middleware"; import type { Message, Part } from "@opencode-ai/sdk"; import { opencodeClient } from "@/lib/opencode/client"; import { isExecutionForkMetaText } from "@/lib/messages/executionMeta"; import type { SessionMemoryState, MessageStreamLifecycle, AttachedFile } from "./types/sessionTypes"; import { MEMORY_LIMITS } from "./types/sessionTypes"; import { touchStreamingLifecycle, removeLifecycleEntries, clearLifecycleTimersForIds, clearLifecycleCompletionTimer } from "./utils/streamingUtils"; import { extractTextFromPart, normalizeStreamingPart } from "./utils/messageUtils"; import { getSafeStorage } from "./utils/safeStorage"; import { useFileStore } from "./fileStore"; import { useSessionStore } from "./sessionStore"; import { useContextStore } from "./contextStore"; // Helper function to clean up pending user message metadata const cleanupPendingUserMessageMeta = ( currentPending: Map, sessionId: string ): Map => { const nextPending = new Map(currentPending); nextPending.delete(sessionId); return nextPending; }; interface QueuedPart { sessionId: string; messageId: string; part: Part; role?: string; currentSessionId?: string; } let batchQueue: QueuedPart[] = []; let flushTimer: ReturnType | null = null; const USER_BATCH_WINDOW_MS = 50; const COMPACTION_WINDOW_MS = 30_000; const timeoutRegistry = new Map>(); const lastContentRegistry = new Map(); const streamingCooldownTimers = new Map>(); const MIN_SORTABLE_LENGTH = 10; const extractSortableId = (id: unknown): string | null => { if (typeof id !== "string") { return null; } const trimmed = id.trim(); if (!trimmed) { return null; } const underscoreIndex = trimmed.indexOf("_"); const candidate = underscoreIndex >= 0 ? trimmed.slice(underscoreIndex + 1) : trimmed; if (!candidate || candidate.length < MIN_SORTABLE_LENGTH) { return null; } return candidate; }; const isIdNewer = (id: string, referenceId: string): boolean => { const currentSortable = extractSortableId(id); const referenceSortable = extractSortableId(referenceId); if (!currentSortable || !referenceSortable) { return true; } if (currentSortable.length !== referenceSortable.length) { return true; } return currentSortable > referenceSortable; }; const streamDebugEnabled = (): boolean => { if (typeof window === "undefined") return false; try { return window.localStorage.getItem("openchamber_stream_debug") === "1"; } catch { return false; } }; const computePartsTextLength = (parts: Part[] | undefined): number => { if (!Array.isArray(parts) || parts.length === 0) { return 0; } return parts.reduce((sum, part) => { if (!part || part.type !== "text") { return sum; } const content = (part as Record).text ?? (part as Record).content; if (typeof content === "string") { return sum + content.length; } return sum; }, 0); }; const hasStopReasonStop = (parts: Part[] | undefined): boolean => { if (!Array.isArray(parts)) { return false; } return parts.some( (part) => part?.type === "step-finish" && (part as Record)?.reason === "stop" ); }; const getPartKey = (part: Part | undefined): string | undefined => { if (!part) { return undefined; } if (typeof part.id === "string" && part.id.length > 0) { return part.id; } if (part.type) { const reason = (part as Record).reason; const callId = (part as Record).callID; return `${part.type}-${reason ?? ""}-${callId ?? ""}`; } return undefined; }; const ignoredAssistantMessageIds = new Set(); const mergePreferExistingParts = (existing: Part[] = [], incoming: Part[] = []): Part[] => { if (!incoming.length) { return [...existing]; } const merged = [...existing]; const existingKeys = new Set(existing.map(getPartKey).filter((key): key is string => Boolean(key))); incoming.forEach((part) => { if (!part) { return; } const key = getPartKey(part); if (key && existingKeys.has(key)) { return; } merged.push(part); if (key) { existingKeys.add(key); } }); return merged; }; const mergeDuplicateMessage = ( existing: { info: any; parts: Part[] }, incoming: { info: any; parts: Part[] } ): { info: any; parts: Part[] } => { const existingParts = Array.isArray(existing.parts) ? existing.parts : []; const incomingParts = Array.isArray(incoming.parts) ? incoming.parts : []; const existingLen = computePartsTextLength(existingParts); const incomingLen = computePartsTextLength(incomingParts); const existingStop = hasStopReasonStop(existingParts); const incomingStop = hasStopReasonStop(incomingParts); let parts = incomingParts; if (existingStop && existingLen >= incomingLen) { parts = mergePreferExistingParts(existingParts, incomingParts); } else if (incomingStop && incomingLen >= existingLen) { parts = mergePreferExistingParts(incomingParts, existingParts); } else if (existingLen >= incomingLen) { parts = existingParts; } return { ...incoming, info: { ...existing.info, ...incoming.info, }, parts, }; }; const dedupeMessagesById = (messages: { info: any; parts: Part[] }[]) => { const deduped: { info: any; parts: Part[] }[] = []; const indexById = new Map(); for (const message of messages) { const messageId = typeof message?.info?.id === "string" ? message.info.id : null; if (!messageId) { deduped.push(message); continue; } const existingIndex = indexById.get(messageId); if (existingIndex === undefined) { indexById.set(messageId, deduped.length); deduped.push(message); continue; } deduped[existingIndex] = mergeDuplicateMessage(deduped[existingIndex], message); } return deduped; }; const computeMaxTrimmedHeadId = (removed: Array<{ info: any }>, previous?: string): string | undefined => { let maxId = previous; let maxSortable = previous ? extractSortableId(previous) : null; for (const entry of removed) { const candidateId = entry?.info?.id; const candidateSortable = extractSortableId(candidateId); if (!candidateId || !candidateSortable) { continue; } if (!maxSortable || candidateSortable > maxSortable) { maxSortable = candidateSortable; maxId = candidateId; } } return maxId; }; const setStreamingIdForSession = (source: Map, sessionId: string, messageId: string | null) => { const existing = source.get(sessionId); if (existing === messageId) { return source; } const next = new Map(source); if (messageId) { next.set(sessionId, messageId); } else { next.delete(sessionId); } return next; }; const upsertMessageSessionIndex = (source: Map, messageId: string, sessionId: string) => { const existing = source.get(messageId); if (existing === sessionId) { return source; } const next = new Map(source); next.set(messageId, sessionId); return next; }; const removeMessageSessionIndexEntries = (source: Map, ids: Iterable) => { const next = new Map(source); let mutated = false; for (const id of ids) { if (next.delete(id)) { mutated = true; } } return mutated ? next : source; }; const collectActiveMessageIdsForSession = (state: MessageState, sessionId: string): Set => { const ids = new Set(); const latest = state.streamingMessageIds.get(sessionId); if (latest) { ids.add(latest); } state.messageStreamStates.forEach((_lifecycle, messageId) => { if (state.messageSessionIndex.get(messageId) === sessionId) { ids.add(messageId); } }); return ids; }; const isMessageStreamingInSession = (state: MessageState, sessionId: string, messageId: string) => { if (state.streamingMessageIds.get(sessionId) === messageId) { return true; } return state.messageSessionIndex.get(messageId) === sessionId && state.messageStreamStates.has(messageId); }; const resolveSessionDirectory = async (sessionId: string | null | undefined): Promise => { if (!sessionId) { return undefined; } try { const sessionStore = useSessionStore.getState(); const metadata = sessionStore.getWorktreeMetadata(sessionId); if (metadata?.path) { return metadata.path; } const session = sessionStore.sessions.find((entry) => entry.id === sessionId) as { directory?: string } | undefined; const sessionDirectory = typeof session?.directory === 'string' && session.directory.length > 0 ? session.directory : undefined; return sessionDirectory; } catch (error) { console.warn('Failed to resolve session directory override:', error); return undefined; } }; const getSessionRevertMessageId = (sessionId: string | null | undefined): string | null => { if (!sessionId) return null; try { const sessionStore = useSessionStore.getState(); const session = sessionStore.sessions.find((entry) => entry.id === sessionId) as { revert?: { messageID?: string } } | undefined; return session?.revert?.messageID ?? null; } catch { return null; } }; const filterRevertedMessages = ( messages: { info: Message; parts: Part[] }[], revertMessageId: string | null ): { info: Message; parts: Part[] }[] => { if (!revertMessageId) return messages; const revertIndex = messages.findIndex((m) => m.info.id === revertMessageId); if (revertIndex === -1) return messages; // Keep only messages before the revert point (exclusive) return messages.slice(0, revertIndex); }; const executeWithSessionDirectory = async (sessionId: string | null | undefined, operation: () => Promise): Promise => { const directoryOverride = await resolveSessionDirectory(sessionId); if (directoryOverride) { return opencodeClient.withDirectory(directoryOverride, operation); } return operation(); }; interface SessionAbortRecord { timestamp: number; acknowledged: boolean; } interface MessageState { messages: Map; sessionMemoryState: Map; messageStreamStates: Map; messageSessionIndex: Map; streamingMessageIds: Map; abortControllers: Map; lastUsedProvider: { providerID: string; modelID: string } | null; isSyncing: boolean; pendingAssistantParts: Map; sessionCompactionUntil: Map; sessionAbortFlags: Map; pendingAssistantHeaderSessions: Set; pendingUserMessageMetaBySession: Map; } interface MessageActions { loadMessages: (sessionId: string) => Promise; sendMessage: (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null) => Promise; abortCurrentOperation: (currentSessionId?: string) => Promise; _addStreamingPartImmediate: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => void; addStreamingPart: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => void; forceCompleteMessage: (sessionId: string | null | undefined, messageId: string, source?: "timeout" | "cooldown") => void; completeStreamingMessage: (sessionId: string, messageId: string) => void; markMessageStreamSettled: (messageId: string) => void; updateMessageInfo: (sessionId: string, messageId: string, messageInfo: any) => void; syncMessages: (sessionId: string, messages: { info: Message; parts: Part[] }[]) => void; updateViewportAnchor: (sessionId: string, anchor: number) => void; trimToViewportWindow: (sessionId: string, targetSize?: number, currentSessionId?: string) => void; evictLeastRecentlyUsed: (currentSessionId?: string) => void; loadMoreMessages: (sessionId: string, direction: "up" | "down") => Promise; getLastMessageModel: (sessionId: string) => { providerID?: string; modelID?: string } | null; updateSessionCompaction: (sessionId: string, compactingTimestamp: number | null | undefined) => void; acknowledgeSessionAbort: (sessionId: string) => void; } type MessageStore = MessageState & MessageActions; export const useMessageStore = create()( devtools( persist( (set, get) => ({ messages: new Map(), sessionMemoryState: new Map(), messageStreamStates: new Map(), messageSessionIndex: new Map(), streamingMessageIds: new Map(), abortControllers: new Map(), lastUsedProvider: null, isSyncing: false, pendingAssistantParts: new Map(), sessionCompactionUntil: new Map(), sessionAbortFlags: new Map(), pendingAssistantHeaderSessions: new Set(), pendingUserMessageMetaBySession: new Map(), loadMessages: async (sessionId: string, limit: number = MEMORY_LIMITS.VIEWPORT_MESSAGES) => { const allMessages = await executeWithSessionDirectory(sessionId, () => opencodeClient.getSessionMessages(sessionId)); // Filter out reverted messages first const revertMessageId = getSessionRevertMessageId(sessionId); const messagesWithoutReverted = filterRevertedMessages(allMessages, revertMessageId); const watermark = get().sessionMemoryState.get(sessionId)?.trimmedHeadMaxId; const afterWatermark = watermark ? messagesWithoutReverted.filter((message) => { const messageId = message?.info?.id; if (!messageId) return true; return isIdNewer(messageId, watermark); }) : messagesWithoutReverted; const messagesToKeep = afterWatermark.slice(-limit); set((state) => { const newMessages = new Map(state.messages); const previousMessages = state.messages.get(sessionId) || []; const previousMessagesById = new Map( previousMessages .filter((msg) => typeof msg.info?.id === "string") .map((msg) => [msg.info.id as string, msg]) ); const normalizedMessages = messagesToKeep.map((message) => { const infoWithMarker = { ...message.info, clientRole: (message.info as any)?.clientRole ?? message.info.role, userMessageMarker: message.info.role === "user" ? true : (message.info as any)?.userMessageMarker, } as any; const serverParts = (Array.isArray(message.parts) ? message.parts : []).map((part) => { if (part?.type === 'text') { const raw = (part as any).text ?? (part as any).content ?? ''; if (isExecutionForkMetaText(raw)) { return { ...part, synthetic: true } as Part; } } return part; }); const existingEntry = infoWithMarker?.id ? previousMessagesById.get(infoWithMarker.id as string) : undefined; if ( existingEntry && existingEntry.info.role === "assistant" ) { const existingParts = Array.isArray(existingEntry.parts) ? existingEntry.parts : []; const existingLen = computePartsTextLength(existingParts); const serverLen = computePartsTextLength(serverParts); const storeHasStop = hasStopReasonStop(existingParts); if (storeHasStop && existingLen > serverLen) { const mergedParts = mergePreferExistingParts(existingParts, serverParts); return { ...message, info: infoWithMarker, parts: mergedParts, }; } } return { ...message, info: infoWithMarker, parts: serverParts, }; }); const mergedMessages = dedupeMessagesById(normalizedMessages); const previousIds = new Set(previousMessages.map((msg) => msg.info.id)); const nextIds = new Set(mergedMessages.map((msg) => msg.info.id)); const removedIds: string[] = []; previousIds.forEach((id) => { if (!nextIds.has(id)) { removedIds.push(id); } }); newMessages.set(sessionId, mergedMessages); const newMemoryState = new Map(state.sessionMemoryState); const previousMemoryState = state.sessionMemoryState.get(sessionId); newMemoryState.set(sessionId, { viewportAnchor: mergedMessages.length - 1, isStreaming: false, lastAccessedAt: Date.now(), backgroundMessageCount: 0, totalAvailableMessages: allMessages.length, hasMoreAbove: allMessages.length > messagesToKeep.length, trimmedHeadMaxId: previousMemoryState?.trimmedHeadMaxId, streamingCooldownUntil: undefined, }); const result: Record = { messages: newMessages, sessionMemoryState: newMemoryState, }; clearLifecycleTimersForIds(removedIds); const updatedLifecycle = removeLifecycleEntries(state.messageStreamStates, removedIds); if (updatedLifecycle !== state.messageStreamStates) { result.messageStreamStates = updatedLifecycle; } if (removedIds.length > 0) { const currentStreaming = state.streamingMessageIds.get(sessionId); if (currentStreaming && removedIds.includes(currentStreaming)) { result.streamingMessageIds = setStreamingIdForSession( result.streamingMessageIds ?? state.streamingMessageIds, sessionId, null ); } } if (removedIds.length > 0) { const nextIndex = removeMessageSessionIndexEntries( result.messageSessionIndex ?? state.messageSessionIndex, removedIds ); if (nextIndex !== (result.messageSessionIndex ?? state.messageSessionIndex)) { result.messageSessionIndex = nextIndex; } } if (removedIds.length > 0) { const nextPendingParts = new Map(state.pendingAssistantParts); let pendingChanged = false; removedIds.forEach((id) => { if (nextPendingParts.delete(id)) { pendingChanged = true; } }); if (pendingChanged) { result.pendingAssistantParts = nextPendingParts; } } const targetIndex = result.messageSessionIndex ?? state.messageSessionIndex; let indexAccumulator = targetIndex; mergedMessages.forEach((message) => { const id = (message?.info as { id?: unknown })?.id; if (typeof id === "string" && id.length > 0) { indexAccumulator = upsertMessageSessionIndex(indexAccumulator, id, sessionId); } }); if (indexAccumulator !== targetIndex) { result.messageSessionIndex = indexAccumulator; } return result; }); }, sendMessage: async (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null) => { if (!currentSessionId) { throw new Error("No session selected"); } const sessionId = currentSessionId; if (get().sessionAbortFlags.has(sessionId)) { set((state) => { const nextAbortFlags = new Map(state.sessionAbortFlags); nextAbortFlags.delete(sessionId); return { sessionAbortFlags: nextAbortFlags }; }); } await executeWithSessionDirectory(sessionId, async () => { try { let effectiveContent = content; const isCommand = content.startsWith("/"); if (isCommand) { const spaceIndex = content.indexOf(" "); const command = spaceIndex === -1 ? content.substring(1) : content.substring(1, spaceIndex); const commandArgs = spaceIndex === -1 ? "" : content.substring(spaceIndex + 1).trim(); const apiClient = opencodeClient.getApiClient(); const directory = opencodeClient.getDirectory(); if (command === "init") { const messageId = `msg_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; await apiClient.session.init({ path: { id: sessionId }, body: { messageID: messageId, providerID, modelID, }, query: directory ? { directory } : undefined, }); return; } if (command === "summarize") { await apiClient.session.summarize({ path: { id: sessionId }, body: { providerID, modelID, }, query: directory ? { directory } : undefined, }); return; } try { const commandDetails = await opencodeClient.getCommandDetails(command); if (commandDetails?.template) { effectiveContent = commandDetails.template.replace(/\$ARGUMENTS/g, commandArgs); } else { effectiveContent = content; } } catch (error) { console.error("Command template resolution failed:", error); effectiveContent = content; } } set({ lastUsedProvider: { providerID, modelID }, }); set((state) => { const memoryState = state.sessionMemoryState.get(sessionId) || { viewportAnchor: 0, isStreaming: false, lastAccessedAt: Date.now(), backgroundMessageCount: 0, }; const existingTimer = streamingCooldownTimers.get(sessionId); if (existingTimer) { clearTimeout(existingTimer); streamingCooldownTimers.delete(sessionId); } const newMemoryState = new Map(state.sessionMemoryState); newMemoryState.set(sessionId, { ...memoryState, isStreaming: true, streamStartTime: Date.now(), streamingCooldownUntil: undefined, }); return { sessionMemoryState: newMemoryState }; }); try { const controller = new AbortController(); set((state) => { const nextControllers = new Map(state.abortControllers); nextControllers.set(sessionId, controller); return { abortControllers: nextControllers }; }); const filePayloads = (attachments ?? []).map((file) => ({ type: "file" as const, mime: file.mimeType, filename: file.filename, url: file.dataUrl, })); set((state) => { const next = new Set(state.pendingAssistantHeaderSessions); next.add(sessionId); const nextUserMeta = new Map(state.pendingUserMessageMetaBySession); nextUserMeta.set(sessionId, { mode: typeof agent === 'string' && agent.trim().length > 0 ? agent.trim() : undefined, providerID, modelID, }); return { pendingAssistantHeaderSessions: next, pendingUserMessageMetaBySession: nextUserMeta }; }); await opencodeClient.sendMessage({ id: sessionId, providerID, modelID, text: effectiveContent, agent, files: filePayloads.length > 0 ? filePayloads : undefined, agentMentions: agentMentionName ? [{ name: agentMentionName }] : undefined, }); if (filePayloads.length > 0) { try { useFileStore.getState().clearAttachedFiles(); } catch (clearError) { console.error("Failed to clear attached files after send", clearError); } } set((state) => { const nextControllers = new Map(state.abortControllers); nextControllers.delete(sessionId); return { abortControllers: nextControllers }; }); } catch (error: any) { let errorMessage = "Network error while sending message. The message may still be processing."; if (error.name === "AbortError") { errorMessage = "Request timed out. The message may still be processing."; } else if (error.message?.includes("504") || error.message?.includes("Gateway")) { errorMessage = "Gateway timeout - your message is being processed. Please wait for response."; set((state) => { const nextControllers = new Map(state.abortControllers); nextControllers.delete(sessionId); return { abortControllers: nextControllers }; }); return; } else if (error.message) { errorMessage = error.message; } set((state) => { const nextControllers = new Map(state.abortControllers); nextControllers.delete(sessionId); const nextHeaders = new Set(state.pendingAssistantHeaderSessions); nextHeaders.delete(sessionId); const nextUserMeta = new Map(state.pendingUserMessageMetaBySession); nextUserMeta.delete(sessionId); return { abortControllers: nextControllers, pendingAssistantHeaderSessions: nextHeaders, pendingUserMessageMetaBySession: nextUserMeta }; }); throw new Error(errorMessage); } } catch (error: any) { let errorMessage = "Network error while sending message. The message may still be processing."; if (error.name === "AbortError") { errorMessage = "Request timed out. The message may still be processing."; } else if (error.response?.status === 401) { errorMessage = "Session not found or unauthorized. Please refresh the page."; } else if (error.response?.status === 502) { errorMessage = "OpenCode is restarting. Please wait a moment and try again."; } else if (error.message?.includes("504") || error.message?.includes("Gateway")) { errorMessage = "Gateway timeout - your message is being processed. Please wait for response."; } else if (error.message) { errorMessage = error.message; } set((state) => { const nextControllers = new Map(state.abortControllers); nextControllers.delete(sessionId); const nextHeaders = new Set(state.pendingAssistantHeaderSessions); nextHeaders.delete(sessionId); const nextUserMeta = new Map(state.pendingUserMessageMetaBySession); nextUserMeta.delete(sessionId); return { abortControllers: nextControllers, pendingAssistantHeaderSessions: nextHeaders, pendingUserMessageMetaBySession: nextUserMeta }; }); throw new Error(errorMessage); } }); }, abortCurrentOperation: async (currentSessionId?: string) => { if (!currentSessionId) { return; } const stateSnapshot = get(); const { abortControllers, messages: storeMessages } = stateSnapshot; const controller = abortControllers.get(currentSessionId); controller?.abort(); const activeIds = collectActiveMessageIdsForSession(stateSnapshot, currentSessionId); if (activeIds.size === 0) { const sessionMessages = currentSessionId ? storeMessages.get(currentSessionId) ?? [] : []; let fallbackAssistantId: string | null = null; for (let index = sessionMessages.length - 1; index >= 0; index -= 1) { const message = sessionMessages[index]; if (!message || message.info.role !== 'assistant') { continue; } if (!fallbackAssistantId) { fallbackAssistantId = message.info.id; } const hasWorkingPart = (message.parts ?? []).some((part) => { return part.type === 'reasoning' || part.type === 'tool' || part.type === 'step-start'; }); if (hasWorkingPart) { activeIds.add(message.info.id); break; } } if (activeIds.size === 0 && fallbackAssistantId) { activeIds.add(fallbackAssistantId); } } for (const id of activeIds) { const timeout = timeoutRegistry.get(id); if (timeout) { clearTimeout(timeout); timeoutRegistry.delete(id); lastContentRegistry.delete(id); } } if (activeIds.size > 0) { clearLifecycleTimersForIds(activeIds); } const abortTimestamp = Date.now(); set((state) => { const updatedStates = removeLifecycleEntries(state.messageStreamStates, activeIds); const sessionMessages = state.messages.get(currentSessionId) ?? []; let messagesChanged = false; let updatedMessages = state.messages; if (sessionMessages.length > 0 && activeIds.size > 0) { const updatedSessionMessages = sessionMessages.map((message) => { if (!activeIds.has(message.info.id) && activeIds.size > 0) { return message; } const updatedParts = (message.parts ?? []).map((part) => { if (part.type === 'reasoning') { const reasoningPart = part as any; const time = { ...(reasoningPart.time ?? {}) }; if (typeof time.end !== 'number') { time.end = abortTimestamp; } return { ...reasoningPart, time, } as Part; } if (part.type === 'tool') { const toolPart = part as any; const stateData = { ...(toolPart.state ?? {}) }; if (stateData.status === 'running' || stateData.status === 'pending') { stateData.status = 'aborted'; } return { ...toolPart, state: stateData, } as Part; } if (part.type === 'step-start') { const stepPart = part as any; return { ...stepPart, type: 'step-finish', aborted: true, } as Part; } return part; }); messagesChanged = true; return { ...message, info: { ...message.info, abortedAt: abortTimestamp, streaming: false, status: 'aborted', }, parts: updatedParts, }; }); if (messagesChanged) { updatedMessages = new Map(state.messages); updatedMessages.set(currentSessionId, updatedSessionMessages); } } const memoryState = state.sessionMemoryState.get(currentSessionId); let nextMemoryState = state.sessionMemoryState; if (memoryState) { const updatedMemory = new Map(state.sessionMemoryState); updatedMemory.set(currentSessionId, { ...memoryState, isStreaming: false, streamStartTime: undefined, isZombie: false, }); nextMemoryState = updatedMemory; } const nextAbortFlags = new Map(state.sessionAbortFlags); nextAbortFlags.set(currentSessionId, { timestamp: abortTimestamp, acknowledged: false, }); return { messageStreamStates: updatedStates, sessionMemoryState: nextMemoryState, sessionAbortFlags: nextAbortFlags, abortControllers: (() => { const nextControllers = new Map(state.abortControllers); nextControllers.delete(currentSessionId); return nextControllers; })(), streamingMessageIds: setStreamingIdForSession(state.streamingMessageIds, currentSessionId, null), ...(messagesChanged ? { messages: updatedMessages } : {}), }; }); void opencodeClient.abortSession(currentSessionId).catch((error) => { console.warn('Abort request failed:', error); }); }, _addStreamingPartImmediate: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => { const stateSnapshot = get(); if (ignoredAssistantMessageIds.has(messageId)) { return; } const trimmedHeadMaxId = stateSnapshot.sessionMemoryState.get(sessionId)?.trimmedHeadMaxId; if (trimmedHeadMaxId && !isIdNewer(messageId, trimmedHeadMaxId)) { (window as any).__messageTracker?.(messageId, 'ignored_trimmed_stream_part'); return; } const existingMessagesSnapshot = stateSnapshot.messages.get(sessionId) || []; const existingMessageSnapshot = existingMessagesSnapshot.find((m) => m.info.id === messageId); const actualRole = (() => { if (role === 'user') return 'user'; if (existingMessageSnapshot?.info.role === 'user') return 'user'; return role || existingMessageSnapshot?.info.role || 'assistant'; })(); const memoryStateSnapshot = get().sessionMemoryState.get(sessionId); if (memoryStateSnapshot?.streamStartTime) { const streamDuration = Date.now() - memoryStateSnapshot.streamStartTime; if (streamDuration > MEMORY_LIMITS.ZOMBIE_TIMEOUT) { if (!memoryStateSnapshot.isZombie) { set((state) => { const newMemoryState = new Map(state.sessionMemoryState); newMemoryState.set(sessionId, { ...memoryStateSnapshot, isZombie: true, }); return { sessionMemoryState: newMemoryState }; }); } setTimeout(() => { const store = get(); store.completeStreamingMessage(sessionId, messageId); }, 0); (window as any).__messageTracker?.(messageId, 'skipped_zombie_stream'); return; } } set((state) => { const sessionMessages = state.messages.get(sessionId) || []; const messagesArray = [...sessionMessages]; const updates: any = {}; const indexedSessions = upsertMessageSessionIndex(state.messageSessionIndex, messageId, sessionId); if (indexedSessions !== state.messageSessionIndex) { updates.messageSessionIndex = indexedSessions; } const finalizeAbortState = (result: Partial): Partial => { const shouldClearAbortFlag = (actualRole === 'assistant' || actualRole === 'user') && state.sessionAbortFlags.has(sessionId); if (!shouldClearAbortFlag) { return result; } const nextAbortFlags = new Map(state.sessionAbortFlags); nextAbortFlags.delete(sessionId); return { ...result, sessionAbortFlags: nextAbortFlags, }; }; const maintainTimeouts = (text: string) => { const value = text || ''; const lastContent = lastContentRegistry.get(messageId); if (value && lastContent === value) { const currentState = get(); if (isMessageStreamingInSession(currentState, sessionId, messageId)) { const existingTimeout = timeoutRegistry.get(messageId); if (existingTimeout) { clearTimeout(existingTimeout); timeoutRegistry.delete(messageId); } setTimeout(() => { const store = get(); if (typeof store.forceCompleteMessage === "function") { store.forceCompleteMessage(sessionId, messageId, "timeout"); } store.completeStreamingMessage(sessionId, messageId); }, 100); } } lastContentRegistry.set(messageId, value); const existingTimeout = timeoutRegistry.get(messageId); if (existingTimeout) { clearTimeout(existingTimeout); } const newTimeout = setTimeout(() => { const store = get(); if (typeof store.forceCompleteMessage === "function") { store.forceCompleteMessage(sessionId, messageId, "timeout"); } if (isMessageStreamingInSession(store, sessionId, messageId)) { store.completeStreamingMessage(sessionId, messageId); } timeoutRegistry.delete(messageId); lastContentRegistry.delete(messageId); }, 8000); timeoutRegistry.set(messageId, newTimeout); }; const isBackgroundSession = sessionId !== currentSessionId; const memoryState = state.sessionMemoryState.get(sessionId); if (isBackgroundSession && memoryState?.isStreaming) { if (messagesArray.length >= MEMORY_LIMITS.BACKGROUND_STREAMING_BUFFER) { messagesArray.shift(); } const newMemoryState = new Map(state.sessionMemoryState); newMemoryState.set(sessionId, { ...memoryState, backgroundMessageCount: (memoryState.backgroundMessageCount || 0) + 1, }); state.sessionMemoryState = newMemoryState; } if (actualRole === 'assistant') { const currentMemoryState = state.sessionMemoryState.get(sessionId); if (currentMemoryState) { const now = Date.now(); const nextMemoryState = new Map(state.sessionMemoryState); nextMemoryState.set(sessionId, { ...currentMemoryState, isStreaming: true, streamStartTime: currentMemoryState.streamStartTime ?? now, lastAccessedAt: now, isZombie: false, }); state.sessionMemoryState = nextMemoryState; } } const incomingText = extractTextFromPart(part); if (isExecutionForkMetaText(incomingText)) { (part as any).synthetic = true; } if (streamDebugEnabled() && actualRole === "assistant") { try { console.info("[STREAM-TRACE] part", { messageId, role: actualRole, type: (part as any)?.type || "text", textLen: incomingText.length, snapshotParts: existingMessagesSnapshot.length, }); } catch { /* ignored */ } } const previousStreamingMap = updates.streamingMessageIds ?? state.streamingMessageIds; if (actualRole === 'assistant') { const nextStreamingMap = setStreamingIdForSession(previousStreamingMap, sessionId, messageId); if (nextStreamingMap !== previousStreamingMap) { updates.streamingMessageIds = nextStreamingMap; (window as any).__messageTracker?.(messageId, 'streamingId_set_latest'); } } const messageIndex = messagesArray.findIndex((m) => m.info.id === messageId); if (messageIndex !== -1 && actualRole === 'user') { const existingMessage = messagesArray[messageIndex]; const existingPartIndex = existingMessage.parts.findIndex((p) => p.id === part.id); if ((part as any).synthetic === true) { (window as any).__messageTracker?.(messageId, 'skipped_synthetic_user_part'); return state; } const normalizedPart = normalizeStreamingPart( part, existingPartIndex !== -1 ? existingMessage.parts[existingPartIndex] : undefined ); (window as any).__messageTracker?.(messageId, `user_part_type:${(normalizedPart as any).type || 'unknown'}`); const updatedMessage = { ...existingMessage }; if (existingPartIndex !== -1) { updatedMessage.parts = updatedMessage.parts.map((p, idx) => idx === existingPartIndex ? normalizedPart : p ); } else { updatedMessage.parts = [...updatedMessage.parts, normalizedPart]; } const updatedMessages = [...messagesArray]; updatedMessages[messageIndex] = updatedMessage; const newMessages = new Map(state.messages); newMessages.set(sessionId, updatedMessages); return finalizeAbortState({ messages: newMessages }); } if (actualRole === 'assistant' && messageIndex !== -1) { const existingMessage = messagesArray[messageIndex]; const existingPartIndex = existingMessage.parts.findIndex((p) => p.id === part.id); const normalizedPart = normalizeStreamingPart( part, existingPartIndex !== -1 ? existingMessage.parts[existingPartIndex] : undefined ); (window as any).__messageTracker?.(messageId, `part_type:${(normalizedPart as any).type || 'unknown'}`); const updatedMessage = { ...existingMessage }; if (existingPartIndex !== -1) { updatedMessage.parts = updatedMessage.parts.map((p, idx) => idx === existingPartIndex ? normalizedPart : p ); } else { updatedMessage.parts = [...updatedMessage.parts, normalizedPart]; } const updatedMessages = [...messagesArray]; updatedMessages[messageIndex] = updatedMessage; const newMessages = new Map(state.messages); newMessages.set(sessionId, updatedMessages); updates.messageStreamStates = touchStreamingLifecycle(state.messageStreamStates, messageId); const nextStreamingMap = setStreamingIdForSession(updates.streamingMessageIds ?? state.streamingMessageIds, sessionId, messageId); if (nextStreamingMap !== (updates.streamingMessageIds ?? state.streamingMessageIds)) { updates.streamingMessageIds = nextStreamingMap; (window as any).__messageTracker?.(messageId, 'streamingId_set'); } if ((normalizedPart as any).type === 'text') { maintainTimeouts((normalizedPart as any).text || ''); } else { maintainTimeouts(''); } return finalizeAbortState({ messages: newMessages, ...updates }); } if (messageIndex === -1) { if (actualRole === 'user') { if ((part as any).synthetic === true) { (window as any).__messageTracker?.(messageId, 'skipped_synthetic_new_user_part'); return state; } const normalizedPart = normalizeStreamingPart(part); (window as any).__messageTracker?.(messageId, `new_user_part_type:${(normalizedPart as any).type || 'unknown'}`); const pendingMeta = state.pendingUserMessageMetaBySession.get(sessionId); const contextStore = useContextStore.getState(); const sessionAgent = pendingMeta?.mode ?? contextStore.getSessionAgentSelection(sessionId) ?? contextStore.getCurrentAgent(sessionId); const agentMode = typeof sessionAgent === 'string' && sessionAgent.trim().length > 0 ? sessionAgent.trim() : undefined; const providerID = pendingMeta?.providerID ?? (state.lastUsedProvider?.providerID || undefined); const modelID = pendingMeta?.modelID ?? (state.lastUsedProvider?.modelID || undefined); if (pendingMeta) { updates.pendingUserMessageMetaBySession = cleanupPendingUserMessageMeta(state.pendingUserMessageMetaBySession, sessionId); } const newUserMessage = { info: { id: messageId, sessionID: sessionId, role: 'user' as const, clientRole: 'user', userMessageMarker: true, ...(agentMode ? { mode: agentMode } : {}), ...(providerID ? { providerID } : {}), ...(modelID ? { modelID } : {}), time: { created: Date.now(), }, }, parts: [normalizedPart], }; const updatedMessages = [...messagesArray, newUserMessage]; updatedMessages.sort((a, b) => { const aTime = (a.info as any)?.time?.created || 0; const bTime = (b.info as any)?.time?.created || 0; return aTime - bTime; }); const newMessages = new Map(state.messages); newMessages.set(sessionId, updatedMessages); return finalizeAbortState({ messages: newMessages }); } if ((part as any)?.type === 'text') { const textIncoming = extractTextFromPart(part).trim(); if (textIncoming.length > 0) { const latestUser = [...messagesArray] .reverse() .find((m) => m.info.role === 'user'); if (latestUser) { const latestUserText = latestUser.parts.map((p) => extractTextFromPart(p)).join('').trim(); if (latestUserText.length > 0 && latestUserText === textIncoming) { ignoredAssistantMessageIds.add(messageId); (window as any).__messageTracker?.(messageId, 'ignored_assistant_echo'); return state; } } } } const normalizedPart = normalizeStreamingPart(part); (window as any).__messageTracker?.(messageId, `part_type:${(normalizedPart as any).type || 'unknown'}`); if ((normalizedPart as any).type === 'text') { maintainTimeouts((normalizedPart as any).text || ''); } else { maintainTimeouts(''); } const pendingEntry = state.pendingAssistantParts.get(messageId); const pendingParts = pendingEntry ? [...pendingEntry.parts] : []; const pendingIndex = pendingParts.findIndex((existing) => existing.id === normalizedPart.id); if (pendingIndex !== -1) { pendingParts[pendingIndex] = normalizedPart; } else { pendingParts.push(normalizedPart); } const newPending = new Map(state.pendingAssistantParts); newPending.set(messageId, { sessionId, parts: pendingParts }); const providerID = state.lastUsedProvider?.providerID || ""; const modelID = state.lastUsedProvider?.modelID || ""; const now = Date.now(); const cwd = opencodeClient.getDirectory() ?? "/"; const contextStore = useContextStore.getState(); const sessionAgent = contextStore.getSessionAgentSelection(sessionId) ?? contextStore.getCurrentAgent(sessionId); const agentMode = typeof sessionAgent === "string" && sessionAgent.trim().length > 0 ? sessionAgent.trim() : undefined; const shouldAnchorHeader = state.pendingAssistantHeaderSessions.has(sessionId); if (shouldAnchorHeader) { const nextPendingHeaders = new Set(state.pendingAssistantHeaderSessions); nextPendingHeaders.delete(sessionId); updates.pendingAssistantHeaderSessions = nextPendingHeaders; } const placeholderInfo = (actualRole === "user" ? { id: messageId, sessionID: sessionId, role: "user", time: { created: now }, agent: agentMode || "default", model: { providerID, modelID }, clientRole: actualRole, animationSettled: undefined, streaming: undefined, } : { id: messageId, sessionID: sessionId, role: "assistant", time: { created: now }, parentID: messageId, modelID, providerID, mode: agentMode || "default", ...(shouldAnchorHeader ? { openchamberHeaderAnchor: true } : {}), path: { cwd, root: cwd }, cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 }, }, clientRole: actualRole, animationSettled: false, streaming: true, }) as unknown as Message; const placeholderMessage = { info: placeholderInfo, parts: pendingParts, }; const nextMessages = [...messagesArray, placeholderMessage]; const newMessages = new Map(state.messages); newMessages.set(sessionId, nextMessages); if (actualRole === 'assistant') { updates.messageStreamStates = touchStreamingLifecycle(state.messageStreamStates, messageId); const nextStreamingMap = setStreamingIdForSession(updates.streamingMessageIds ?? state.streamingMessageIds, sessionId, messageId); if (nextStreamingMap !== (updates.streamingMessageIds ?? state.streamingMessageIds)) { updates.streamingMessageIds = nextStreamingMap; (window as any).__messageTracker?.(messageId, 'streamingId_set'); } } return finalizeAbortState({ messages: newMessages, pendingAssistantParts: newPending, ...updates, }); } else { const existingMessage = messagesArray[messageIndex]; const existingPartIndex = existingMessage.parts.findIndex((p) => p.id === part.id); const normalizedPart = normalizeStreamingPart( part, existingPartIndex !== -1 ? existingMessage.parts[existingPartIndex] : undefined ); (window as any).__messageTracker?.(messageId, `part_type:${(normalizedPart as any).type || 'unknown'}`); const updatedMessage = { ...existingMessage }; if (existingPartIndex !== -1) { updatedMessage.parts = updatedMessage.parts.map((p, idx) => idx === existingPartIndex ? normalizedPart : p ); } else { updatedMessage.parts = [...updatedMessage.parts, normalizedPart]; } const updatedMessages = [...messagesArray]; updatedMessages[messageIndex] = updatedMessage; const newMessages = new Map(state.messages); newMessages.set(sessionId, updatedMessages); if (updatedMessage.info.role === "assistant") { updates.messageStreamStates = touchStreamingLifecycle(state.messageStreamStates, messageId); const nextStreamingMap = setStreamingIdForSession(updates.streamingMessageIds ?? state.streamingMessageIds, sessionId, messageId); if (nextStreamingMap !== (updates.streamingMessageIds ?? state.streamingMessageIds)) { updates.streamingMessageIds = nextStreamingMap; (window as any).__messageTracker?.(messageId, 'streamingId_set'); } } if ((normalizedPart as any).type === 'text') { maintainTimeouts((normalizedPart as any).text || ''); } else { maintainTimeouts(''); } return finalizeAbortState({ messages: newMessages, ...updates }); } }); const partType = (part as any)?.type; if (partType === 'step-finish' && actualRole !== 'user') { setTimeout(() => { const store = get(); store.completeStreamingMessage(sessionId, messageId); }, 0); } }, addStreamingPart: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => { if (role !== 'user') { get()._addStreamingPartImmediate(sessionId, messageId, part, role, currentSessionId); return; } batchQueue.push({ sessionId, messageId, part, role, currentSessionId }); if (!flushTimer) { flushTimer = setTimeout(() => { const itemsToProcess = [...batchQueue]; batchQueue = []; flushTimer = null; const store = get(); for (const item of itemsToProcess) { store._addStreamingPartImmediate(item.sessionId, item.messageId, item.part, item.role, item.currentSessionId); } }, USER_BATCH_WINDOW_MS); } }, forceCompleteMessage: (sessionId: string | null | undefined, messageId: string, source: "timeout" | "cooldown" = "timeout") => { const resolveSessionId = (state: MessageState): string | null => { if (sessionId) { return sessionId; } for (const [candidateId, sessionMessages] of state.messages.entries()) { if (sessionMessages.some((msg) => msg.info.id === messageId)) { return candidateId; } } return null; }; set((state) => { const targetSessionId = resolveSessionId(state); if (!targetSessionId) { return state; } const sessionMessages = state.messages.get(targetSessionId) ?? []; const messageIndex = sessionMessages.findIndex((msg) => msg.info.id === messageId); if (messageIndex === -1) { return state; } const message = sessionMessages[messageIndex]; if (!message) { return state; } const now = Date.now(); const existingInfo = message.info as any; const existingCompleted = typeof existingInfo?.time?.completed === "number" && existingInfo.time.completed > 0; let infoChanged = false; const updatedInfo: Record = { ...existingInfo }; if (!existingCompleted) { updatedInfo.time = { ...(existingInfo.time ?? {}), completed: now, }; infoChanged = true; } if (updatedInfo.status !== "completed") { updatedInfo.status = "completed"; infoChanged = true; } if (updatedInfo.streaming) { updatedInfo.streaming = false; infoChanged = true; } let partsChanged = false; const updatedParts = message.parts.map((part) => { if (!part) { return part; } if (part.type === "tool") { const existingState = (part as any).state; if (!existingState) { return part; } const status = existingState.status; const needsStatusUpdate = status === "running" || status === "pending" || status === "started"; const needsEndTimestamp = !existingState.time || typeof existingState.time?.end !== "number"; if (needsStatusUpdate || needsEndTimestamp) { const nextState: Record = { ...existingState }; if (needsStatusUpdate) { nextState.status = "completed"; } if (needsEndTimestamp) { nextState.time = { ...(existingState.time ?? {}), end: now, }; } partsChanged = true; return { ...part, state: nextState, } as Part; } return part; } if (part.type === "reasoning") { const reasoningTime = (part as any).time; if (!reasoningTime || typeof reasoningTime.end !== "number") { partsChanged = true; return { ...part, time: { ...(reasoningTime ?? {}), end: now, }, } as Part; } return part; } if (part.type === "text") { const textTime = (part as any).time; if (textTime && typeof textTime.end !== "number") { partsChanged = true; return { ...part, time: { ...textTime, end: now, }, } as Part; } return part; } return part; }); if (!infoChanged && !partsChanged) { return state; } (window as any).__messageTracker?.(messageId, `force_complete:${source}`); const updatedMessage = { ...message, info: updatedInfo as Message, parts: partsChanged ? updatedParts : message.parts, }; const nextSessionMessages = [...sessionMessages]; nextSessionMessages[messageIndex] = updatedMessage; const nextMessages = new Map(state.messages); nextMessages.set(targetSessionId, nextSessionMessages); return { messages: nextMessages }; }); }, markMessageStreamSettled: (messageId: string) => { set((state) => { clearLifecycleCompletionTimer(messageId); const next = new Map(state.messageStreamStates); next.delete(messageId); let updatedMessages = state.messages; let messagesModified = false; state.messages.forEach((sessionMessages, sessionId) => { if (messagesModified) return; const idx = sessionMessages.findIndex((msg) => msg.info.id === messageId); if (idx === -1) return; const message = sessionMessages[idx]; if ((message.info as any)?.animationSettled) { return; } const updatedMessage = { ...message, info: { ...message.info, animationSettled: true, }, }; const sessionArray = [...sessionMessages]; sessionArray[idx] = updatedMessage; const newMessages = new Map(state.messages); newMessages.set(sessionId, sessionArray); updatedMessages = newMessages; messagesModified = true; }); const updates: Partial & { messageStreamStates: Map } = { messageStreamStates: next, ...(messagesModified ? { messages: updatedMessages } : {}), } as any; return updates; }); clearLifecycleTimersForIds([messageId]); }, updateMessageInfo: (sessionId: string, messageId: string, messageInfo: any) => { set((state) => { const trimmedHeadMaxId = state.sessionMemoryState.get(sessionId)?.trimmedHeadMaxId; if (trimmedHeadMaxId && !isIdNewer(messageId, trimmedHeadMaxId)) { (window as any).__messageTracker?.(messageId, 'ignored_trimmed_update'); return state; } const sessionMessages = state.messages.get(sessionId) ?? []; const normalizedSessionMessages = [...sessionMessages]; const messageIndex = normalizedSessionMessages.findIndex((msg) => msg.info.id === messageId); const pendingEntry = state.pendingAssistantParts.get(messageId); const mergeParts = (existingParts: Part[] = [], incomingParts: Part[] = []) => { if (!incomingParts.length) { return existingParts; } const merged = [...existingParts]; incomingParts.forEach((incomingPart) => { const idx = merged.findIndex((part) => part.id === incomingPart.id); if (idx === -1) { merged.push(incomingPart); } else { merged[idx] = incomingPart; } }); return merged; }; const ensureClientRole = (info: any) => { if (!info) { return info; } const clientRole = info.clientRole ?? info.role; const userMarker = clientRole === 'user' ? true : info.userMessageMarker; return { ...info, clientRole, ...(userMarker ? { userMessageMarker: true } : {}), }; }; if (messageIndex === -1) { console.info("[MESSAGE-DEBUG] updateMessageInfo: messageIndex === -1", { sessionId, messageId, messageInfo, existingCount: normalizedSessionMessages.length, }); if (normalizedSessionMessages.length > 0) { const firstMessage = normalizedSessionMessages[0]; const firstInfo = firstMessage?.info as any; const firstCreated = typeof firstInfo?.time?.created === 'number' ? firstInfo.time.created : null; const firstId = typeof firstInfo?.id === 'string' ? firstInfo.id : null; const incomingInfoToCompare = messageInfo as any; const incomingCreated = typeof incomingInfoToCompare?.time?.created === 'number' ? incomingInfoToCompare.time.created : null; const incomingId = typeof incomingInfoToCompare?.id === 'string' ? incomingInfoToCompare.id : messageId; let isOlderThanViewport = false; if (incomingCreated !== null && firstCreated !== null) { isOlderThanViewport = incomingCreated < firstCreated; } if (!isOlderThanViewport && incomingId && firstId) { isOlderThanViewport = incomingId.localeCompare(firstId) < 0; } if (isOlderThanViewport) { (window as any).__messageTracker?.(messageId, 'skipped_evicted_message_update'); return state; } } const incomingInfo = ensureClientRole(messageInfo); if (incomingInfo && incomingInfo.role === 'user') { const pendingParts = pendingEntry?.parts ?? []; const pendingMeta = state.pendingUserMessageMetaBySession.get(sessionId); const newUserMessage = { info: { ...incomingInfo, userMessageMarker: true, clientRole: 'user', ...(pendingMeta?.mode ? { mode: pendingMeta.mode } : {}), ...(pendingMeta?.providerID ? { providerID: pendingMeta.providerID } : {}), ...(pendingMeta?.modelID ? { modelID: pendingMeta.modelID } : {}), } as Message, parts: pendingParts.length > 0 ? [...pendingParts] : [], }; const newMessages = new Map(state.messages); const appended = [...normalizedSessionMessages, newUserMessage]; appended.sort((a, b) => { const aTime = (a.info as any)?.time?.created || 0; const bTime = (b.info as any)?.time?.created || 0; return aTime - bTime; }); newMessages.set(sessionId, appended); const updates: Partial = { messages: newMessages, ...(pendingMeta ? { pendingUserMessageMetaBySession: cleanupPendingUserMessageMeta(state.pendingUserMessageMetaBySession, sessionId), } : {}), }; const nextIndex = upsertMessageSessionIndex( updates.messageSessionIndex ?? state.messageSessionIndex, messageId, sessionId ); if (nextIndex !== (updates.messageSessionIndex ?? state.messageSessionIndex)) { updates.messageSessionIndex = nextIndex; } if (pendingEntry) { const newPending = new Map(state.pendingAssistantParts); newPending.delete(messageId); updates.pendingAssistantParts = newPending; } return updates; } if (!incomingInfo || incomingInfo.role !== 'assistant') { return state; } const pendingParts = pendingEntry?.parts ?? []; const shouldAnchorHeader = state.pendingAssistantHeaderSessions.has(sessionId); const newMessage = { info: { ...incomingInfo, animationSettled: (incomingInfo as any)?.animationSettled ?? false, ...(shouldAnchorHeader ? { openchamberHeaderAnchor: true } : {}), } as Message, parts: pendingParts.length > 0 ? [...pendingParts] : [], }; const newMessages = new Map(state.messages); const appended = [...normalizedSessionMessages, newMessage]; newMessages.set(sessionId, appended); const updates: Partial = { messages: newMessages, ...(shouldAnchorHeader ? { pendingAssistantHeaderSessions: (() => { const nextPendingHeaders = new Set(state.pendingAssistantHeaderSessions); nextPendingHeaders.delete(sessionId); return nextPendingHeaders; })(), } : {}), }; const nextIndex = upsertMessageSessionIndex( updates.messageSessionIndex ?? state.messageSessionIndex, messageId, sessionId ); if (nextIndex !== (updates.messageSessionIndex ?? state.messageSessionIndex)) { updates.messageSessionIndex = nextIndex; } if (pendingEntry) { const newPending = new Map(state.pendingAssistantParts); newPending.delete(messageId); updates.pendingAssistantParts = newPending; } return updates; } const existingMessage = normalizedSessionMessages[messageIndex]; const existingInfo = existingMessage.info as any; const isUserMessage = existingInfo.userMessageMarker === true || existingInfo.clientRole === 'user' || existingInfo.role === 'user'; if (isUserMessage) { const updatedInfo = { ...existingMessage.info, ...messageInfo, role: 'user', clientRole: 'user', userMessageMarker: true, providerID: existingInfo.providerID || undefined, modelID: existingInfo.modelID || undefined, } as any; const pendingMeta = state.pendingUserMessageMetaBySession.get(sessionId); if (pendingMeta && !updatedInfo.mode && pendingMeta.mode) { updatedInfo.mode = pendingMeta.mode; } const updatedMessage = { ...existingMessage, info: updatedInfo }; const newMessages = new Map(state.messages); const updatedSessionMessages = [...normalizedSessionMessages]; updatedSessionMessages[messageIndex] = updatedMessage; newMessages.set(sessionId, updatedSessionMessages); if (pendingMeta) { const nextPending = new Map(state.pendingUserMessageMetaBySession); nextPending.delete(sessionId); return { messages: newMessages, pendingUserMessageMetaBySession: nextPending }; } return { messages: newMessages }; } const updatedInfo = { ...existingMessage.info, ...messageInfo, } as any; if (messageInfo.role && messageInfo.role !== existingMessage.info.role) { updatedInfo.role = existingMessage.info.role; } updatedInfo.clientRole = updatedInfo.clientRole ?? existingMessage.info.clientRole ?? existingMessage.info.role; if (updatedInfo.clientRole === "user") { updatedInfo.userMessageMarker = true; } const mergedParts = pendingEntry?.parts ? mergeParts(existingMessage.parts, pendingEntry.parts) : existingMessage.parts; const updatedMessage = { ...existingMessage, info: updatedInfo, parts: mergedParts, }; const newMessages = new Map(state.messages); const updatedSessionMessages = [...normalizedSessionMessages]; updatedSessionMessages[messageIndex] = updatedMessage; newMessages.set(sessionId, updatedSessionMessages); const updates: Partial = { messages: newMessages, }; if (pendingEntry) { const newPending = new Map(state.pendingAssistantParts); newPending.delete(messageId); updates.pendingAssistantParts = newPending; } return updates; }); }, completeStreamingMessage: (sessionId: string, messageId: string) => { const state = get(); (window as any).__messageTracker?.( messageId, `completion_called_current:${state.streamingMessageIds.get(sessionId) ?? 'none'}` ); if (typeof state.forceCompleteMessage === "function") { state.forceCompleteMessage(sessionId, messageId, "cooldown"); } const shouldClearStreamingId = state.streamingMessageIds.get(sessionId) === messageId; if (shouldClearStreamingId) { (window as any).__messageTracker?.(messageId, 'streamingId_cleared'); } else { (window as any).__messageTracker?.(messageId, 'streamingId_NOT_cleared_different_id'); } const updates: Record = {}; if (shouldClearStreamingId) { updates.streamingMessageIds = setStreamingIdForSession(state.streamingMessageIds, sessionId, null); updates.abortControllers = (() => { const next = new Map(state.abortControllers); next.delete(sessionId); return next; })(); } if (state.messageStreamStates.has(messageId)) { const next = new Map(state.messageStreamStates); next.delete(messageId); updates.messageStreamStates = next; } if (Object.keys(updates).length > 0) { set(updates); } if (state.pendingAssistantParts.has(messageId)) { set((currentState) => { if (!currentState.pendingAssistantParts.has(messageId)) { return currentState; } const nextPending = new Map(currentState.pendingAssistantParts); nextPending.delete(messageId); return { pendingAssistantParts: nextPending }; }); } clearLifecycleTimersForIds([messageId]); let startedCooldown = false; set((state) => { const memoryState = state.sessionMemoryState.get(sessionId); if (!memoryState || !memoryState.isStreaming) return state; const newMemoryState = new Map(state.sessionMemoryState); const now = Date.now(); const updatedMemory: SessionMemoryState = { ...memoryState, isStreaming: false, streamStartTime: undefined, isZombie: false, lastAccessedAt: now, streamingCooldownUntil: now + 2000, }; newMemoryState.set(sessionId, updatedMemory); startedCooldown = true; return { sessionMemoryState: newMemoryState }; }); if (startedCooldown) { const existingTimer = streamingCooldownTimers.get(sessionId); if (existingTimer) { clearTimeout(existingTimer); streamingCooldownTimers.delete(sessionId); } const timeoutId = setTimeout(() => { set((state) => { const memoryState = state.sessionMemoryState.get(sessionId); if (!memoryState) return state; if (memoryState.isStreaming) { return state; } const nextMemoryState = new Map(state.sessionMemoryState); const { streamingCooldownUntil: _streamingCooldownUntil, ...rest } = memoryState; void _streamingCooldownUntil; nextMemoryState.set(sessionId, rest as SessionMemoryState); return { sessionMemoryState: nextMemoryState }; }); streamingCooldownTimers.delete(sessionId); }, 2000); streamingCooldownTimers.set(sessionId, timeoutId); } }, syncMessages: (sessionId: string, messages: { info: Message; parts: Part[] }[]) => { // Filter out reverted messages first const revertMessageId = getSessionRevertMessageId(sessionId); const messagesWithoutReverted = filterRevertedMessages(messages, revertMessageId); const watermark = get().sessionMemoryState.get(sessionId)?.trimmedHeadMaxId; const messagesFiltered = watermark ? messagesWithoutReverted.filter((message) => { const messageId = message?.info?.id; if (!messageId) return true; return isIdNewer(messageId, watermark); }) : messagesWithoutReverted; set((state) => { const newMessages = new Map(state.messages); const previousMessages = state.messages.get(sessionId) || []; const previousMessagesById = new Map( previousMessages .filter((msg) => typeof msg.info?.id === "string") .map((msg) => [msg.info.id as string, msg]) ); const normalizedMessages = messagesFiltered.map((message) => { const infoWithMarker = { ...message.info, clientRole: (message.info as any)?.clientRole ?? message.info.role, userMessageMarker: message.info.role === "user" ? true : (message.info as any)?.userMessageMarker, animationSettled: message.info.role === "assistant" ? (message.info as any)?.animationSettled ?? true : (message.info as any)?.animationSettled, } as any; const serverParts = (Array.isArray(message.parts) ? message.parts : []).map((part) => { if (part?.type === 'text') { const raw = (part as any).text ?? (part as any).content ?? ''; if (isExecutionForkMetaText(raw)) { return { ...part, synthetic: true } as Part; } } return part; }); const messageId = typeof infoWithMarker?.id === "string" ? (infoWithMarker.id as string) : undefined; const existingEntry = messageId ? previousMessagesById.get(messageId) : undefined; if (existingEntry && existingEntry.info.role === "assistant") { const existingParts = Array.isArray(existingEntry.parts) ? existingEntry.parts : []; const existingLen = computePartsTextLength(existingParts); const serverLen = computePartsTextLength(serverParts); const storeHasStop = hasStopReasonStop(existingParts); if (storeHasStop && existingLen > serverLen) { const mergedParts = mergePreferExistingParts(existingParts, serverParts); return { ...message, info: infoWithMarker, parts: mergedParts, }; } } return { ...message, info: infoWithMarker, parts: serverParts, }; }); const mergedMessages = dedupeMessagesById(normalizedMessages); const previousIds = new Set(previousMessages.map((msg) => msg.info.id)); const nextIds = new Set(mergedMessages.map((msg) => msg.info.id)); const removedIds: string[] = []; previousIds.forEach((id) => { if (!nextIds.has(id)) { removedIds.push(id); } }); newMessages.set(sessionId, mergedMessages); const result: Record = { messages: newMessages, isSyncing: true, }; clearLifecycleTimersForIds(removedIds); const updatedLifecycle = removeLifecycleEntries(state.messageStreamStates, removedIds); if (updatedLifecycle !== state.messageStreamStates) { result.messageStreamStates = updatedLifecycle; } if (removedIds.length > 0) { const currentStreaming = state.streamingMessageIds.get(sessionId); if (currentStreaming && removedIds.includes(currentStreaming)) { result.streamingMessageIds = setStreamingIdForSession( result.streamingMessageIds ?? state.streamingMessageIds, sessionId, null ); } } if (removedIds.length > 0) { const nextIndex = removeMessageSessionIndexEntries( result.messageSessionIndex ?? state.messageSessionIndex, removedIds ); if (nextIndex !== (result.messageSessionIndex ?? state.messageSessionIndex)) { result.messageSessionIndex = nextIndex; } } if (removedIds.length > 0) { const nextPendingParts = new Map(state.pendingAssistantParts); let pendingChanged = false; removedIds.forEach((id) => { if (nextPendingParts.delete(id)) { pendingChanged = true; } }); if (pendingChanged) { result.pendingAssistantParts = nextPendingParts; } } const targetIndex = result.messageSessionIndex ?? state.messageSessionIndex; let indexAccumulator = targetIndex; mergedMessages.forEach((message) => { const id = (message?.info as { id?: unknown })?.id; if (typeof id === "string" && id.length > 0) { indexAccumulator = upsertMessageSessionIndex(indexAccumulator, id, sessionId); } }); if (indexAccumulator !== targetIndex) { result.messageSessionIndex = indexAccumulator; } return result; }); setTimeout(() => { set({ isSyncing: false }); }, 100); }, updateSessionCompaction: (sessionId: string, compactingTimestamp: number | null | undefined) => { set((state) => { const nextCompaction = new Map(state.sessionCompactionUntil); if (!compactingTimestamp || compactingTimestamp <= 0) { if (!nextCompaction.has(sessionId)) { return state; } nextCompaction.delete(sessionId); return { sessionCompactionUntil: nextCompaction }; } const deadline = compactingTimestamp + COMPACTION_WINDOW_MS; const existingDeadline = nextCompaction.get(sessionId); if (existingDeadline === deadline) { return state; } nextCompaction.set(sessionId, deadline); return { sessionCompactionUntil: nextCompaction }; }); }, acknowledgeSessionAbort: (sessionId: string) => { if (!sessionId) { return; } set((state) => { const record = state.sessionAbortFlags.get(sessionId); if (!record || record.acknowledged) { return state; } const nextAbortFlags = new Map(state.sessionAbortFlags); nextAbortFlags.set(sessionId, { ...record, acknowledged: true }); return { sessionAbortFlags: nextAbortFlags } as Partial; }); }, updateViewportAnchor: (sessionId: string, anchor: number) => { set((state) => { const memoryState = state.sessionMemoryState.get(sessionId) || { viewportAnchor: 0, isStreaming: false, lastAccessedAt: Date.now(), backgroundMessageCount: 0, }; const newMemoryState = new Map(state.sessionMemoryState); newMemoryState.set(sessionId, { ...memoryState, viewportAnchor: anchor }); return { sessionMemoryState: newMemoryState }; }); }, trimToViewportWindow: (sessionId: string, targetSize: number = MEMORY_LIMITS.VIEWPORT_MESSAGES, currentSessionId?: string) => { const state = get(); const sessionMessages = state.messages.get(sessionId); if (!sessionMessages || sessionMessages.length <= targetSize) { return; } const memoryState = state.sessionMemoryState.get(sessionId) || { viewportAnchor: sessionMessages.length - 1, isStreaming: false, lastAccessedAt: Date.now(), backgroundMessageCount: 0, }; if (memoryState.isStreaming && sessionId === currentSessionId) { return; } const anchor = memoryState.viewportAnchor || sessionMessages.length - 1; let start = Math.max(0, anchor - Math.floor(targetSize / 2)); const end = Math.min(sessionMessages.length, start + targetSize); if (end === sessionMessages.length && end - start < targetSize) { start = Math.max(0, end - targetSize); } const trimmedMessages = sessionMessages.slice(start, end); const removedOlder = sessionMessages.slice(0, start); const trimmedIds = new Set(trimmedMessages.map((message) => message.info.id)); const removedIds = sessionMessages .filter((message) => !trimmedIds.has(message.info.id)) .map((message) => message.info.id); set((state) => { const newMessages = new Map(state.messages); newMessages.set(sessionId, trimmedMessages); const newMemoryState = new Map(state.sessionMemoryState); const updatedMemoryState = { ...memoryState, viewportAnchor: anchor - start, trimmedHeadMaxId: computeMaxTrimmedHeadId(removedOlder, memoryState.trimmedHeadMaxId), }; newMemoryState.set(sessionId, updatedMemoryState); const result: Record = { messages: newMessages, sessionMemoryState: newMemoryState, }; clearLifecycleTimersForIds(removedIds); const updatedLifecycle = removeLifecycleEntries(state.messageStreamStates, removedIds); if (updatedLifecycle !== state.messageStreamStates) { result.messageStreamStates = updatedLifecycle; } if (removedIds.length > 0) { const currentStreaming = state.streamingMessageIds.get(sessionId); if (currentStreaming && removedIds.includes(currentStreaming)) { result.streamingMessageIds = setStreamingIdForSession( result.streamingMessageIds ?? state.streamingMessageIds, sessionId, null ); } } if (removedIds.length > 0) { const nextIndex = removeMessageSessionIndexEntries( result.messageSessionIndex ?? state.messageSessionIndex, removedIds ); if (nextIndex !== (result.messageSessionIndex ?? state.messageSessionIndex)) { result.messageSessionIndex = nextIndex; } } const targetIndex = result.messageSessionIndex ?? state.messageSessionIndex; let indexAccumulator = targetIndex; trimmedMessages.forEach((message) => { const id = (message?.info as { id?: unknown })?.id; if (typeof id === "string" && id.length > 0) { indexAccumulator = upsertMessageSessionIndex(indexAccumulator, id, sessionId); } }); if (indexAccumulator !== targetIndex) { result.messageSessionIndex = indexAccumulator; } return result; }); }, evictLeastRecentlyUsed: (currentSessionId?: string) => { const state = get(); const sessionCount = state.messages.size; if (sessionCount <= MEMORY_LIMITS.MAX_SESSIONS) return; const sessionsWithMemory: Array<[string, SessionMemoryState]> = []; state.messages.forEach((_, sessionId) => { const memoryState = state.sessionMemoryState.get(sessionId) || { viewportAnchor: 0, isStreaming: false, lastAccessedAt: 0, backgroundMessageCount: 0, }; sessionsWithMemory.push([sessionId, memoryState]); }); const evictable = sessionsWithMemory.filter(([id, memState]) => id !== currentSessionId && !memState.isStreaming); if (evictable.length === 0) return; evictable.sort((a, b) => a[1].lastAccessedAt - b[1].lastAccessedAt); const lruSessionId = evictable[0][0]; set((state) => { const removedMessages = state.messages.get(lruSessionId) || []; const removedIds = removedMessages.map((message) => message.info.id); const newMessages = new Map(state.messages); const newMemoryState = new Map(state.sessionMemoryState); newMessages.delete(lruSessionId); newMemoryState.delete(lruSessionId); const result: Record = { messages: newMessages, sessionMemoryState: newMemoryState, }; const nextPendingParts = new Map(state.pendingAssistantParts); let pendingChanged = false; nextPendingParts.forEach((entry, messageId) => { if (entry.sessionId === lruSessionId) { nextPendingParts.delete(messageId); pendingChanged = true; } }); if (pendingChanged) { result.pendingAssistantParts = nextPendingParts; } const nextCompaction = new Map(state.sessionCompactionUntil); if (nextCompaction.delete(lruSessionId)) { result.sessionCompactionUntil = nextCompaction; } clearLifecycleTimersForIds(removedIds); const updatedLifecycle = removeLifecycleEntries(state.messageStreamStates, removedIds); if (updatedLifecycle !== state.messageStreamStates) { result.messageStreamStates = updatedLifecycle; } const nextIndex = removeMessageSessionIndexEntries( result.messageSessionIndex ?? state.messageSessionIndex, removedIds ); if (nextIndex !== (result.messageSessionIndex ?? state.messageSessionIndex)) { result.messageSessionIndex = nextIndex; } const nextStreamingIds = setStreamingIdForSession(state.streamingMessageIds, lruSessionId, null); if (nextStreamingIds !== state.streamingMessageIds) { result.streamingMessageIds = nextStreamingIds; } if (state.abortControllers.has(lruSessionId)) { const nextControllers = new Map(state.abortControllers); nextControllers.delete(lruSessionId); result.abortControllers = nextControllers; } return result; }); }, loadMoreMessages: async (sessionId: string, direction: "up" | "down" = "up") => { const state = get(); const currentMessages = state.messages.get(sessionId); const memoryState = state.sessionMemoryState.get(sessionId); if (!currentMessages || !memoryState) { return; } if (memoryState.totalAvailableMessages && currentMessages.length >= memoryState.totalAvailableMessages) { return; } const allMessages = await executeWithSessionDirectory(sessionId, () => opencodeClient.getSessionMessages(sessionId)); if (direction === "up" && currentMessages.length > 0) { const firstCurrentMessage = currentMessages[0]; const indexInAll = allMessages.findIndex((m) => m.info.id === firstCurrentMessage.info.id); if (indexInAll > 0) { const loadCount = Math.min(MEMORY_LIMITS.VIEWPORT_MESSAGES, indexInAll); const newMessages = allMessages.slice(indexInAll - loadCount, indexInAll); set((state) => { const updatedMessages = [...newMessages, ...currentMessages]; const dedupedMessages = dedupeMessagesById(updatedMessages); const newMessagesMap = new Map(state.messages); newMessagesMap.set(sessionId, dedupedMessages); const addedCount = Math.max(0, dedupedMessages.length - currentMessages.length); const newMemoryState = new Map(state.sessionMemoryState); newMemoryState.set(sessionId, { ...memoryState, viewportAnchor: memoryState.viewportAnchor + addedCount, hasMoreAbove: indexInAll - loadCount > 0, totalAvailableMessages: allMessages.length, }); return { messages: newMessagesMap, sessionMemoryState: newMemoryState, }; }); } else if (indexInAll === 0) { set((state) => { const newMemoryState = new Map(state.sessionMemoryState); newMemoryState.set(sessionId, { ...memoryState, hasMoreAbove: false, }); return { sessionMemoryState: newMemoryState }; }); } } }, getLastMessageModel: (sessionId: string) => { const { messages } = get(); const sessionMessages = messages.get(sessionId); if (!sessionMessages || sessionMessages.length === 0) { return null; } for (let i = sessionMessages.length - 1; i >= 0; i--) { const message = sessionMessages[i]; if (message.info.role === "assistant" && "providerID" in message.info && "modelID" in message.info) { return { providerID: (message.info as any).providerID, modelID: (message.info as any).modelID, }; } } return null; }, }), { name: "message-store", storage: createJSONStorage(() => getSafeStorage()), partialize: (state: MessageStore) => ({ lastUsedProvider: state.lastUsedProvider, sessionMemoryState: Array.from(state.sessionMemoryState.entries()).map(([sessionId, memory]) => [ sessionId, { viewportAnchor: memory.viewportAnchor, isStreaming: memory.isStreaming, lastAccessedAt: memory.lastAccessedAt, backgroundMessageCount: memory.backgroundMessageCount, totalAvailableMessages: memory.totalAvailableMessages, hasMoreAbove: memory.hasMoreAbove, trimmedHeadMaxId: memory.trimmedHeadMaxId, }, ]), sessionAbortFlags: Array.from(state.sessionAbortFlags.entries()).map(([sessionId, record]) => [ sessionId, { timestamp: record.timestamp, acknowledged: record.acknowledged }, ]), }), merge: (persistedState: any, currentState: MessageStore): MessageStore => { if (!persistedState) { return currentState; } let restoredMemoryState = currentState.sessionMemoryState; if (Array.isArray(persistedState.sessionMemoryState)) { restoredMemoryState = new Map( persistedState.sessionMemoryState.map((entry: [string, SessionMemoryState]) => entry) ); } let restoredAbortFlags = currentState.sessionAbortFlags; if (Array.isArray(persistedState.sessionAbortFlags)) { restoredAbortFlags = new Map(persistedState.sessionAbortFlags); } return { ...currentState, lastUsedProvider: persistedState.lastUsedProvider ?? currentState.lastUsedProvider, sessionMemoryState: restoredMemoryState, sessionAbortFlags: restoredAbortFlags, }; }, } ), { name: "message-store", } ) );