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
+10 -12
View File
@@ -33,7 +33,7 @@
"@ibm/plex": "^6.4.1",
"@lezer/highlight": "^1.2.3",
"@octokit/rest": "^22.0.1",
"@opencode-ai/sdk": "^1.17.9",
"@opencode-ai/sdk": "^1.17.12",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -100,7 +100,7 @@
},
"packages/electron": {
"name": "@openchamber/electron",
"version": "1.13.4",
"version": "1.13.8",
"dependencies": {
"@openchamber/web": "workspace:*",
"electron-context-menu": "^4.1.2",
@@ -136,7 +136,7 @@
},
"packages/ui": {
"name": "@openchamber/ui",
"version": "1.13.4",
"version": "1.13.8",
"dependencies": {
"@aparajita/capacitor-secure-storage": "^8.0.0",
"@base-ui/react": "^1.4.0",
@@ -173,7 +173,7 @@
"@fontsource/ibm-plex-sans": "^5.1.1",
"@ibm/plex": "^6.4.1",
"@lezer/highlight": "^1.2.3",
"@opencode-ai/sdk": "^1.17.9",
"@opencode-ai/sdk": "^1.17.12",
"@pierre/diffs": "1.3.0-beta.6",
"@replit/codemirror-vim": "^6.3.0",
"@simplewebauthn/browser": "13.3.0",
@@ -242,10 +242,10 @@
},
"packages/vscode": {
"name": "openchamber",
"version": "1.13.4",
"version": "1.13.8",
"dependencies": {
"@openchamber/ui": "workspace:*",
"@opencode-ai/sdk": "^1.17.9",
"@opencode-ai/sdk": "^1.17.12",
"adm-zip": "^0.5.16",
"jsonc-parser": "^3.3.1",
"react": "^19.1.1",
@@ -265,14 +265,14 @@
},
"packages/web": {
"name": "@openchamber/web",
"version": "1.13.4",
"version": "1.13.8",
"bin": {
"openchamber": "./bin/cli.js",
},
"dependencies": {
"@clack/prompts": "^1.1.0",
"@octokit/rest": "^22.0.1",
"@opencode-ai/sdk": "^1.17.9",
"@opencode-ai/sdk": "^1.17.12",
"@simplewebauthn/server": "13.3.1",
"adm-zip": "^0.5.16",
"better-sqlite3": "^12.10.0",
@@ -1007,7 +1007,7 @@
"@openchamber/web": ["@openchamber/web@workspace:packages/web"],
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.17.9", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-MHmXEpGPHkg14v1p+cUlIOUxd6DQdSElfau9nqY7tcDI0x5r4Y8D0dKXcyAh0Gc73ptaGW67Vg84nkcV6O27Pw=="],
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.17.12", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-N8kazWO0ZLCHWYFuZQt1UJM+bWxY6g1auSG6SvD1+K3+W+nw2qIhDAUGNCD0KVW3bY2LCwvfWvpG2ZbVGCHC0Q=="],
"@paralleldrive/cuid2": ["@paralleldrive/cuid2@2.3.1", "", { "dependencies": { "@noble/hashes": "^1.1.5" } }, "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw=="],
@@ -3387,7 +3387,7 @@
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
"wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
@@ -3739,8 +3739,6 @@
"serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
"serve-sim/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
"sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
+1 -1
View File
@@ -110,7 +110,7 @@
"@ibm/plex": "^6.4.1",
"@lezer/highlight": "^1.2.3",
"@octokit/rest": "^22.0.1",
"@opencode-ai/sdk": "^1.17.9",
"@opencode-ai/sdk": "^1.17.12",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
+1 -1
View File
@@ -46,7 +46,7 @@
"@fontsource/ibm-plex-sans": "^5.1.1",
"@ibm/plex": "^6.4.1",
"@lezer/highlight": "^1.2.3",
"@opencode-ai/sdk": "^1.17.9",
"@opencode-ai/sdk": "^1.17.12",
"@pierre/diffs": "1.3.0-beta.6",
"@replit/codemirror-vim": "^6.3.0",
"@simplewebauthn/browser": "13.3.0",
+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)
}
+1 -1
View File
@@ -244,7 +244,7 @@
},
"dependencies": {
"@openchamber/ui": "workspace:*",
"@opencode-ai/sdk": "^1.17.9",
"@opencode-ai/sdk": "^1.17.12",
"adm-zip": "^0.5.16",
"jsonc-parser": "^3.3.1",
"react": "^19.1.1",
+1 -1
View File
@@ -25,7 +25,7 @@
"dependencies": {
"@clack/prompts": "^1.1.0",
"@octokit/rest": "^22.0.1",
"@opencode-ai/sdk": "^1.17.9",
"@opencode-ai/sdk": "^1.17.12",
"@simplewebauthn/server": "13.3.1",
"adm-zip": "^0.5.16",
"better-sqlite3": "^12.10.0",
@@ -120,6 +120,17 @@ export function createGlobalMessageStreamWsBridge({
for (const socket of Array.from(clients)) {
if (!readyClients.has(socket)) {
markReady(socket, clientLastEventIds.get(socket) ?? '');
continue;
}
if (status.wasReady) {
const sent = sendMessageStreamWsFrame(socket, {
type: 'ready',
scope: 'global',
});
if (!sent) {
removeClient(socket);
}
}
}
return;
@@ -435,7 +435,7 @@ describe('message stream websocket runtime', () => {
return createSseResponse({
signal: options.signal,
holdOpen: false,
holdOpen: true,
blocks: [
'id: evt-2\ndata: {"type":"server.connected","properties":{}}\n\n',
],
@@ -451,7 +451,7 @@ describe('message stream websocket runtime', () => {
const readyFrames = socket.sent.filter((frame) => frame.type === 'ready');
const eventFrames = socket.sent.filter((frame) => frame.type === 'event' && frame.payload?.type === 'server.connected');
expect(readyFrames).toHaveLength(1);
expect(readyFrames.length).toBeGreaterThanOrEqual(2);
expect(eventFrames.length).toBeGreaterThanOrEqual(2);
expect(fetchCalls.slice(0, 2)).toEqual([null, 'evt-1']);
expect(triggerHealthCheckCalls).toBe(0);