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
@@ -35,6 +35,22 @@ function createSdkWithSingleEvent(event, hold) {
};
}
// Helper to create an SDK that yields multiple events in sequence, then holds.
function createSdkWithEvents(events, hold) {
return {
global: {
event: async () => ({
stream: (async function* () {
for (const event of events) {
yield event;
}
await hold;
})(),
}),
},
};
}
describe('createEventPipeline', () => {
it('falls back to payload.properties.directory when the SDK event omits top-level directory', async () => {
installDomStubs();
@@ -192,4 +208,136 @@ describe('createEventPipeline', () => {
expect(received[0].directory).toBe('global');
expect(received[0].payload.type).toBe('server.connected');
});
it('delivers message.part.delta events after a coalesced message.part.updated (no stale-delta skip)', async () => {
installDomStubs();
let releaseStream;
const hold = new Promise((resolve) => {
releaseStream = resolve;
});
const received = [];
// Simulate: part.updated arrives first, then delta, then part.updated again (coalesces with first).
// After coalescing, the delta should still be delivered — NOT skipped.
const directory = '/test/dir';
const sdk = createSdkWithEvents([
// T0: message.part.updated for part-A
{
payload: {
type: 'message.part.updated',
properties: {
directory,
part: { id: 'part-A', type: 'text', messageID: 'msg-1' },
},
},
},
// T1: message.part.delta for part-A (should flow through even after coalesce)
{
payload: {
type: 'message.part.delta',
properties: {
directory,
messageID: 'msg-1',
partID: 'part-A',
field: 'text',
delta: ' world',
},
},
},
// T2: message.part.updated for part-A — coalesces with T0
{
payload: {
type: 'message.part.updated',
properties: {
directory,
part: { id: 'part-A', type: 'text', messageID: 'msg-1' },
},
},
},
], hold);
const delivered = new Promise((resolve) => {
const { cleanup } = createEventPipeline({
sdk,
onEvent: (dir, payload) => {
received.push({ directory: dir, payload });
if (received.length === 2) {
cleanup();
releaseStream();
resolve();
}
},
});
});
await delivered;
// Coalescing means T0 and T2 merge into one event at T0's queue position.
// The delta is a different event type with no coalesce key, so it gets
// its own queue slot. After coalesce:
// - queue[0] = coalesced part.updated (from T2, replacing T0)
// - queue[1] = part.delta (from T1)
// Total: 2 events delivered
expect(received.length).toBe(2);
// The first event should be the coalesced message.part.updated
expect(received[0].payload.type).toBe('message.part.updated');
// The delta MUST be delivered — it should NOT be skipped
expect(received[1].payload.type).toBe('message.part.delta');
expect(received[1].payload.properties.delta).toBe(' world');
});
it('coalesces message.part.updated events for the same part', async () => {
installDomStubs();
let releaseStream;
const hold = new Promise((resolve) => {
releaseStream = resolve;
});
const received = [];
const directory = '/test/dir';
const sdk = createSdkWithEvents([
{
payload: {
type: 'message.part.updated',
properties: {
directory,
part: { id: 'part-A', type: 'text', messageID: 'msg-1' },
},
},
},
{
payload: {
type: 'message.part.updated',
properties: {
directory,
part: { id: 'part-A', type: 'text', messageID: 'msg-1' },
},
},
},
], hold);
const delivered = new Promise((resolve) => {
const { cleanup } = createEventPipeline({
sdk,
onEvent: (dir, payload) => {
received.push({ directory: dir, payload });
cleanup();
releaseStream();
resolve();
},
});
});
await delivered;
// Only 1 event should be delivered (coalesced)
expect(received.length).toBe(1);
expect(received[0].payload.type).toBe('message.part.updated');
});
});