fix(sync): remove stale-delta skip and add parts-gap recovery (#889)

The pipeline's stale-delta mechanism incorrectly marked all
message.part.delta events as stale when a message.part.updated
coalesced, regardless of queue position. This caused valid streaming
deltas to be silently dropped, resulting in blank or incomplete
assistant messages.

Additionally, when part events were dropped by the reducer (missing
parts array or partID not found), there was no recovery path — the
state stayed permanently out of sync until the next SSE reconnect
or manual refresh.

Also discovered: message.updated that successfully writes an assistant
message but has empty parts would render a blank bubble, with no
repair triggered since repair only ran on reducer return false.

Changes:
- Remove staleDeltas Set and deltaKey from event-pipeline.ts
- Coalesce still replaces same-key events, but deltas are never skipped
- Add enqueuePartsRepair + repairSessionParts to sync-context.tsx
  (5s cooldown, deduped, async SDK re-fetch)
- Trigger repair on reducer return false for part events
- Trigger repair on message.updated return true with empty parts
- Add sync debug.ts with gated diagnostic logging
- Add pipeline coalescing tests
This commit is contained in:
jwcrystal
2026-04-11 23:40:47 +03:00
committed by GitHub
parent 1c7a0b8ddd
commit 964c209ff6
5 changed files with 351 additions and 18 deletions
+83
View File
@@ -0,0 +1,83 @@
/**
* Sync debug logging — gated behind localStorage flag.
*
* Enable in browser console:
* localStorage.setItem("openchamber:sync:debug", "1")
*
* Disable:
* localStorage.removeItem("openchamber:sync:debug")
*
* All checks are early-returns on the hot path — zero cost when disabled.
*/
const FLAG_KEY = "openchamber:sync:debug"
let _enabled: boolean | undefined
export function isSyncDebugEnabled(): boolean {
if (_enabled !== undefined) return _enabled
try {
_enabled = typeof localStorage !== "undefined" && localStorage.getItem(FLAG_KEY) === "1"
} catch {
_enabled = false
}
return _enabled
}
/** Force-refresh the flag (call after user toggles localStorage). */
export function refreshSyncDebugFlag(): void {
_enabled = undefined
}
type SyncDebugCategory = "pipeline" | "reducer" | "dispatch"
function log(cat: SyncDebugCategory, ...args: unknown[]): void {
if (!isSyncDebugEnabled()) return
const tag = `%c[sync:${cat}]`
const style = "color: #888"
console.log(tag, style, ...args)
}
export const syncDebug = {
pipeline: {
/** Event coalesced (replaced an earlier event in the queue). */
coalesced: (eventType: string, coalesceKey: string) =>
log("pipeline", "coalesced", eventType, coalesceKey),
/** Flush batch dispatched. */
flush: (count: number) =>
log("pipeline", "flush", `${count} events`),
},
reducer: {
/** message.updated skipped because role/finish/completed matched existing. */
messageUpdatedUnchanged: (sessionID: string, messageID: string, role: string, finish: unknown, completed: unknown) =>
log("reducer", "message.updated UNCHANGED (skipped)", { sessionID, messageID, role, finish, completed }),
/** message.part.updated arrived but no parts array exists for this messageID. */
partUpdatedNoExistingParts: (messageID: string, partID: string, partType: string) =>
log("reducer", "message.part.updated NO EXISTING PARTS", { messageID, partID, partType }),
/** message.part.delta arrived but parts array missing — silently dropped. */
partDeltaNoParts: (messageID: string, partID: string) =>
log("reducer", "message.part.delta DROPPED (no parts array)", { messageID, partID }),
/** message.part.delta arrived but partID not found in parts array. */
partDeltaNotFound: (messageID: string, partID: string) =>
log("reducer", "message.part.delta DROPPED (partID not found)", { messageID, partID }),
/** SKIP_PARTS filtered out a part. */
partSkipped: (messageID: string, partID: string, partType: string) =>
log("reducer", "message.part.updated SKIPPED (type filtered)", { messageID, partID, partType }),
},
dispatch: {
/** Event dispatched to store but reducer returned false (no state change). */
eventNoChange: (eventType: string, sessionID?: string, messageID?: string) =>
log("dispatch", "event → no change", { eventType, sessionID, messageID }),
/** Event applied to store successfully. */
eventApplied: (eventType: string, sessionID?: string, messageID?: string) =>
log("dispatch", "event → applied", { eventType, sessionID, messageID }),
},
} as const