fix(sync): unify live session truth across chat and sidebar

This commit is contained in:
Bohdan Triapitsyn
2026-04-16 19:16:32 +03:00
parent 4c4f24a404
commit ddc1039d1c
15 changed files with 904 additions and 299 deletions
@@ -236,7 +236,7 @@ describe('createEventPipeline', () => {
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 () => {
it('skips stale message.part.delta events after a newer message.part.updated for the same field', async () => {
installDomStubs();
let releaseStream;
@@ -246,8 +246,8 @@ describe('createEventPipeline', () => {
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.
// Simulate: part.updated arrives, then delta, then a newer part.updated for the
// same part. The older queued delta becomes stale and must be skipped.
const directory = '/test/dir';
const sdk = createSdkWithEvents([
// T0: message.part.updated for part-A
@@ -260,7 +260,7 @@ describe('createEventPipeline', () => {
},
},
},
// T1: message.part.delta for part-A (should flow through even after coalesce)
// T1: message.part.delta for part-A (should be dropped as stale)
{
payload: {
type: 'message.part.delta',
@@ -290,7 +290,7 @@ describe('createEventPipeline', () => {
sdk,
onEvent: (dir, payload) => {
received.push({ directory: dir, payload });
if (received.length === 2) {
if (received.length === 1) {
cleanup();
releaseStream();
resolve();
@@ -301,20 +301,39 @@ describe('createEventPipeline', () => {
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.length).toBe(1);
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('keeps delta events for other fields on the same part', async () => {
const received = await runPipelineWithEvents([
{
directory: 'dir-a',
payload: {
type: 'message.part.delta',
properties: {
messageID: 'msg-1',
partID: 'part-1',
field: 'reasoning',
delta: 'before',
},
},
},
{
directory: 'dir-a',
payload: {
type: 'message.part.updated',
properties: {
part: { id: 'part-1', type: 'text', messageID: 'msg-1' },
},
},
},
]);
expect(received).toHaveLength(2);
expect(received[0].payload.type).toBe('message.part.delta');
expect(received[0].payload.properties.field).toBe('reasoning');
expect(received[1].payload.type).toBe('message.part.updated');
});
it('coalesces message.part.updated events for the same part', async () => {
@@ -367,6 +386,62 @@ describe('createEventPipeline', () => {
expect(received.length).toBe(1);
expect(received[0].payload.type).toBe('message.part.updated');
});
it('routes events before queueing so coalescing happens on the resolved directory', async () => {
installDomStubs();
let releaseStream;
const hold = new Promise((resolve) => {
releaseStream = resolve;
});
const received = [];
const sdk = createSdkWithEvents([
{
directory: 'global',
payload: {
type: 'message.part.updated',
properties: {
part: { id: 'part-A', type: 'text', messageID: 'msg-1' },
},
},
},
{
directory: '/real-dir',
payload: {
type: 'message.part.updated',
properties: {
part: { id: 'part-A', type: 'text', messageID: 'msg-1', text: 'next' },
},
},
},
], hold);
const delivered = new Promise((resolve) => {
const { cleanup } = createEventPipeline({
sdk,
routeDirectory: (directory, payload) => {
if (payload.type === 'message.part.updated') {
return '/resolved-dir';
}
return directory;
},
onEvent: (dir, payload) => {
received.push({ directory: dir, payload });
cleanup();
releaseStream();
resolve();
},
});
});
await delivered;
expect(received).toHaveLength(1);
expect(received[0].directory).toBe('/resolved-dir');
expect(received[0].payload.type).toBe('message.part.updated');
expect(received[0].payload.properties.part.text).toBe('next');
});
});
// ---------------------------------------------------------------------------
@@ -151,4 +151,50 @@ describe('applyDirectoryEvent', () => {
expect(state.part[messageID]?.[0]?.text).toBe('haha')
})
it('does not let a stale running tool update overwrite a completed tool part', () => {
const state = structuredClone(INITIAL_STATE)
const messageID = 'msg-5'
const partID = 'part-5'
applyDirectoryEvent(state, {
type: 'message.part.updated',
properties: {
part: {
id: partID,
type: 'tool',
messageID,
tool: 'apply_patch',
state: {
status: 'completed',
time: {
start: 10,
end: 20,
},
},
},
},
})
applyDirectoryEvent(state, {
type: 'message.part.updated',
properties: {
part: {
id: partID,
type: 'tool',
messageID,
tool: 'apply_patch',
state: {
status: 'running',
time: {
start: 10,
},
},
},
},
})
expect(state.part[messageID]?.[0]?.state?.status).toBe('completed')
expect(state.part[messageID]?.[0]?.state?.time?.end).toBe(20)
})
})
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'bun:test'
import {
aggregateLiveSessions,
aggregateLiveSessionStatuses,
findLiveSession,
findLiveSessionStatus,
} from '../live-aggregate.ts'
import { deriveLiveActiveNowSessions } from '../../components/session/sidebar/activitySections.ts'
const session = (id, directory, updated, extra = {}) => ({
id,
title: `${id}-title`,
time: { created: updated - 1, updated, archived: undefined },
directory,
...extra,
})
describe('live aggregate', () => {
it('prefers the freshest live session snapshot across child stores', () => {
const states = [
{
session: [session('ses-1', '/a', 10, { title: 'old' })],
session_status: {},
},
{
session: [session('ses-1', '/a', 25, { title: 'new' }), session('ses-2', '/b', 20)],
session_status: {},
},
]
const sessions = aggregateLiveSessions(states)
expect(sessions.map((item) => `${item.id}:${item.title}`)).toEqual(['ses-1:new', 'ses-2:ses-2-title'])
expect(findLiveSession(states, 'ses-1')?.title).toBe('new')
})
it('prefers busy/retry statuses over stale idle snapshots', () => {
const states = [
{
session: [],
session_status: {
'ses-1': { type: 'idle' },
'ses-2': { type: 'idle' },
},
},
{
session: [],
session_status: {
'ses-1': { type: 'busy' },
'ses-2': { type: 'retry', message: 'retrying' },
},
},
]
const statuses = aggregateLiveSessionStatuses(states)
expect(statuses['ses-1']?.type).toBe('busy')
expect(statuses['ses-2']?.type).toBe('retry')
expect(findLiveSessionStatus(states, 'ses-2')?.type).toBe('retry')
})
it('derives active-now sessions from live statuses instead of persisted history', () => {
const sessions = [
session('ses-1', '/a', 20),
session('ses-2', '/b', 30),
session('ses-3', '/c', 10, { time: { created: 9, updated: 10, archived: 50 } }),
session('ses-4', '/d', 40, { parentID: 'ses-parent' }),
]
const activeNow = deriveLiveActiveNowSessions(sessions, {
'ses-1': { type: 'busy' },
'ses-2': { type: 'retry', message: 'retrying' },
'ses-3': { type: 'busy' },
'ses-4': { type: 'busy' },
})
expect(activeNow.map((item) => item.id)).toEqual(['ses-2', 'ses-1'])
})
})