Skip to content

Commit 3a9f0c2

Browse files
committed
fix(chat): preserve state across stale snapshots
1 parent 30fe6f7 commit 3a9f0c2

2 files changed

Lines changed: 50 additions & 0 deletions

File tree

src/client/features/chat/chat-projection.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
acceptSnapshot,
99
applyProjectionEvent,
1010
beginInteraction,
11+
beginSnapshot,
1112
createChatProjection,
1213
deriveStatus,
1314
failInteraction,
@@ -90,6 +91,50 @@ describe('chat projection', () => {
9091
}).cursor,
9192
).toBe(2);
9293
});
94+
it('does not let a stale snapshot cut roll back sequenced timeline state', () => {
95+
const base = acceptSnapshot(createChatProjection('s'), {
96+
...snapshot(2),
97+
activeTurnId: 'turn-1',
98+
items: [],
99+
interactions: [],
100+
});
101+
const final = applyProjectionEvent(base, {
102+
sequence: 3,
103+
type: 'agentMessageDelta',
104+
payload: { text: 'recovered final', phase: 'final_answer' },
105+
});
106+
const interaction = applyProjectionEvent(final, {
107+
sequence: 4,
108+
type: 'interaction.requested',
109+
payload: { requestId: 'request-1', kind: 'commandApproval', turnId: 'turn-1', payload: {} },
110+
});
111+
const activity = applyProjectionEvent(interaction, {
112+
sequence: 5,
113+
type: 'activity.updated',
114+
payload: { id: 'activity-1', label: 'Tool', detail: 'Finished' },
115+
});
116+
117+
const retained = acceptSnapshot(beginSnapshot(activity), {
118+
...snapshot(2),
119+
activeTurnId: null,
120+
items: [],
121+
interactions: [],
122+
});
123+
124+
expect(retained).toMatchObject({
125+
cursor: 5,
126+
snapshotting: false,
127+
activeTurnId: 'turn-1',
128+
lifecycle: 'working',
129+
});
130+
expect(retained.messages).toEqual([
131+
expect.objectContaining({ text: 'recovered final', phase: 'final_answer', complete: true }),
132+
]);
133+
expect(retained.interactions).toEqual([
134+
expect.objectContaining({ requestId: 'request-1', state: 'pending' }),
135+
]);
136+
expect(retained.activities).toEqual([{ id: 'activity-1', label: 'Tool', detail: 'Finished' }]);
137+
});
93138
it('does not promote duplicate identical optimistic prompts by ambiguous text', () => {
94139
const projection = queuePrompt(
95140
queuePrompt(createChatProjection('s'), 'one', 'same'),

src/client/features/chat/chat-projection.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,11 @@ export function hydrateCache(sessionId: string, cached: unknown): ChatProjection
262262
/** Accepts authoritative history without discarding a still-pending local operation. */
263263
export function acceptSnapshot(current: ChatProjection, snapshot: ChatSnapshot): ChatProjection {
264264
if (!Number.isInteger(snapshot.baseSequence) || snapshot.baseSequence < 0) return current;
265+
// The projection has already applied state newer than this snapshot cut. Rebuilding
266+
// from the stale snapshot would roll those sequenced events back, so retain the
267+
// current projection and only drain events buffered while the request was in flight.
268+
if (snapshot.baseSequence < current.cursor)
269+
return replayBuffered({ ...current, snapshotting: false });
265270
const canonicalUsers = snapshot.items.filter((item) => item.kind === 'user');
266271
const prompts = current.prompts.map((prompt) => {
267272
const correlated = canonicalUsers.some(

0 commit comments

Comments
 (0)