Skip to content

Commit ac9c52f

Browse files
author
Yumiue
committed
fix(web): prevent stale session/workspace state writeback
1 parent 8394b21 commit ac9c52f

4 files changed

Lines changed: 112 additions & 4 deletions

File tree

web/src/stores/useSessionStore.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,68 @@ describe('useSessionStore', () => {
187187
expect(useChatStore.getState().isTransitioning).toBe(false)
188188
})
189189

190+
it('resetForWorkspaceSwitch aborts in-flight switchSession and blocks stale writeback', async () => {
191+
const mockBindStream = vi.fn().mockResolvedValue({})
192+
let resolveLoad!: (value: any) => void
193+
const mockLoadSession = vi.fn().mockImplementation(
194+
() => new Promise((resolve) => { resolveLoad = resolve }),
195+
)
196+
const mockAPI = { bindStream: mockBindStream, loadSession: mockLoadSession } as any
197+
198+
const switchPromise = useSessionStore.getState().switchSession('sess-old', mockAPI)
199+
await Promise.resolve()
200+
201+
useSessionStore.getState().resetForWorkspaceSwitch()
202+
203+
resolveLoad({
204+
payload: {
205+
messages: [{ role: 'assistant', content: 'stale payload', tool_calls: [] }],
206+
agent_mode: 'plan',
207+
},
208+
})
209+
await switchPromise
210+
211+
expect(useChatStore.getState().messages).toHaveLength(0)
212+
expect(useChatStore.getState().agentMode).toBe('build')
213+
})
214+
215+
it('switchSession applies only latest request when older request resolves later', async () => {
216+
const mockBindStream = vi.fn().mockResolvedValue({})
217+
let resolveLoadA!: (value: any) => void
218+
let resolveLoadB!: (value: any) => void
219+
const mockLoadSession = vi
220+
.fn()
221+
.mockImplementationOnce(() => new Promise((resolve) => { resolveLoadA = resolve }))
222+
.mockImplementationOnce(() => new Promise((resolve) => { resolveLoadB = resolve }))
223+
const mockAPI = { bindStream: mockBindStream, loadSession: mockLoadSession } as any
224+
225+
const switchA = useSessionStore.getState().switchSession('sess-a', mockAPI)
226+
await Promise.resolve()
227+
const switchB = useSessionStore.getState().switchSession('sess-b', mockAPI)
228+
await Promise.resolve()
229+
230+
resolveLoadB({
231+
payload: {
232+
messages: [{ role: 'assistant', content: 'new payload', tool_calls: [] }],
233+
agent_mode: 'plan',
234+
},
235+
})
236+
await switchB
237+
238+
resolveLoadA({
239+
payload: {
240+
messages: [{ role: 'assistant', content: 'old payload', tool_calls: [] }],
241+
agent_mode: 'build',
242+
},
243+
})
244+
await switchA
245+
246+
expect(useSessionStore.getState().currentSessionId).toBe('sess-b')
247+
expect(useChatStore.getState().messages).toHaveLength(1)
248+
expect(useChatStore.getState().messages[0].content).toBe('new payload')
249+
expect(useChatStore.getState().agentMode).toBe('plan')
250+
})
251+
190252
it('fetchSessions auto-selects first session and binds stream', async () => {
191253
const setMessagesSpy = vi.spyOn(useChatStore.getState(), 'setMessages')
192254
const addMessageSpy = vi.spyOn(useChatStore.getState(), 'addMessage')

web/src/stores/useSessionStore.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,7 @@ export async function reloadSessionAfterCheckpointRestore(
262262

263263
let _fetchSessionsPromise: Promise<void> | null = null
264264
let _fetchSessionsSeq = 0
265+
let _switchSessionSeq = 0
265266

266267
export const useSessionStore = create<SessionState>((set, get) => ({
267268
projects: [],
@@ -290,6 +291,7 @@ export const useSessionStore = create<SessionState>((set, get) => ({
290291
if (prevAbort) {
291292
prevAbort.abort()
292293
}
294+
const switchSeq = ++_switchSessionSeq
293295
const abortCtrl = new AbortController()
294296
set({ _switchAbort: abortCtrl, loading: true })
295297

@@ -308,13 +310,15 @@ export const useSessionStore = create<SessionState>((set, get) => ({
308310

309311
// 3. Bind stream (events will be discarded due to isTransitioning)
310312
await gatewayAPI.bindStream({ session_id: sessionId, channel: 'all' })
313+
if (abortCtrl.signal.aborted || switchSeq !== _switchSessionSeq || get()._switchAbort !== abortCtrl) return
311314

312315
// 4. Load historical messages (concurrently fetch todos + runtime snapshot)
313316
const sessionFrame = await loadSessionWithInsights(gatewayAPI, sessionId)
317+
if (abortCtrl.signal.aborted || switchSeq !== _switchSessionSeq || get()._switchAbort !== abortCtrl) return
314318
const sessionData = sessionFrame.payload as { messages?: BackendMessage[]; agent_mode?: string }
315319

316320
// Check if this request was superseded
317-
if (abortCtrl.signal.aborted) return
321+
if (abortCtrl.signal.aborted || switchSeq !== _switchSessionSeq || get()._switchAbort !== abortCtrl) return
318322

319323
// 5. Load messages and stop transitioning
320324
if (sessionData.messages && sessionData.messages.length > 0) {
@@ -326,7 +330,7 @@ export const useSessionStore = create<SessionState>((set, get) => ({
326330
useChatStore.getState().setAgentMode(restoredMode)
327331
chatStore.setTransitioning(false)
328332
} catch (err) {
329-
if (abortCtrl.signal.aborted) return
333+
if (abortCtrl.signal.aborted || switchSeq !== _switchSessionSeq || get()._switchAbort !== abortCtrl) return
330334
console.error('switchSession failed:', err)
331335
// Revert to previous session and re-bind its stream
332336
set({ currentSessionId: prevSessionId })
@@ -335,7 +339,7 @@ export const useSessionStore = create<SessionState>((set, get) => ({
335339
}
336340
useChatStore.getState().setTransitioning(false)
337341
} finally {
338-
if (get()._switchAbort === abortCtrl) {
342+
if (switchSeq === _switchSessionSeq && get()._switchAbort === abortCtrl) {
339343
set({ loading: false, _switchAbort: null })
340344
}
341345
}
@@ -379,9 +383,14 @@ export const useSessionStore = create<SessionState>((set, get) => ({
379383
},
380384

381385
resetForWorkspaceSwitch: () => {
386+
const currentAbort = get()._switchAbort
387+
if (currentAbort) {
388+
currentAbort.abort()
389+
}
382390
_fetchSessionsPromise = null
383391
_fetchSessionsSeq += 1
384-
set({ _initialBindDone: false, loading: false })
392+
_switchSessionSeq += 1
393+
set({ _initialBindDone: false, loading: false, _switchAbort: null })
385394
},
386395

387396
removeSessionLocally: (sessionId) => {

web/src/stores/useWorkspaceStore.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useChatStore } from './useChatStore'
44
import { useSessionStore } from './useSessionStore'
55
import { useUIStore } from './useUIStore'
66
import { useGatewayStore } from './useGatewayStore'
7+
import { useRuntimeInsightStore } from './useRuntimeInsightStore'
78

89
function flushPromises() {
910
return new Promise((resolve) => setTimeout(resolve, 0))
@@ -32,13 +33,17 @@ describe('useWorkspaceStore', () => {
3233
useUIStore.setState({
3334
showToast: vi.fn(),
3435
clearFileChanges: vi.fn(),
36+
clearCheckpointRollbackUndo: vi.fn(),
3537
resetPreviewTabs: vi.fn(),
3638
setSearchQuery: vi.fn(),
3739
} as any)
3840
useGatewayStore.setState({
3941
setCurrentRunId: vi.fn(),
4042
notifyProviderChanged: vi.fn(),
4143
} as any)
44+
useRuntimeInsightStore.setState({
45+
reset: vi.fn(),
46+
} as any)
4247
})
4348

4449
it('deduplicates concurrent fetchWorkspaces calls', async () => {
@@ -80,7 +85,9 @@ describe('useWorkspaceStore', () => {
8085

8186
expect(useChatStore.getState().clearMessages).toHaveBeenCalled()
8287
expect(useSessionStore.getState().resetForWorkspaceSwitch).toHaveBeenCalled()
88+
expect(useRuntimeInsightStore.getState().reset).toHaveBeenCalled()
8389
expect(useUIStore.getState().clearFileChanges).toHaveBeenCalled()
90+
expect(useUIStore.getState().clearCheckpointRollbackUndo).toHaveBeenCalled()
8491
expect(useUIStore.getState().resetPreviewTabs).toHaveBeenCalled()
8592
expect(gatewayAPI.switchWorkspace).toHaveBeenCalledWith('w2')
8693
expect(useGatewayStore.getState().notifyProviderChanged).toHaveBeenCalled()
@@ -124,6 +131,31 @@ describe('useWorkspaceStore', () => {
124131
expect(showToast).toHaveBeenCalledWith('Failed to create workspace', 'error')
125132
})
126133

134+
it('createWorkspace clears runtime insight and rollback undo before switching', async () => {
135+
const gatewayAPI = {
136+
createWorkspace: vi.fn().mockResolvedValue({
137+
payload: {
138+
workspace: {
139+
hash: 'w-new',
140+
path: '/new',
141+
name: 'New',
142+
created_at: '1',
143+
updated_at: '1',
144+
},
145+
},
146+
}),
147+
switchWorkspace: vi.fn().mockResolvedValue(undefined),
148+
} as any
149+
const fetchSessions = useSessionStore.getState().fetchSessions as any
150+
151+
await useWorkspaceStore.getState().createWorkspace('/new', gatewayAPI, 'New')
152+
153+
expect(useRuntimeInsightStore.getState().reset).toHaveBeenCalled()
154+
expect(useUIStore.getState().clearCheckpointRollbackUndo).toHaveBeenCalled()
155+
expect(gatewayAPI.switchWorkspace).toHaveBeenCalledWith('w-new')
156+
expect(fetchSessions).toHaveBeenCalledWith(gatewayAPI, true)
157+
})
158+
127159
it('deleteWorkspace switches to remaining first workspace when current is removed', async () => {
128160
const switchWorkspace = vi.spyOn(useWorkspaceStore.getState(), 'switchWorkspace')
129161
useWorkspaceStore.setState({

web/src/stores/useWorkspaceStore.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useSessionStore } from '@/stores/useSessionStore'
55
import { useChatStore } from '@/stores/useChatStore'
66
import { useUIStore } from '@/stores/useUIStore'
77
import { useGatewayStore } from '@/stores/useGatewayStore'
8+
import { useRuntimeInsightStore } from '@/stores/useRuntimeInsightStore'
89

910
/** 工作区记录 */
1011
export interface Workspace {
@@ -101,7 +102,9 @@ export const useWorkspaceStore = create<WorkspaceState>((set, get) => ({
101102
})
102103
useSessionStore.getState().resetForWorkspaceSwitch()
103104
useGatewayStore.getState().setCurrentRunId('')
105+
useRuntimeInsightStore.getState().reset()
104106
useUIStore.getState().clearFileChanges()
107+
useUIStore.getState().clearCheckpointRollbackUndo()
105108
useUIStore.getState().resetPreviewTabs()
106109
useUIStore.getState().setSearchQuery('')
107110

@@ -144,7 +147,9 @@ export const useWorkspaceStore = create<WorkspaceState>((set, get) => ({
144147
})
145148
useSessionStore.getState().resetForWorkspaceSwitch()
146149
useGatewayStore.getState().setCurrentRunId('')
150+
useRuntimeInsightStore.getState().reset()
147151
useUIStore.getState().clearFileChanges()
152+
useUIStore.getState().clearCheckpointRollbackUndo()
148153
useUIStore.getState().resetPreviewTabs()
149154
useUIStore.getState().setSearchQuery('')
150155

0 commit comments

Comments
 (0)