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:
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
|
||||
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import { syncDebug } from "./debug"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -86,13 +87,9 @@ export function createEventPipeline(input: EventPipelineInput) {
|
||||
let queue: QueuedEvent[] = []
|
||||
let buffer: QueuedEvent[] = []
|
||||
const coalesced = new Map<string, number>()
|
||||
const staleDeltas = new Set<string>()
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let last = 0
|
||||
|
||||
const deltaKey = (directory: string, messageID: string, partID: string) =>
|
||||
`${directory}:${messageID}:${partID}`
|
||||
|
||||
// Coalesce key — same-type events for the same entity replace earlier ones
|
||||
const key = (directory: string, payload: Event): string | undefined => {
|
||||
if (payload.type === "session.status") {
|
||||
@@ -109,7 +106,7 @@ export function createEventPipeline(input: EventPipelineInput) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Flush — swap queue, dispatch events, skip stale deltas
|
||||
// Flush — swap queue, dispatch events
|
||||
const flush = () => {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = undefined
|
||||
@@ -117,21 +114,16 @@ export function createEventPipeline(input: EventPipelineInput) {
|
||||
if (queue.length === 0) return
|
||||
|
||||
const events = queue
|
||||
const skip = staleDeltas.size > 0 ? new Set(staleDeltas) : undefined
|
||||
queue = buffer
|
||||
buffer = events
|
||||
queue.length = 0
|
||||
coalesced.clear()
|
||||
staleDeltas.clear()
|
||||
|
||||
last = Date.now()
|
||||
syncDebug.pipeline.flush(events.length)
|
||||
// React 18 batches synchronous setState calls automatically,
|
||||
// equivalent to SolidJS batch()
|
||||
for (const event of events) {
|
||||
if (skip && event.payload.type === "message.part.delta") {
|
||||
const props = event.payload.properties as { messageID: string; partID: string }
|
||||
if (skip.has(deltaKey(event.directory, props.messageID, props.partID))) continue
|
||||
}
|
||||
onEvent(event.directory, event.payload)
|
||||
}
|
||||
|
||||
@@ -214,10 +206,7 @@ export function createEventPipeline(input: EventPipelineInput) {
|
||||
const i = coalesced.get(k)
|
||||
if (i !== undefined) {
|
||||
queue[i] = { directory, payload: normalizedPayload }
|
||||
if (normalizedPayload.type === "message.part.updated") {
|
||||
const part = (normalizedPayload.properties as { part: { messageID: string; id: string } }).part
|
||||
staleDeltas.add(deltaKey(directory, part.messageID, part.id))
|
||||
}
|
||||
syncDebug.pipeline.coalesced(normalizedPayload.type, k)
|
||||
continue
|
||||
}
|
||||
coalesced.set(k, queue.length)
|
||||
|
||||
Reference in New Issue
Block a user