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:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user