import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test' import type { ServerWebSocket } from 'bun' import { __resetWebSocketHandlerStateForTests, closeSessionConnection, getActiveSessionIds, handleWebSocket, normalizeSlashCommandMetadata, 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('normalizes slash command metadata while preserving legacy string payloads', () => { expect(normalizeSlashCommandMetadata([ { name: 'goal', description: 'Create or manage an autonomous completion goal', argumentHint: '|status|pause|resume|clear|complete', whenToUse: 'Use when the session should keep iterating until done.', }, 'clear', { command: 'compact', description: 'Compact context' }, { name: ' ' }, ])).toEqual([ { name: 'goal', description: 'Create or manage an autonomous completion goal', argumentHint: '|status|pause|resume|clear|complete', whenToUse: 'Use when the session should keep iterating until done.', }, { name: 'clear', description: '' }, { name: 'compact', description: 'Compact context' }, ]) }) })