cc-haha/src/server/__tests__/websocket-handler.test.ts
程序员阿江(Relakkes) d327b7fb30 fix: preserve pending user questions across reconnects
Desktop AskUserQuestion waits on the SDK permission bridge, and a transient renderer disconnect could previously let the short no-client cleanup kill the CLI. That turned a live user question into an AbortError and left history restore showing a stale interactive card.

Track live SDK permission requests, replay them to reconnecting clients, and give sessions waiting on user input a longer cleanup grace. Render aborted terminal results as completed history instead of an unanswered prompt.

Constraint: SDK permission requests are live in-memory control messages until answered or aborted.

Rejected: Only de-dupe history cards in MessageList | would not keep the CLI alive or restore the active permission request.

Rejected: Disable disconnect cleanup globally | would leak abandoned CLI processes for ordinary disconnected sessions.

Confidence: high

Scope-risk: moderate

Reversibility: clean

Directive: Do not shorten pending-permission cleanup without testing AskUserQuestion reconnect and abort paths.

Tested: bun run check:desktop

Tested: bun run check:server

Tested: git diff --check

Not-tested: Live Sub2API provider reconnect repro.
2026-05-23 19:06:59 +08:00

139 lines
4.6 KiB
TypeScript

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<WebSocketData> & { 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)
})
})