Skip to content

Commit 5a63cb5

Browse files
committed
fix(flowchat): reload history after cross-device rollback
1 parent 978f58a commit 5a63cb5

3 files changed

Lines changed: 97 additions & 6 deletions

File tree

‎src/web-ui/AGENTS.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,11 @@ also run the focused state contracts:
122122
pnpm --dir src/web-ui run test:run src/app/services/sessionSceneLifecycle.test.ts src/flow_chat/services/sessionActivation.test.ts src/flow_chat/services/storeSync.test.ts src/app/stores/sceneStore.test.ts
123123
```
124124

125+
For cross-device undo/redo/rollback history invalidation and settled-Turn fences,
126+
run `pnpm --dir src/web-ui run test:run src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts`.
127+
Missing fence identities (including legacy payloads) must reload authoritative
128+
history; an explicit settled-Turn identity must preserve newer live Turns.
129+
125130
For ecosystem discovery, import, or compatibility status presentation changes:
126131

127132
```bash

‎src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts‎

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1647,6 +1647,7 @@ describe('handleSessionHistoryChanged', () => {
16471647
const context = createFlowChatContext();
16481648
handleSessionHistoryChanged(context, {
16491649
sessionId: 'session-1',
1650+
settledTurnId: 'turn-1',
16501651
});
16511652
await Promise.resolve();
16521653

@@ -1672,12 +1673,84 @@ describe('handleSessionHistoryChanged', () => {
16721673
const context = createFlowChatContext();
16731674
handleSessionHistoryChanged(context, {
16741675
sessionId: 'session-1',
1676+
settledTurnId: 'turn-1',
16751677
});
16761678
await Promise.resolve();
16771679

16781680
expect(reconcile).not.toHaveBeenCalled();
16791681
expect(context.pendingHistoryFenceSessions.has('session-1')).toBe(true);
16801682
});
1683+
1684+
it.each([
1685+
{ sessionId: 'session-1', settledTurnId: null },
1686+
{ sessionId: 'session-1' },
1687+
{ session_id: 'session-1', settled_turn_id: null },
1688+
])('removes rolled-back turns with an authoritative reload, including legacy payloads: %j', async event => {
1689+
createSessionWithTurn({
1690+
...createFinishingTurn(),
1691+
status: 'completed',
1692+
endTime: 1000,
1693+
});
1694+
const store = FlowChatStore.getInstance();
1695+
store.setState(prev => ({
1696+
...prev,
1697+
activeSessionId: 'session-1',
1698+
sessions: new Map([['session-1', {
1699+
...prev.sessions.get('session-1')!,
1700+
workspacePath: '/workspace',
1701+
workspaceId: 'workspace-1',
1702+
config: { agentType: 'Standard', workspaceId: 'workspace-1' },
1703+
}]]),
1704+
}));
1705+
vi.spyOn(agentAPI, 'restoreSessionView').mockResolvedValue({
1706+
session: {
1707+
sessionId: 'session-1', sessionName: 'Session 1', agentType: 'Standard',
1708+
state: 'Idle', turnCount: 0, createdAt: 1, updatedAt: 2,
1709+
},
1710+
turns: [],
1711+
contextRestoreState: 'ready',
1712+
isPartial: false,
1713+
loadedTurnCount: 0,
1714+
totalTurnCount: 0,
1715+
} as any);
1716+
const reconcile = vi.spyOn(store, 'reconcileSettledDialogTurn');
1717+
const context = createFlowChatContext();
1718+
context.pendingHistoryFenceSessions.add('session-1');
1719+
1720+
handleSessionHistoryChanged(context, event);
1721+
1722+
await vi.waitFor(() => expect(store.getState().sessions.get('session-1')?.dialogTurns).toEqual([]));
1723+
expect(reconcile).not.toHaveBeenCalled();
1724+
expect(context.pendingHistoryFenceSessions.has('session-1')).toBe(false);
1725+
});
1726+
1727+
it('reloads an out-of-lifecycle edit even when the obsolete local tail is still finishing', () => {
1728+
createSessionWithTurn(createFinishingTurn());
1729+
const store = FlowChatStore.getInstance();
1730+
const reload = vi.spyOn(store, 'loadSessionHistory').mockResolvedValue(undefined);
1731+
const context = createFlowChatContext();
1732+
1733+
handleSessionHistoryChanged(context, { sessionId: 'session-1', settledTurnId: null });
1734+
1735+
expect(reload).toHaveBeenCalledExactlyOnceWith('session-1', { includeInternal: false });
1736+
expect(context.pendingHistoryFenceSessions.has('session-1')).toBe(false);
1737+
});
1738+
1739+
it('repairs the identified settled turn without dropping a newer live turn', () => {
1740+
createSessionWithTurn({ ...createFinishingTurn(), status: 'completed', endTime: 1000 });
1741+
const store = FlowChatStore.getInstance();
1742+
store.addDialogTurn('session-1', { ...createFinishingTurn(), id: 'turn-2', status: 'processing' });
1743+
const reconcile = vi.spyOn(store, 'reconcileSettledDialogTurn').mockResolvedValue(false);
1744+
const reload = vi.spyOn(store, 'loadSessionHistory').mockResolvedValue(undefined);
1745+
const context = createFlowChatContext();
1746+
1747+
handleSessionHistoryChanged(context, { session_id: 'session-1', settled_turn_id: 'turn-1' });
1748+
1749+
expect(reconcile).toHaveBeenCalledExactlyOnceWith('session-1', 'turn-1');
1750+
expect(reload).not.toHaveBeenCalled();
1751+
expect(context.pendingHistoryFenceSessions.has('session-1')).toBe(false);
1752+
expect(store.getState().sessions.get('session-1')?.dialogTurns.at(-1)?.status).toBe('processing');
1753+
});
16811754
});
16821755

16831756
describe('handleDialogTurnComplete', () => {
@@ -1761,7 +1834,7 @@ describe('handleDialogTurnComplete', () => {
17611834

17621835
// Durable fence lands while the turn is still finalizing: it must be
17631836
// parked instead of racing the persistence commit with an extra read.
1764-
handleSessionHistoryChanged(context, { sessionId: 'session-1' });
1837+
handleSessionHistoryChanged(context, { sessionId: 'session-1', settledTurnId: 'turn-1' });
17651838
expect(FlowChatStore.getInstance().reconcileSettledDialogTurn)
17661839
.not.toHaveBeenCalled();
17671840
expect(context.pendingHistoryFenceSessions.has('session-1')).toBe(true);

‎src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts‎

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1603,6 +1603,8 @@ export function handleSessionStateChanged(context: FlowChatContext, event: any):
16031603
}
16041604

16051605
/**
1606+
* Reload the authoritative history after an out-of-lifecycle edit. A durable
1607+
* fence carrying a settled Turn identity only needs to re-read that Turn.
16061608
* Re-read the terminal tail only after the Runtime announces that its Turn is
16071609
* durable. DialogTurnCompleted is intentionally lower latency than the final
16081610
* persistence write, so relying on that event alone can preserve a painted
@@ -1626,17 +1628,28 @@ export function handleSessionHistoryChanged(context: FlowChatContext, event: any
16261628
return;
16271629
}
16281630

1629-
const latestTurn = session.dialogTurns[session.dialogTurns.length - 1];
1630-
if (latestTurn && !['completed', 'cancelled', 'error'].includes(latestTurn.status)) {
1631+
const settledTurnId = event?.settledTurnId ?? event?.settled_turn_id;
1632+
if (!settledTurnId) {
1633+
// Undo/redo and rollback remove visible Turns. Repairing one old tail Turn
1634+
// cannot remove that suffix, including when the history becomes empty.
1635+
// Older hosts omit the optional identity, so use full invalidation there too.
1636+
context.pendingHistoryFenceSessions.delete(sessionId);
1637+
void context.flowChatStore.loadSessionHistory(sessionId, {
1638+
includeInternal: session.sessionKind === 'subagent',
1639+
}).catch(error => {
1640+
log.warn('Failed to reload changed session history', { sessionId, error });
1641+
});
1642+
return;
1643+
}
1644+
1645+
const settledTurn = session.dialogTurns.find(turn => turn.id === settledTurnId);
1646+
if (settledTurn && !['completed', 'cancelled', 'error'].includes(settledTurn.status)) {
16311647
// The local projection has not settled yet (completion quiet window, or
16321648
// the next Turn already started). Defer to the completion finalizer.
16331649
context.pendingHistoryFenceSessions.add(sessionId);
16341650
return;
16351651
}
16361652

1637-
const settledTurn = [...session.dialogTurns]
1638-
.reverse()
1639-
.find(turn => ['completed', 'cancelled', 'error'].includes(turn.status));
16401653
if (!settledTurn) {
16411654
return;
16421655
}

0 commit comments

Comments
 (0)