From d075276a17b68af6fabd6a7bc1d19cd9e16520b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 1 Jun 2026 16:52:58 +0800 Subject: [PATCH] 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 --- .../__tests__/websocket-handler.test.ts | 27 +++++++++++++++++++ src/server/ws/handler.ts | 8 +++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/server/__tests__/websocket-handler.test.ts b/src/server/__tests__/websocket-handler.test.ts index 4584ec2b..34adcdba 100644 --- a/src/server/__tests__/websocket-handler.test.ts +++ b/src/server/__tests__/websocket-handler.test.ts @@ -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' }) + }) }) diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index 2c51c768..6abb0107 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -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) +}