2026-07-14 03:45:39 +08:00
|
|
|
import { test } from 'node:test';
|
|
|
|
|
import assert from 'node:assert/strict';
|
|
|
|
|
|
|
|
|
|
import { createSessionLiveReloadCoordinator } from '../app/src/renderer/src/session-live-reload.mjs';
|
2026-07-14 18:11:51 +08:00
|
|
|
import { state } from '../app/src/renderer/src/store.js';
|
|
|
|
|
import {
|
2026-07-15 22:12:40 +08:00
|
|
|
fetchSessionDetailPatch,
|
2026-07-14 18:11:51 +08:00
|
|
|
getCachedSessionDetail,
|
|
|
|
|
loadSessionDetail,
|
2026-07-15 22:12:40 +08:00
|
|
|
materializeSessionDetailPatch,
|
2026-07-14 18:11:51 +08:00
|
|
|
} from '../app/src/renderer/src/data.js';
|
2026-07-21 00:58:34 +08:00
|
|
|
import { assembleSessionDetail } from '../app/src/shared/session-detail-assembly.mjs';
|
2026-07-14 18:11:51 +08:00
|
|
|
import { createSessionPatch } from '../app/src/shared/session-patch.mjs';
|
2026-07-14 03:45:39 +08:00
|
|
|
|
2026-07-15 22:12:40 +08:00
|
|
|
test('live updates coalesce while scrolling and load only the latest after scroll end', async () => {
|
2026-07-14 03:45:39 +08:00
|
|
|
let scrolling = true;
|
|
|
|
|
let loads = 0;
|
|
|
|
|
const commits = [];
|
|
|
|
|
const coordinator = createSessionLiveReloadCoordinator({
|
|
|
|
|
isScrolling: () => scrolling,
|
|
|
|
|
load: async () => ++loads,
|
|
|
|
|
commit: async snapshot => { commits.push(snapshot); },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await coordinator.request();
|
|
|
|
|
await coordinator.request();
|
|
|
|
|
await coordinator.request();
|
2026-07-15 22:12:40 +08:00
|
|
|
assert.equal(loads, 0, 'patch preparation stays off the scrolling renderer task budget');
|
2026-07-14 03:45:39 +08:00
|
|
|
assert.deepEqual(commits, []);
|
|
|
|
|
|
|
|
|
|
scrolling = false;
|
|
|
|
|
await coordinator.flush();
|
2026-07-15 22:12:40 +08:00
|
|
|
assert.equal(loads, 1, 'scroll end loads the latest coalesced state once');
|
|
|
|
|
assert.deepEqual(commits, [1]);
|
2026-07-14 03:45:39 +08:00
|
|
|
|
|
|
|
|
await coordinator.flush();
|
2026-07-15 22:12:40 +08:00
|
|
|
assert.equal(loads, 1, 'an idle flush without another update is a no-op');
|
2026-07-14 03:45:39 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('an update arriving during an in-flight load skips the stale snapshot without overlap', async () => {
|
|
|
|
|
let releaseFirstLoad;
|
|
|
|
|
let activeLoads = 0;
|
|
|
|
|
let maxActiveLoads = 0;
|
|
|
|
|
let loads = 0;
|
|
|
|
|
const commits = [];
|
|
|
|
|
const firstLoadGate = new Promise(resolve => { releaseFirstLoad = resolve; });
|
|
|
|
|
const coordinator = createSessionLiveReloadCoordinator({
|
|
|
|
|
isScrolling: () => false,
|
|
|
|
|
load: async () => {
|
|
|
|
|
loads++;
|
|
|
|
|
activeLoads++;
|
|
|
|
|
maxActiveLoads = Math.max(maxActiveLoads, activeLoads);
|
|
|
|
|
if (loads === 1) await firstLoadGate;
|
|
|
|
|
activeLoads--;
|
|
|
|
|
return loads;
|
|
|
|
|
},
|
|
|
|
|
commit: async snapshot => { commits.push(snapshot); },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const first = coordinator.request();
|
|
|
|
|
const second = coordinator.request();
|
|
|
|
|
releaseFirstLoad();
|
|
|
|
|
await Promise.all([first, second]);
|
|
|
|
|
|
|
|
|
|
assert.equal(loads, 2);
|
|
|
|
|
assert.equal(maxActiveLoads, 1, 'snapshot loads remain serialized');
|
|
|
|
|
assert.deepEqual(commits, [2], 'only the freshest loaded snapshot is committed');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('scrolling that starts during IPC defers the loaded snapshot commit', async () => {
|
|
|
|
|
let scrolling = false;
|
|
|
|
|
let releaseLoad;
|
|
|
|
|
let loads = 0;
|
|
|
|
|
const commits = [];
|
|
|
|
|
const loadGate = new Promise(resolve => { releaseLoad = resolve; });
|
|
|
|
|
const coordinator = createSessionLiveReloadCoordinator({
|
|
|
|
|
isScrolling: () => scrolling,
|
|
|
|
|
load: async () => {
|
|
|
|
|
loads++;
|
|
|
|
|
await loadGate;
|
|
|
|
|
return 'loaded-before-scroll-ended';
|
|
|
|
|
},
|
|
|
|
|
commit: async snapshot => { commits.push(snapshot); },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const request = coordinator.request();
|
|
|
|
|
scrolling = true;
|
|
|
|
|
releaseLoad();
|
|
|
|
|
await request;
|
|
|
|
|
|
|
|
|
|
assert.equal(loads, 1);
|
|
|
|
|
assert.deepEqual(commits, []);
|
|
|
|
|
|
|
|
|
|
scrolling = false;
|
|
|
|
|
await coordinator.flush();
|
|
|
|
|
assert.equal(loads, 1, 'the already-loaded snapshot is reused');
|
|
|
|
|
assert.deepEqual(commits, ['loaded-before-scroll-ended']);
|
|
|
|
|
});
|
2026-07-14 18:11:51 +08:00
|
|
|
|
|
|
|
|
test('a skipped live patch does not advance the visible patch baseline', async t => {
|
|
|
|
|
const sessionId = 'coalesced-patch-session';
|
|
|
|
|
const previousSessions = state.sessions;
|
|
|
|
|
t.after(() => {
|
|
|
|
|
state.sessions = previousSessions;
|
2026-07-15 22:12:40 +08:00
|
|
|
state.sessionTitleOverrides.delete(sessionId);
|
2026-07-14 18:11:51 +08:00
|
|
|
delete globalThis.window;
|
|
|
|
|
});
|
|
|
|
|
let rows = [
|
|
|
|
|
{ uuid: 'message-1', type: 'user', timestamp: '2026-07-14T00:00:01Z', text: 'one' },
|
|
|
|
|
];
|
|
|
|
|
let patchCalls = 0;
|
|
|
|
|
let releaseFirstPatch;
|
|
|
|
|
let firstPatchStarted;
|
|
|
|
|
const firstPatchGate = new Promise(resolve => { releaseFirstPatch = resolve; });
|
|
|
|
|
const firstPatchReady = new Promise(resolve => { firstPatchStarted = resolve; });
|
|
|
|
|
|
|
|
|
|
globalThis.window = {
|
|
|
|
|
obelisk: {
|
|
|
|
|
getSessionMessages: async () => rows,
|
|
|
|
|
getSessionToolCalls: async () => [],
|
|
|
|
|
getSessionToolResults: async () => [],
|
|
|
|
|
getSessionSubagents: async () => [],
|
|
|
|
|
getSessionWorkflows: async () => [],
|
|
|
|
|
getSessionSummaries: async () => [],
|
|
|
|
|
getSessionPatch: async (_id, cursor) => {
|
2026-07-21 00:58:34 +08:00
|
|
|
const snapshotAtCall = { messages: assembleSessionDetail({
|
2026-07-14 18:11:51 +08:00
|
|
|
messages: rows,
|
|
|
|
|
toolCalls: [],
|
|
|
|
|
toolResults: [],
|
|
|
|
|
subagents: [],
|
|
|
|
|
workflows: [],
|
2026-07-21 00:58:34 +08:00
|
|
|
}).messages, workflows: [] };
|
2026-07-14 18:11:51 +08:00
|
|
|
patchCalls++;
|
|
|
|
|
if (patchCalls === 1) {
|
|
|
|
|
firstPatchStarted();
|
|
|
|
|
await firstPatchGate;
|
|
|
|
|
}
|
2026-07-15 22:12:40 +08:00
|
|
|
return {
|
|
|
|
|
...createSessionPatch(snapshotAtCall, cursor),
|
|
|
|
|
session: {
|
|
|
|
|
id: sessionId,
|
|
|
|
|
title: 'Live session title',
|
|
|
|
|
message_count: rows.length,
|
|
|
|
|
},
|
|
|
|
|
};
|
2026-07-14 18:11:51 +08:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
};
|
2026-07-15 22:12:40 +08:00
|
|
|
state.sessions = [{ id: sessionId, title: 'Initial title', message_count: 1, messages: [] }];
|
2026-07-14 18:11:51 +08:00
|
|
|
await loadSessionDetail(sessionId);
|
|
|
|
|
|
|
|
|
|
const commits = [];
|
|
|
|
|
const coordinator = createSessionLiveReloadCoordinator({
|
|
|
|
|
isScrolling: () => false,
|
2026-07-15 22:12:40 +08:00
|
|
|
load: async () => materializeSessionDetailPatch(await fetchSessionDetailPatch(sessionId)),
|
2026-07-14 18:11:51 +08:00
|
|
|
commit: async latest => {
|
|
|
|
|
commits.push({
|
|
|
|
|
messages: latest.messages.map(message => message.uuid),
|
|
|
|
|
changedIds: latest.messagePatch.changedIds,
|
2026-07-15 22:12:40 +08:00
|
|
|
title: latest.title,
|
|
|
|
|
messageCount: latest.message_count,
|
2026-07-14 18:11:51 +08:00
|
|
|
});
|
|
|
|
|
latest.acceptMessagePatch?.();
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
rows = [...rows, { uuid: 'message-2', type: 'assistant', timestamp: '2026-07-14T00:00:02Z', text: 'two' }];
|
|
|
|
|
const first = coordinator.request();
|
|
|
|
|
await firstPatchReady;
|
|
|
|
|
rows = [...rows, { uuid: 'message-3', type: 'assistant', timestamp: '2026-07-14T00:00:03Z', text: 'three' }];
|
|
|
|
|
const second = coordinator.request();
|
|
|
|
|
releaseFirstPatch();
|
|
|
|
|
await Promise.all([first, second]);
|
|
|
|
|
|
|
|
|
|
assert.deepEqual(commits, [{
|
|
|
|
|
messages: ['message-1', 'message-2', 'message-3'],
|
|
|
|
|
changedIds: ['message-2', 'message-3'],
|
2026-07-15 22:12:40 +08:00
|
|
|
title: 'Live session title',
|
|
|
|
|
messageCount: 3,
|
2026-07-14 18:11:51 +08:00
|
|
|
}]);
|
|
|
|
|
assert.deepEqual(
|
|
|
|
|
getCachedSessionDetail(sessionId).messages.map(message => message.uuid),
|
|
|
|
|
['message-1', 'message-2', 'message-3'],
|
|
|
|
|
'accepted patches become the reusable session-detail snapshot',
|
|
|
|
|
);
|
2026-07-15 22:12:40 +08:00
|
|
|
assert.deepEqual(
|
|
|
|
|
{
|
|
|
|
|
title: getCachedSessionDetail(sessionId).title,
|
|
|
|
|
messageCount: getCachedSessionDetail(sessionId).message_count,
|
|
|
|
|
},
|
|
|
|
|
{ title: 'Live session title', messageCount: 3 },
|
|
|
|
|
'accepted patches retain the current session metadata without a global catalogue reload',
|
|
|
|
|
);
|
2026-07-14 18:11:51 +08:00
|
|
|
assert.deepEqual(
|
|
|
|
|
state.sessions.find(session => session.id === sessionId).messages,
|
|
|
|
|
[],
|
|
|
|
|
'the stale full-snapshot copy is invalidated after patch acceptance',
|
|
|
|
|
);
|
2026-07-15 22:12:40 +08:00
|
|
|
assert.deepEqual(
|
|
|
|
|
state.sessionTitleOverrides.get(sessionId),
|
|
|
|
|
'Live session title',
|
|
|
|
|
'accepted title changes update the shared breadcrumb/window-title overlay after the visible commit',
|
|
|
|
|
);
|
2026-07-14 18:11:51 +08:00
|
|
|
|
|
|
|
|
const evictionSessionIds = ['eviction-session-1', 'eviction-session-2', 'eviction-session-3'];
|
|
|
|
|
state.sessions.push(...evictionSessionIds.map(id => ({ id, messages: [] })));
|
|
|
|
|
for (const id of evictionSessionIds) await loadSessionDetail(id);
|
|
|
|
|
|
|
|
|
|
assert.equal(getCachedSessionDetail(sessionId), null, 'the oldest accepted snapshot is evicted by the bounded cache');
|
|
|
|
|
assert.deepEqual(
|
|
|
|
|
state.sessions.find(session => session.id === sessionId).messages,
|
|
|
|
|
[],
|
|
|
|
|
'an evicted session cannot fall back to stale initial messages and must reload',
|
|
|
|
|
);
|
|
|
|
|
});
|