Skip to content

Commit 82a77f7

Browse files
committed
fix(web): handle stale session state on reconnect
1 parent 0e3d6b8 commit 82a77f7

6 files changed

Lines changed: 182 additions & 14 deletions

File tree

internal/gateway/bootstrap_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1332,6 +1332,59 @@ func TestHandleBindStreamFrameRejectsSessionOutsideCurrentWorkspace(t *testing.T
13321332
}
13331333
}
13341334

1335+
func TestHandleBindStreamFrameValidatesVisibleSessionBeforeBinding(t *testing.T) {
1336+
relay := NewStreamRelay(StreamRelayOptions{})
1337+
ctx, cancel := context.WithCancel(context.Background())
1338+
defer cancel()
1339+
1340+
connectionID := NewConnectionID()
1341+
workspaceState := NewConnectionWorkspaceState()
1342+
workspaceState.SetWorkspaceHash("workspace-a")
1343+
connectionCtx := WithConnectionID(ctx, connectionID)
1344+
connectionCtx = WithConnectionWorkspaceState(connectionCtx, workspaceState)
1345+
connectionCtx = WithStreamRelay(connectionCtx, relay)
1346+
if err := relay.RegisterConnection(ConnectionRegistration{
1347+
ConnectionID: connectionID,
1348+
Channel: StreamChannelIPC,
1349+
Context: connectionCtx,
1350+
Cancel: cancel,
1351+
Write: func(message RelayMessage) error {
1352+
_ = message
1353+
return nil
1354+
},
1355+
Close: func() {},
1356+
}); err != nil {
1357+
t.Fatalf("register connection: %v", err)
1358+
}
1359+
defer relay.dropConnection(connectionID)
1360+
1361+
var loaded LoadSessionInput
1362+
runtimeStub := &bootstrapRuntimeStub{
1363+
loadSessionFn: func(_ context.Context, input LoadSessionInput) (Session, error) {
1364+
loaded = input
1365+
return Session{ID: input.SessionID}, nil
1366+
},
1367+
}
1368+
response := handleBindStreamFrame(connectionCtx, MessageFrame{
1369+
Type: FrameTypeRequest,
1370+
Action: FrameActionBindStream,
1371+
RequestID: "bind-visible-session",
1372+
Payload: protocol.BindStreamParams{
1373+
SessionID: "session-visible",
1374+
Channel: "all",
1375+
},
1376+
}, runtimeStub)
1377+
if response.Type != FrameTypeAck {
1378+
t.Fatalf("response type = %q, want %q: %#v", response.Type, FrameTypeAck, response.Error)
1379+
}
1380+
if loaded.SessionID != "session-visible" {
1381+
t.Fatalf("validated session_id = %q, want %q", loaded.SessionID, "session-visible")
1382+
}
1383+
if got := relay.ResolveFallbackSessionIDForWorkspace(connectionID, "workspace-a"); got != "session-visible" {
1384+
t.Fatalf("fallback session = %q, want %q", got, "session-visible")
1385+
}
1386+
}
1387+
13351388
func TestHandleTriggerActionFrame(t *testing.T) {
13361389
registerConnection := func(
13371390
t *testing.T,

web/src/components/chat/ChatInput.test.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,4 +351,34 @@ describe('ChatInput', () => {
351351
expect(mockGatewayAPI.cancel).toHaveBeenCalledWith({ session_id: 'session-1', run_id: 'run-1' })
352352
})
353353
})
354+
355+
it('falls back to run id only when cancelling without an active session', async () => {
356+
useSessionStore.setState({ currentSessionId: '' } as never)
357+
useGatewayStore.setState({ currentRunId: 'run-1' } as never)
358+
useChatStore.setState({ isGenerating: true } as never)
359+
mockGatewayAPI.cancel.mockResolvedValueOnce({ payload: { canceled: true, run_id: 'run-1' } })
360+
render(<ChatInput />)
361+
362+
fireEvent.click(screen.getByTitle(//))
363+
364+
await waitFor(() => {
365+
expect(mockGatewayAPI.cancel).toHaveBeenCalledWith({ run_id: 'run-1' })
366+
})
367+
})
368+
369+
it('does not reset generating state when no cancel request is sent', async () => {
370+
const resetGeneratingState = vi.spyOn(useChatStore.getState(), 'resetGeneratingState')
371+
useSessionStore.setState({ currentSessionId: '' } as never)
372+
useGatewayStore.setState({ currentRunId: '' } as never)
373+
useChatStore.setState({ isGenerating: true } as never)
374+
render(<ChatInput />)
375+
376+
fireEvent.click(screen.getByTitle(//))
377+
378+
await waitFor(() => {
379+
expect(mockGatewayAPI.cancel).not.toHaveBeenCalled()
380+
})
381+
expect(resetGeneratingState).not.toHaveBeenCalled()
382+
resetGeneratingState.mockRestore()
383+
})
354384
})

web/src/components/chat/ChatInput.tsx

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -426,18 +426,17 @@ export default function ChatInput() {
426426
const runId = useGatewayStore.getState().currentRunId
427427
const currentSessionId = useSessionStore.getState().currentSessionId
428428
if (runId && gatewayAPI) {
429-
if (!isValidSessionId(currentSessionId)) {
430-
useUIStore.getState().showToast('Cannot cancel run without an active session', 'error')
431-
useChatStore.getState().resetGeneratingState()
432-
return
433-
}
434429
try {
435-
await gatewayAPI.cancel({ session_id: currentSessionId, run_id: runId })
430+
const cancelParams = isValidSessionId(currentSessionId)
431+
? { session_id: currentSessionId, run_id: runId }
432+
: { run_id: runId }
433+
await gatewayAPI.cancel(cancelParams)
434+
useChatStore.getState().resetGeneratingState()
436435
} catch (err) {
437436
console.error('Cancel failed:', err)
438437
}
438+
return
439439
}
440-
useChatStore.getState().resetGeneratingState()
441440
}
442441

443442
const isEmpty = !text.trim()

web/src/context/RuntimeProvider.lifecycle.test.tsx

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,14 @@ describe('RuntimeProvider lifecycle', () => {
164164
useSessionStore.setState({
165165
...useSessionStore.getState(),
166166
currentSessionId: 'session-2',
167-
fetchSessions: vi.fn().mockResolvedValue(undefined),
167+
fetchSessions: vi.fn(async (gatewayAPI: any) => {
168+
await gatewayAPI.listSessions()
169+
}),
170+
projects: [{
171+
id: 'group_today',
172+
name: 'Today',
173+
sessions: [{ id: 'session-2', title: 'Two', time: new Date(0).toISOString() }],
174+
}],
168175
} as any)
169176

170177
let runtimeSnapshot: any = null
@@ -183,12 +190,65 @@ describe('RuntimeProvider lifecycle', () => {
183190

184191
const methods = client.call.mock.calls.map((call: any[]) => call[0])
185192
const switchIndex = methods.indexOf('gateway.switchWorkspace')
193+
const fetchIndex = methods.indexOf('gateway.listSessions')
186194
const bindIndex = methods.indexOf('gateway.bindStream')
187195
expect(switchIndex).toBeGreaterThanOrEqual(0)
196+
expect(fetchIndex).toBeGreaterThanOrEqual(0)
188197
expect(bindIndex).toBeGreaterThanOrEqual(0)
189-
expect(switchIndex).toBeLessThan(bindIndex)
198+
expect(switchIndex).toBeLessThan(fetchIndex)
199+
expect(fetchIndex).toBeLessThan(bindIndex)
190200
expect(client.call).toHaveBeenCalledWith('gateway.switchWorkspace', { workspace_hash: 'w2' })
191201
expect(client.call).toHaveBeenCalledWith('gateway.bindStream', { session_id: 'session-2', channel: 'all' })
192202
})
203+
204+
it('recovers reconnect when rebinding a stale session fails', async () => {
205+
sessionStorage.setItem(
206+
'neocode.browserRuntimeConfig',
207+
JSON.stringify({ mode: 'browser', gatewayBaseURL: 'http://127.0.0.1:8080', token: 'tok' }),
208+
)
209+
useWorkspaceStore.setState({
210+
fetchWorkspaces: vi.fn().mockResolvedValue(undefined),
211+
workspaces: [{ hash: 'w2', path: '/workspace-two', name: 'Two', createdAt: '', updatedAt: '' }],
212+
currentWorkspaceHash: 'w2',
213+
} as any)
214+
useSessionStore.setState({
215+
...useSessionStore.getState(),
216+
currentSessionId: 'session-stale',
217+
fetchSessions: vi.fn().mockResolvedValue(undefined),
218+
projects: [{
219+
id: 'group_today',
220+
name: 'Today',
221+
sessions: [{ id: 'session-stale', title: 'Stale', time: new Date(0).toISOString() }],
222+
}],
223+
} as any)
224+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
225+
226+
let runtimeSnapshot: any = null
227+
render(
228+
<RuntimeProvider>
229+
<RuntimeProbe onReady={(rt) => { runtimeSnapshot = rt }} />
230+
</RuntimeProvider>,
231+
)
232+
await waitFor(() => expect(runtimeSnapshot?.status).toBe('connected'))
233+
const client = clients[0]
234+
client.call.mockImplementation(async (method: string, params?: any) => {
235+
if (method === 'gateway.bindStream' && params?.session_id === 'session-stale') {
236+
throw new Error('session not found')
237+
}
238+
if (method === 'gateway.authenticate') return { payload: {} }
239+
if (method === 'gateway.listWorkspaces') return { payload: { workspaces: [] } }
240+
if (method === 'gateway.ping') return { payload: {} }
241+
return { payload: {} }
242+
})
243+
244+
await act(async () => {
245+
await client._emitReconnect()
246+
})
247+
248+
await waitFor(() => expect(runtimeSnapshot?.status).toBe('connected'))
249+
expect(runtimeSnapshot.error).toBe('')
250+
expect(useSessionStore.getState().setCurrentSessionId).toHaveBeenCalledWith('')
251+
warnSpy.mockRestore()
252+
})
193253
})
194254

web/src/context/RuntimeProvider.tsx

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,29 @@ async function syncWorkspaceContext(gatewayAPI: GatewayAPI): Promise<boolean> {
9696
return true
9797
}
9898

99+
function sessionExistsInProjects(sessionId: string) {
100+
return useSessionStore.getState().projects.some((project) =>
101+
project.sessions.some((session) => session.id === sessionId),
102+
)
103+
}
104+
105+
async function bindCurrentSessionForReconnect(gatewayAPI: GatewayAPI) {
106+
const sessionId = useSessionStore.getState().currentSessionId
107+
if (!isValidSessionId(sessionId)) return
108+
if (!sessionExistsInProjects(sessionId)) {
109+
useSessionStore.getState().setCurrentSessionId('')
110+
useSessionStore.getState().setCurrentProjectId('')
111+
return
112+
}
113+
try {
114+
await gatewayAPI.bindStream({ session_id: sessionId, channel: 'all' })
115+
} catch (err) {
116+
console.warn('[RuntimeProvider] Reconnect bindStream skipped stale session:', err)
117+
useSessionStore.getState().setCurrentSessionId('')
118+
useSessionStore.getState().setCurrentProjectId('')
119+
}
120+
}
121+
99122
/** RuntimeProvider 装配前端运行时,并为业务组件提供当前 Gateway 客户端。 */
100123
export function RuntimeProvider({ children }: { children: ReactNode }) {
101124
const mode = useMemo(detectRuntimeMode, [])
@@ -179,11 +202,8 @@ export function RuntimeProvider({ children }: { children: ReactNode }) {
179202

180203
const hasWorkspaces = await syncWorkspaceContext(api)
181204
if (hasWorkspaces) {
182-
const sessionId = useSessionStore.getState().currentSessionId
183-
if (isValidSessionId(sessionId)) {
184-
await api.bindStream({ session_id: sessionId, channel: 'all' })
185-
}
186205
await useSessionStore.getState().fetchSessions(api, true)
206+
await bindCurrentSessionForReconnect(api)
187207
}
188208
await refreshPendingUserQuestion(api, useSessionStore.getState().currentSessionId)
189209

web/src/stores/useSessionStore.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -414,8 +414,14 @@ export const useSessionStore = create<SessionState>((set, get) => ({
414414
const projects = mapSessionsToProjects(sessions)
415415
set({ projects, loading: false })
416416

417-
const state = get()
417+
let state = get()
418418
if (requestSeq !== _fetchSessionsSeq) return
419+
const currentSessionVisible = isValidSessionId(state.currentSessionId) &&
420+
sessions.some((session) => session.id === state.currentSessionId)
421+
if (isValidSessionId(state.currentSessionId) && !currentSessionVisible) {
422+
set({ currentSessionId: '', currentProjectId: '', _initialBindDone: false })
423+
state = get()
424+
}
419425
if (!isValidSessionId(state.currentSessionId) && sessions.length > 0) {
420426
const firstSession = sessions[0]
421427
set({ currentSessionId: firstSession.id })

0 commit comments

Comments
 (0)