fix: prevent idle tabs showing active during prewarm (#681)

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
This commit is contained in:
程序员阿江(Relakkes) 2026-06-01 16:52:58 +08:00
parent e9e4f3e1e5
commit d075276a17
2 changed files with 34 additions and 1 deletions

View File

@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'
import type { ServerWebSocket } from 'bun'
import {
__markPrewarmPendingForTests,
__resetWebSocketHandlerStateForTests,
closeSessionConnection,
getActiveSessionIds,
@ -135,4 +136,30 @@ describe('WebSocket handler session isolation', () => {
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' })
})
})

View File

@ -150,7 +150,7 @@ export const handleWebSocket = {
}
addActiveClient(sessionId, ws)
if (prewarmedSessions.has(sessionId)) {
if (prewarmPendingSessions.has(sessionId) || prewarmedSessions.has(sessionId)) {
bindPrewarmMetadataCapture(sessionId)
} else {
bindClientSessionOutput(sessionId, ws)
@ -2235,5 +2235,11 @@ export function __resetWebSocketHandlerStateForTests(): void {
activeSessions.clear()
clientOutputCallbacks.clear()
sessionCleanupTimers.clear()
prewarmPendingSessions.clear()
prewarmedSessions.clear()
prewarmIdleTimers.clear()
}
export function __markPrewarmPendingForTests(sessionId: string): void {
prewarmPendingSessions.add(sessionId)
}