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", "@ibm/plex": "^6.4.1",
"@lezer/highlight": "^1.2.3", "@lezer/highlight": "^1.2.3",
"@octokit/rest": "^22.0.1", "@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-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -100,7 +100,7 @@
}, },
"packages/electron": { "packages/electron": {
"name": "@openchamber/electron", "name": "@openchamber/electron",
"version": "1.13.4", "version": "1.13.8",
"dependencies": { "dependencies": {
"@openchamber/web": "workspace:*", "@openchamber/web": "workspace:*",
"electron-context-menu": "^4.1.2", "electron-context-menu": "^4.1.2",
@@ -136,7 +136,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@openchamber/ui", "name": "@openchamber/ui",
"version": "1.13.4", "version": "1.13.8",
"dependencies": { "dependencies": {
"@aparajita/capacitor-secure-storage": "^8.0.0", "@aparajita/capacitor-secure-storage": "^8.0.0",
"@base-ui/react": "^1.4.0", "@base-ui/react": "^1.4.0",
@@ -173,7 +173,7 @@
"@fontsource/ibm-plex-sans": "^5.1.1", "@fontsource/ibm-plex-sans": "^5.1.1",
"@ibm/plex": "^6.4.1", "@ibm/plex": "^6.4.1",
"@lezer/highlight": "^1.2.3", "@lezer/highlight": "^1.2.3",
"@opencode-ai/sdk": "^1.17.9", "@opencode-ai/sdk": "^1.17.12",
"@pierre/diffs": "1.3.0-beta.6", "@pierre/diffs": "1.3.0-beta.6",
"@replit/codemirror-vim": "^6.3.0", "@replit/codemirror-vim": "^6.3.0",
"@simplewebauthn/browser": "13.3.0", "@simplewebauthn/browser": "13.3.0",
@@ -242,10 +242,10 @@
}, },
"packages/vscode": { "packages/vscode": {
"name": "openchamber", "name": "openchamber",
"version": "1.13.4", "version": "1.13.8",
"dependencies": { "dependencies": {
"@openchamber/ui": "workspace:*", "@openchamber/ui": "workspace:*",
"@opencode-ai/sdk": "^1.17.9", "@opencode-ai/sdk": "^1.17.12",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"jsonc-parser": "^3.3.1", "jsonc-parser": "^3.3.1",
"react": "^19.1.1", "react": "^19.1.1",
@@ -265,14 +265,14 @@
}, },
"packages/web": { "packages/web": {
"name": "@openchamber/web", "name": "@openchamber/web",
"version": "1.13.4", "version": "1.13.8",
"bin": { "bin": {
"openchamber": "./bin/cli.js", "openchamber": "./bin/cli.js",
}, },
"dependencies": { "dependencies": {
"@clack/prompts": "^1.1.0", "@clack/prompts": "^1.1.0",
"@octokit/rest": "^22.0.1", "@octokit/rest": "^22.0.1",
"@opencode-ai/sdk": "^1.17.9", "@opencode-ai/sdk": "^1.17.12",
"@simplewebauthn/server": "13.3.1", "@simplewebauthn/server": "13.3.1",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"better-sqlite3": "^12.10.0", "better-sqlite3": "^12.10.0",
@@ -1007,7 +1007,7 @@
"@openchamber/web": ["@openchamber/web@workspace:packages/web"], "@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=="], "@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=="], "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=="], "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=="], "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=="], "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=="], "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", "@ibm/plex": "^6.4.1",
"@lezer/highlight": "^1.2.3", "@lezer/highlight": "^1.2.3",
"@octokit/rest": "^22.0.1", "@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-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
+1 -1
View File
@@ -46,7 +46,7 @@
"@fontsource/ibm-plex-sans": "^5.1.1", "@fontsource/ibm-plex-sans": "^5.1.1",
"@ibm/plex": "^6.4.1", "@ibm/plex": "^6.4.1",
"@lezer/highlight": "^1.2.3", "@lezer/highlight": "^1.2.3",
"@opencode-ai/sdk": "^1.17.9", "@opencode-ai/sdk": "^1.17.12",
"@pierre/diffs": "1.3.0-beta.6", "@pierre/diffs": "1.3.0-beta.6",
"@replit/codemirror-vim": "^6.3.0", "@replit/codemirror-vim": "^6.3.0",
"@simplewebauthn/browser": "13.3.0", "@simplewebauthn/browser": "13.3.0",
+37 -1
View File
@@ -884,6 +884,11 @@ const childStoreHasMessagePartState = (
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 = ( const resolveDirectoryFromRoutingIndex = (
routingIndex: EventRoutingIndex, routingIndex: EventRoutingIndex,
rawDirectory: string, rawDirectory: string,
@@ -932,6 +937,15 @@ const resolveDirectoryFromRoutingIndex = (
return dir 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 // Single-store fallback: if there's only one directory, use it
@@ -949,6 +963,23 @@ 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 = ( const updateRoutingIndexFromEvent = (
routingIndex: EventRoutingIndex, routingIndex: EventRoutingIndex,
directory: string, directory: string,
@@ -1562,7 +1593,12 @@ function handleEvent(
// Snapshot materialization is driven by typed reducer outcomes, not by // Snapshot materialization is driven by typed reducer outcomes, not by
// inferring meaning from a generic false/no-change result. // inferring meaning from a generic false/no-change result.
if (materializationResult) { if (materializationResult) {
const materializationSessionID = materializationResult.sessionID ?? getSessionIdFromPayload(payload) ?? undefined const materializationSessionID = resolveMaterializationSessionID(
materializationResult.sessionID ?? getSessionIdFromPayload(payload) ?? undefined,
materializationResult.messageID ?? getMessageIdFromPayload(payload) ?? undefined,
resolvedDirectory,
routingIndex,
)
if (materializationSessionID) { if (materializationSessionID) {
enqueueSessionMaterialization(resolvedDirectory, materializationSessionID, childStores) enqueueSessionMaterialization(resolvedDirectory, materializationSessionID, childStores)
} }
+1 -1
View File
@@ -244,7 +244,7 @@
}, },
"dependencies": { "dependencies": {
"@openchamber/ui": "workspace:*", "@openchamber/ui": "workspace:*",
"@opencode-ai/sdk": "^1.17.9", "@opencode-ai/sdk": "^1.17.12",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"jsonc-parser": "^3.3.1", "jsonc-parser": "^3.3.1",
"react": "^19.1.1", "react": "^19.1.1",
+1 -1
View File
@@ -25,7 +25,7 @@
"dependencies": { "dependencies": {
"@clack/prompts": "^1.1.0", "@clack/prompts": "^1.1.0",
"@octokit/rest": "^22.0.1", "@octokit/rest": "^22.0.1",
"@opencode-ai/sdk": "^1.17.9", "@opencode-ai/sdk": "^1.17.12",
"@simplewebauthn/server": "13.3.1", "@simplewebauthn/server": "13.3.1",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"better-sqlite3": "^12.10.0", "better-sqlite3": "^12.10.0",
@@ -120,6 +120,17 @@ export function createGlobalMessageStreamWsBridge({
for (const socket of Array.from(clients)) { for (const socket of Array.from(clients)) {
if (!readyClients.has(socket)) { if (!readyClients.has(socket)) {
markReady(socket, clientLastEventIds.get(socket) ?? ''); markReady(socket, clientLastEventIds.get(socket) ?? '');
continue;
}
if (status.wasReady) {
const sent = sendMessageStreamWsFrame(socket, {
type: 'ready',
scope: 'global',
});
if (!sent) {
removeClient(socket);
}
} }
} }
return; return;
@@ -435,7 +435,7 @@ describe('message stream websocket runtime', () => {
return createSseResponse({ return createSseResponse({
signal: options.signal, signal: options.signal,
holdOpen: false, holdOpen: true,
blocks: [ blocks: [
'id: evt-2\ndata: {"type":"server.connected","properties":{}}\n\n', '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 readyFrames = socket.sent.filter((frame) => frame.type === 'ready');
const eventFrames = socket.sent.filter((frame) => frame.type === 'event' && frame.payload?.type === 'server.connected'); 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(eventFrames.length).toBeGreaterThanOrEqual(2);
expect(fetchCalls.slice(0, 2)).toEqual([null, 'evt-1']); expect(fetchCalls.slice(0, 2)).toEqual([null, 'evt-1']);
expect(triggerHealthCheckCalls).toBe(0); expect(triggerHealthCheckCalls).toBe(0);