import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test' import type { ServerWebSocket } from 'bun' import { __resetWebSocketHandlerStateForTests, closeSessionConnection, getActiveSessionIds, handleWebSocket, type WebSocketData, } from '../ws/handler.js' import { conversationService } from '../services/conversationService.js' import { computerUseApprovalService } from '../services/computerUseApprovalService.js' function makeClientSocket(sessionId: string) { const sent: string[] = [] return { data: { sessionId, connectedAt: Date.now(), channel: 'client', sdkToken: null, serverPort: 0, serverHost: '127.0.0.1', }, send: mock((payload: string) => { sent.push(payload) }), close: mock(() => {}), sent, } as unknown as ServerWebSocket & { sent: string[] } } describe('WebSocket handler session isolation', () => { afterEach(() => { __resetWebSocketHandlerStateForTests() mock.restore() }) it('ignores stale disconnects from an older socket for the same session', () => { const sessionId = `duplicate-${crypto.randomUUID()}` const first = makeClientSocket(sessionId) const second = makeClientSocket(sessionId) const clearCallbacks = spyOn(conversationService, 'clearOutputCallbacks') const cancelComputerUse = spyOn(computerUseApprovalService, 'cancelSession') handleWebSocket.open(first) handleWebSocket.open(second) clearCallbacks.mockClear() cancelComputerUse.mockClear() handleWebSocket.close(first, 1000, 'stale tab closed') expect(getActiveSessionIds()).toContain(sessionId) expect(clearCallbacks).not.toHaveBeenCalled() expect(cancelComputerUse).not.toHaveBeenCalled() }) it('closes and removes an active client socket when a session is deleted', () => { const sessionId = `delete-${crypto.randomUUID()}` const ws = makeClientSocket(sessionId) const clearCallbacks = spyOn(conversationService, 'clearOutputCallbacks') const cancelComputerUse = spyOn(computerUseApprovalService, 'cancelSession') handleWebSocket.open(ws) expect(closeSessionConnection(sessionId, 'session deleted')).toBe(true) expect(getActiveSessionIds()).not.toContain(sessionId) expect(ws.close).toHaveBeenCalledWith(1000, 'session deleted') expect(clearCallbacks).toHaveBeenCalledWith(sessionId) expect(cancelComputerUse).toHaveBeenCalledWith(sessionId) }) it('replays pending permission requests when a client reconnects', () => { const sessionId = `permission-reconnect-${crypto.randomUUID()}` const ws = makeClientSocket(sessionId) spyOn(conversationService, 'hasSession').mockReturnValue(true) spyOn(conversationService, 'onOutput').mockImplementation(() => {}) spyOn(conversationService, 'removeOutputCallback').mockImplementation(() => {}) spyOn(conversationService, 'getPendingPermissionRequests').mockReturnValue([ { requestId: 'request-ask-1', toolName: 'AskUserQuestion', toolUseId: 'tool-ask-1', input: { questions: [ { header: 'Scope', question: 'Which scope?', options: [{ label: 'A', description: 'First' }, { label: 'B', description: 'Second' }], }, ], }, description: 'Answer questions?', }, ]) handleWebSocket.open(ws) expect(ws.sent.map((payload) => JSON.parse(payload))).toContainEqual({ type: 'permission_request', requestId: 'request-ask-1', toolName: 'AskUserQuestion', toolUseId: 'tool-ask-1', input: { questions: [ { header: 'Scope', question: 'Which scope?', options: [{ label: 'A', description: 'First' }, { label: 'B', description: 'Second' }], }, ], }, description: 'Answer questions?', }) }) it('keeps disconnected sessions alive longer while user input is pending', () => { const sessionId = `permission-disconnect-${crypto.randomUUID()}` const ws = makeClientSocket(sessionId) const setTimeoutSpy = spyOn(globalThis, 'setTimeout').mockImplementation(() => 0 as any) spyOn(conversationService, 'getPendingPermissionRequests').mockReturnValue([ { requestId: 'request-ask-1', toolName: 'AskUserQuestion', toolUseId: 'tool-ask-1', input: { questions: [] }, }, ]) handleWebSocket.open(ws) setTimeoutSpy.mockClear() handleWebSocket.close(ws, 1006, 'renderer reconnecting') expect(setTimeoutSpy).toHaveBeenCalled() expect(setTimeoutSpy.mock.calls[0]?.[1]).toBeGreaterThan(30_000) }) })