fix(sync): resync the active session after reconnect transitions (#1011)

* fix(sync): resync the active session after reconnect transitions

Include the viewed session in reconnect recovery and trigger a targeted resync on transport switches so the active chat catches up after missed live events.

Constraint: Keep the fix in the sync layer instead of adding ChatContainer-only recovery

Rejected: Widen reconnect heuristics for every cached session | broader recovery scope than needed

Confidence: high

Scope-risk: narrow

* Avoid no-op reconnect resync store writes

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
jwcrystal
2026-04-24 12:39:58 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent e9b117c88b
commit 5c7f5aa4f7
3 changed files with 235 additions and 59 deletions
@@ -0,0 +1,69 @@
import { describe, expect, test } from "bun:test"
import type { Message, SessionStatus } from "@opencode-ai/sdk/v2/client"
import type { Session } from "@opencode-ai/sdk/v2"
import { getReconnectCandidateSessionIds } from "./reconnect-recovery"
function createSession(id: string, overrides: Partial<Session> = {}): Session {
return {
id,
title: id,
time: { created: 1, updated: 1 },
version: "1",
...overrides,
} as Session
}
function createAssistantMessage(id: string, sessionID: string, completed?: number): Message {
return {
id,
sessionID,
role: "assistant",
time: completed ? { created: 1, updated: 1, completed } : { created: 1, updated: 1 },
parts: [],
} as unknown as Message
}
describe("getReconnectCandidateSessionIds", () => {
test("includes non-idle, incomplete assistant, and parent sessions", () => {
const busyStatus = { type: "busy" } as SessionStatus
expect(getReconnectCandidateSessionIds({
session: [
createSession("busy"),
createSession("child", { parentID: "parent" }),
createSession("parent"),
createSession("incomplete"),
],
session_status: { busy: busyStatus },
message: {
incomplete: [createAssistantMessage("m-1", "incomplete")],
},
}).sort()).toEqual(["busy", "incomplete", "parent"])
})
test("includes the currently viewed session even when it looks idle and complete", () => {
expect(getReconnectCandidateSessionIds({
session: [createSession("active")],
session_status: { active: { type: "idle" } as SessionStatus },
message: {
active: [createAssistantMessage("m-1", "active", 1)],
},
}, {
directory: "/repo",
viewedSession: { directory: "/repo", sessionId: "active" },
}).sort()).toContain("active")
})
test("does not include a viewed session from another directory", () => {
expect(getReconnectCandidateSessionIds({
session: [createSession("active")],
session_status: { active: { type: "idle" } as SessionStatus },
message: {
active: [createAssistantMessage("m-1", "active", 1)],
},
}, {
directory: "/repo-a",
viewedSession: { directory: "/repo-b", sessionId: "active" },
}).sort()).not.toContain("active")
})
})
@@ -0,0 +1,62 @@
import type { SessionStatus, Message } from "@opencode-ai/sdk/v2/client"
import type { Session } from "@opencode-ai/sdk/v2"
type ReconnectRecoveryState = {
session: Session[]
session_status?: Record<string, SessionStatus>
message?: Record<string, Message[]>
}
export type ViewedSessionRecoveryTarget = {
directory: string
sessionId: string
}
type ReconnectCandidateOptions = {
directory?: string
viewedSession?: ViewedSessionRecoveryTarget | null
}
export function getReconnectCandidateSessionIds(state: ReconnectRecoveryState, options?: ReconnectCandidateOptions) {
const ids = new Set<string>()
for (const [sessionId, status] of Object.entries(state.session_status ?? {})) {
if (status && status.type !== "idle") ids.add(sessionId)
}
for (const [sessionId, messages] of Object.entries(state.message ?? {})) {
const lastMessage = messages[messages.length - 1]
if (
lastMessage
&& lastMessage.role === "assistant"
&& typeof (lastMessage as { time?: { completed?: number } }).time?.completed !== "number"
) {
ids.add(sessionId)
}
}
const parentIds = new Set<string>()
for (const session of state.session) {
const parentId = (session as Session & { parentID?: string | null }).parentID
if (parentId) {
parentIds.add(parentId)
}
}
for (const pid of parentIds) {
ids.add(pid)
}
const viewedSession = options?.viewedSession
if (viewedSession?.sessionId && viewedSession.directory === options?.directory) {
const sessionId = viewedSession.sessionId
const sessionExists = state.session.some((session) => session.id === sessionId)
|| Object.hasOwn(state.session_status ?? {}, sessionId)
|| Object.hasOwn(state.message ?? {}, sessionId)
if (sessionExists) {
ids.add(sessionId)
}
}
return Array.from(ids)
}
+104 -59
View File
@@ -24,6 +24,7 @@ import { setActionRefs } from "./session-actions"
import { setSyncRefs } from "./sync-refs"
import { stripMessageDiffSnapshots, stripSessionDiffSnapshots } from "./sanitize"
import { syncDebug } from "./debug"
import { getReconnectCandidateSessionIds } from "./reconnect-recovery"
import { opencodeClient } from "@/lib/opencode/client"
import { usePermissionStore } from "@/stores/permissionStore"
import { useConfigStore } from "@/stores/useConfigStore"
@@ -133,6 +134,7 @@ const requestSignature = (items: Array<{ id: string }> | undefined): string => {
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const partRepairSignature = (part: Part): string => JSON.stringify(part)
const syncSnapshotSignature = (value: unknown): string => JSON.stringify(value)
function haveEquivalentPartSnapshots(left: Part[] | undefined, right: Part[]): boolean {
if (!left) {
@@ -160,6 +162,36 @@ function haveEquivalentPartSnapshots(left: Part[] | undefined, right: Part[]): b
return true
}
function haveEquivalentMessageSnapshots(left: Message[] | undefined, right: Message[]): boolean {
if (!left) {
return right.length === 0
}
if (left.length !== right.length) {
return false
}
for (let index = 0; index < left.length; index += 1) {
const leftMessage = left[index]
const rightMessage = right[index]
if (!leftMessage || !rightMessage) {
return false
}
if (leftMessage.id !== rightMessage.id) {
return false
}
if (syncSnapshotSignature(leftMessage) !== syncSnapshotSignature(rightMessage)) {
return false
}
}
return true
}
function haveEquivalentSyncSnapshots(left: unknown, right: unknown): boolean {
return syncSnapshotSignature(left) === syncSnapshotSignature(right)
}
// ---------------------------------------------------------------------------
// Parts-gap recovery — when SSE events arrive but parts are missing,
// trigger a targeted re-fetch for the affected sessions.
@@ -273,40 +305,13 @@ function isRecentBoot() {
return bootingRoot || Date.now() - bootedAt < BOOT_DEBOUNCE_MS
}
function getReconnectCandidateSessionIds(state: State) {
const ids = new Set<string>()
for (const [sessionId, status] of Object.entries(state.session_status ?? {})) {
if (status && status.type !== "idle") ids.add(sessionId)
function getViewedSessionRecoveryTarget(directory: string) {
if (!_activeDirectory || !_activeSession) return null
if (directory !== _activeDirectory) return null
return {
directory: _activeDirectory,
sessionId: _activeSession,
}
for (const [sessionId, messages] of Object.entries(state.message ?? {})) {
const lastMessage = messages[messages.length - 1]
if (
lastMessage
&& lastMessage.role === "assistant"
&& typeof (lastMessage as { time?: { completed?: number } }).time?.completed !== "number"
) {
ids.add(sessionId)
}
}
// Ensure parent sessions of any child sessions are also resynced.
// A child may have completed during the disconnect gap without the
// store knowing (SSE events lost). The parent's task tool part needs
// to reflect the child's final state.
const parentIds = new Set<string>()
for (const session of state.session) {
const parentId = (session as Session & { parentID?: string | null }).parentID
if (parentId) {
parentIds.add(parentId)
}
}
for (const pid of parentIds) {
ids.add(pid)
}
return Array.from(ids)
}
function toSessionStatus(status: Awaited<ReturnType<typeof opencodeClient.getSessionStatus>>[string]): SessionStatus | undefined {
@@ -716,7 +721,10 @@ async function resyncDirectoryAfterReconnect(
routingIndex: EventRoutingIndex,
) {
const current = store.getState()
const candidateSessionIds = getReconnectCandidateSessionIds(current)
const candidateSessionIds = getReconnectCandidateSessionIds(current, {
directory,
viewedSession: getViewedSessionRecoveryTarget(directory),
})
if (candidateSessionIds.length === 0) return
const nextStatuses = await opencodeClient.getSessionStatusForDirectory(directory)
@@ -730,9 +738,23 @@ async function resyncDirectoryAfterReconnect(
}
if (Object.keys(relevantStatuses).length > 0) {
store.setState((state: DirectoryStore) => ({
session_status: { ...state.session_status, ...relevantStatuses },
}))
store.setState((state: DirectoryStore) => {
let changed = false
for (const [sessionId, nextStatus] of Object.entries(relevantStatuses)) {
if (!haveEquivalentSyncSnapshots(state.session_status?.[sessionId], nextStatus)) {
changed = true
break
}
}
if (!changed) {
return state
}
return {
session_status: { ...state.session_status, ...relevantStatuses },
}
})
}
const scopedClient = opencodeClient.getScopedSdkClient(directory)
@@ -752,17 +774,19 @@ async function resyncDirectoryAfterReconnect(
.sort((a, b) => cmp(a.id, b.id))
store.setState((state: DirectoryStore) => {
const sessions = [...state.session]
const sessionIndex = sessions.findIndex((item) => item.id === nextSession.id)
const sessionIndex = state.session.findIndex((item) => item.id === nextSession.id)
let sessions = state.session
let sessionChanged = false
let sessionTotal = state.sessionTotal
if (sessionIndex >= 0) {
if (sessions[sessionIndex] !== nextSession) {
if (!haveEquivalentSyncSnapshots(sessions[sessionIndex], nextSession)) {
sessions = [...state.session]
sessions[sessionIndex] = nextSession
sessionChanged = true
}
} else {
sessions = [...state.session]
sessions.push(nextSession)
sessions.sort((a, b) => cmp(a.id, b.id))
if (!nextSession.parentID) sessionTotal += 1
@@ -772,19 +796,32 @@ async function resyncDirectoryAfterReconnect(
// Merge parts: overwrite only messages present in the fetch snapshot.
// Do NOT delete parts for messages that may have been added by SSE
// events arriving between the fetch and the setState — those are more recent.
const nextPartState = { ...state.part }
let nextPartState = state.part
let partsChanged = false
for (const record of records) {
const messageId = record?.info?.id
if (!messageId) continue
nextPartState[messageId] = (record.parts ?? [])
const nextParts = (record.parts ?? [])
.filter((part) => !!part?.id && !RECONNECT_SKIP_PARTS.has(part.type))
.sort((a, b) => cmp(a.id, b.id))
if (!haveEquivalentPartSnapshots(state.part[messageId], nextParts)) {
if (!partsChanged) {
nextPartState = { ...state.part }
partsChanged = true
}
nextPartState[messageId] = nextParts
}
}
const messagesChanged = !haveEquivalentMessageSnapshots(state.message[sessionId], nextMessages)
if (!sessionChanged && !messagesChanged && !partsChanged) {
return state
}
return {
...(sessionChanged ? { session: sessions, sessionTotal } : {}),
message: { ...state.message, [sessionId]: nextMessages },
part: nextPartState,
...(messagesChanged ? { message: { ...state.message, [sessionId]: nextMessages } } : {}),
...(partsChanged ? { part: nextPartState } : {}),
}
})
@@ -1365,6 +1402,21 @@ export function SyncProvider(props: {
// Abort controller owned by the pipeline closure. Cleanup aborts + flushes.
useEffect(() => {
const reconnectResyncing = new Set<string>()
const triggerRecoveryResync = (directory: string) => {
const store = childStores.children.get(directory)
if (!store) return
if (reconnectResyncing.has(directory)) return
reconnectResyncing.add(directory)
void resyncDirectoryAfterReconnect(directory, store, routingIndex)
.catch(() => {
// Transient failure during resync — next SSE event, transport switch,
// or reconnect will catch up.
})
.finally(() => {
reconnectResyncing.delete(directory)
})
}
const { cleanup } = createEventPipeline({
sdk: props.sdk,
@@ -1381,18 +1433,8 @@ export function SyncProvider(props: {
hasEverConnected: true,
connectionPhase: "connected",
})
for (const [dir, store] of childStores.children) {
if (reconnectResyncing.has(dir)) continue
if (getReconnectCandidateSessionIds(store.getState()).length === 0) continue
reconnectResyncing.add(dir)
void resyncDirectoryAfterReconnect(dir, store, routingIndex)
.catch(() => {
// Transient failure during resync — next SSE event or reconnect will catch up.
})
.finally(() => {
reconnectResyncing.delete(dir)
})
for (const dir of childStores.children.keys()) {
triggerRecoveryResync(dir)
}
},
onDisconnect: (reason) => {
@@ -1404,14 +1446,17 @@ export function SyncProvider(props: {
})
},
onTransportSwitch: () => {
// Transport switched (e.g. WS timeout → SSE fallback) without
// actual disconnection. No events lost — just update connection
// state without triggering a full directory resync.
// Transport switched (e.g. WS timeout → SSE fallback) without a full
// disconnect. If the active session missed the transition into a busy
// turn, force a targeted resync for the viewed directory.
useConfigStore.setState({
isConnected: true,
hasEverConnected: true,
connectionPhase: "connected",
})
if (_activeDirectory) {
triggerRecoveryResync(_activeDirectory)
}
},
})
return cleanup