mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Desktop reconnects can attach a normal output callback while a session prewarm is still pending. CLI startup status then reaches the renderer and marks restored idle tabs as running even though no user turn is active. Treat pending prewarm like completed prewarm and bind metadata-only output until a real user turn claims the session. Constraint: Prewarm still needs init and slash-command metadata without streaming UI status.\nRejected: Filter status in TabBar | leaves other clients and sidebar state exposed to the stale activity signal.\nConfidence: high\nScope-risk: narrow\nTested: bun test src/server/__tests__/websocket-handler.test.ts\nTested: bun test src/server/__tests__/conversations.test.ts -t prewarm\nTested: bun run check:server\nNot-tested: Manual desktop restart visual smoke
166 lines
5.7 KiB
TypeScript
166 lines
5.7 KiB
TypeScript
import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'
|
|
import type { ServerWebSocket } from 'bun'
|
|
import {
|
|
__markPrewarmPendingForTests,
|
|
__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)
|
|
})
|
|
|
|
it('does not forward prewarm startup status to a reconnecting client', async () => {
|
|
const sessionId = `prewarm-reconnect-${crypto.randomUUID()}`
|
|
const second = makeClientSocket(sessionId)
|
|
let outputCallback: ((cliMsg: any) => void) | null = null
|
|
|
|
__markPrewarmPendingForTests(sessionId)
|
|
spyOn(conversationService, 'hasSession').mockReturnValue(true)
|
|
spyOn(conversationService, 'getRecentSdkMessages').mockReturnValue([])
|
|
spyOn(conversationService, 'onOutput').mockImplementation((_sid, callback) => {
|
|
outputCallback = callback
|
|
})
|
|
spyOn(conversationService, 'removeOutputCallback').mockImplementation(() => {})
|
|
spyOn(conversationService, 'clearOutputCallbacks').mockImplementation(() => {
|
|
outputCallback = null
|
|
})
|
|
|
|
handleWebSocket.open(second)
|
|
outputCallback?.({
|
|
type: 'stream_event',
|
|
event: { type: 'message_start' },
|
|
})
|
|
|
|
const secondMessages = second.sent.map((payload) => JSON.parse(payload))
|
|
expect(secondMessages).not.toContainEqual({ type: 'status', state: 'thinking' })
|
|
})
|
|
})
|