fix: recover chat state after idle reconnects

Resyncs active sessions after hidden upstream stream reconnects
Recovers orphaned streaming parts with active-session snapshots
Adds coverage for event-stream reconnect behavior
This commit is contained in:
Bohdan Triapitsyn
2026-07-01 17:21:19 +03:00
parent 73c9431883
commit b60a794e80
8 changed files with 86 additions and 41 deletions
+59 -23
View File
@@ -874,15 +874,20 @@ const childStoreHasSessionState = (
|| Object.prototype.hasOwnProperty.call(state.session_status ?? {}, sessionID)
}
const childStoreHasMessagePartState = (
childStores: ChildStoreManager,
directory: string,
messageID: string,
): boolean => {
const childStoreHasMessagePartState = (
childStores: ChildStoreManager,
directory: string,
messageID: string,
): boolean => {
const store = childStores.getChild(directory)
if (!store) return false
return Object.prototype.hasOwnProperty.call(store.getState().part, messageID)
}
return Object.prototype.hasOwnProperty.call(store.getState().part, messageID)
}
const getActiveDirectoryFallback = (childStores: ChildStoreManager): string | null => {
if (!_activeDirectory || !_activeSession) return null
return childStores.getChild(_activeDirectory) ? _activeDirectory : null
}
const resolveDirectoryFromRoutingIndex = (
routingIndex: EventRoutingIndex,
@@ -927,12 +932,21 @@ const resolveDirectoryFromRoutingIndex = (
}
// Scan child stores for a store that has parts for this message
for (const [dir, store] of childStores.children) {
if (Object.prototype.hasOwnProperty.call(store.getState().part, messageID)) {
return dir
}
}
}
for (const [dir, store] of childStores.children) {
if (Object.prototype.hasOwnProperty.call(store.getState().part, messageID)) {
return dir
}
}
// Some reconnect/idle gaps can deliver part events before the matching
// message.updated event and without a sessionID. If the user is actively
// viewing a session, route the orphaned part event there so the reducer can
// trigger HTTP materialization instead of dropping it as a global event.
const activeDirectory = getActiveDirectoryFallback(childStores)
if (activeDirectory) {
return activeDirectory
}
}
// Single-store fallback: if there's only one directory, use it
if (
@@ -946,8 +960,25 @@ const resolveDirectoryFromRoutingIndex = (
}
}
return normalizedDirectory
}
return normalizedDirectory
}
const resolveMaterializationSessionID = (
materializationSessionID: string | undefined,
messageID: string | undefined,
resolvedDirectory: string,
routingIndex: EventRoutingIndex,
): string | undefined => {
if (materializationSessionID) return materializationSessionID
if (messageID) {
const indexedSessionID = routingIndex.messageSessionById.get(messageID)
if (indexedSessionID) return indexedSessionID
}
if (resolvedDirectory && resolvedDirectory === _activeDirectory && _activeSession) {
return _activeSession
}
return undefined
}
const updateRoutingIndexFromEvent = (
routingIndex: EventRoutingIndex,
@@ -1559,14 +1590,19 @@ function handleEvent(
}
// Snapshot materialization is driven by typed reducer outcomes, not by
// inferring meaning from a generic false/no-change result.
if (materializationResult) {
const materializationSessionID = materializationResult.sessionID ?? getSessionIdFromPayload(payload) ?? undefined
if (materializationSessionID) {
enqueueSessionMaterialization(resolvedDirectory, materializationSessionID, childStores)
}
}
// Snapshot materialization is driven by typed reducer outcomes, not by
// inferring meaning from a generic false/no-change result.
if (materializationResult) {
const materializationSessionID = resolveMaterializationSessionID(
materializationResult.sessionID ?? getSessionIdFromPayload(payload) ?? undefined,
materializationResult.messageID ?? getMessageIdFromPayload(payload) ?? undefined,
resolvedDirectory,
routingIndex,
)
if (materializationSessionID) {
enqueueSessionMaterialization(resolvedDirectory, materializationSessionID, childStores)
}
}
updateRoutingIndexFromEvent(routingIndex, resolvedDirectory, payload)
}