fix(sync): unify live session truth across chat and sidebar
This commit is contained in:
@@ -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'])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user