From e8fcccebf06ad48bb5b638903b250f7d6136cf12 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: Wed, 10 Jun 2026 23:59:59 +0800 Subject: [PATCH] fix: defer permission restarts during active turns (#626) Persist permission mode changes immediately, but defer restart-only permission transitions until the active desktop turn emits its terminal result. Tested: - bun test src/server/__tests__/conversations.test.ts -t "should defer bypass permission restarts until the active turn completes" - bun test src/server/__tests__/conversations.test.ts -t "permission" - bun run check:server Scope-risk: narrow --- src/server/__tests__/conversations.test.ts | 131 +++++++++++++++++++++ src/server/ws/handler.ts | 24 +++- 2 files changed, 154 insertions(+), 1 deletion(-) diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts index 1d3e1644..22df7232 100644 --- a/src/server/__tests__/conversations.test.ts +++ b/src/server/__tests__/conversations.test.ts @@ -2927,6 +2927,137 @@ describe('WebSocket Chat Integration', () => { }) }, 20_000) + it('should defer bypass permission restarts until the active turn completes', async () => { + await withMockStreamDelay(350, async () => { + await fetch(`${baseUrl}/api/permissions/mode`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode: 'default' }), + }) + + const createRes = await fetch(`${baseUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workDir: process.cwd() }), + }) + expect(createRes.status).toBe(201) + const { sessionId } = await createRes.json() as { sessionId: string } + + const originalStartSession = conversationService.startSession.bind(conversationService) + const startCalls: Array<{ + sessionId: string + options: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null } | undefined + }> = [] + + conversationService.startSession = (async function patchedStartSession( + sid: string, + workDir: string, + sdkUrl: string, + options?: { permissionMode?: string; model?: string; effort?: string; thinking?: 'enabled' | 'adaptive' | 'disabled'; providerId?: string | null }, + ) { + startCalls.push({ sessionId: sid, options }) + return originalStartSession(sid, workDir, sdkUrl, options) + }) as typeof conversationService.startSession + + const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`) + let switchTriggered = false + let turnComplete = false + try { + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + ws.close() + reject(new Error(`Timed out waiting for active-turn permission switch for session ${sessionId}`)) + }, 10_000) + + ws.onmessage = (event) => { + const msg = JSON.parse(event.data as string) + + if (msg.type === 'connected') { + ws.send(JSON.stringify({ type: 'user_message', content: 'active turn permission switch' })) + return + } + + if (msg.type === 'error') { + clearTimeout(timeout) + ws.close() + reject(new Error(msg.message)) + return + } + + if ( + msg.type === 'content_delta' && + typeof msg.text === 'string' && + msg.text.includes('active turn permission switch') && + !switchTriggered + ) { + switchTriggered = true + ws.send(JSON.stringify({ + type: 'set_permission_mode', + mode: 'bypassPermissions', + })) + return + } + + if ( + msg.type === 'status' && + msg.state === 'idle' && + switchTriggered && + !turnComplete && + startCalls.length > 1 + ) { + clearTimeout(timeout) + ws.close() + reject(new Error('Permission restart ran before the active turn completed')) + return + } + + if (msg.type === 'message_complete' && switchTriggered && !turnComplete) { + turnComplete = true + expect(startCalls).toHaveLength(1) + return + } + + if (msg.type === 'status' && msg.state === 'idle' && turnComplete && startCalls.length > 1) { + clearTimeout(timeout) + ws.close() + resolve() + } + } + + ws.onerror = () => { + clearTimeout(timeout) + reject(new Error(`WebSocket error for active-turn permission switch session ${sessionId}`)) + } + }) + + expect(switchTriggered).toBe(true) + expect(turnComplete).toBe(true) + expect(startCalls).toHaveLength(2) + expect(startCalls[0]).toMatchObject({ + sessionId, + options: { + permissionMode: 'default', + }, + }) + expect(startCalls[1]).toMatchObject({ + sessionId, + options: { + permissionMode: 'bypassPermissions', + }, + }) + } finally { + ws.close() + conversationService.startSession = originalStartSession + conversationService.stopSession(sessionId) + await fetch(`${baseUrl}/api/permissions/mode`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode: 'default' }), + }) + } + }) + }, 20_000) + it('should keep the session idle in the UI while restarting for a bypass permission switch', async () => { await fetch(`${baseUrl}/api/permissions/mode`, { method: 'PUT', diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index a0673b27..fff1c744 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -89,6 +89,7 @@ type ActiveUserTurnState = { const runtimeOverrides = new Map() const activeUserTurns = new Map() const deferredRuntimeRestarts = new Map() +const deferredPermissionModes = new Map() const runtimeTransitionPromises = new Map>() const sessionStartupPromises = new Map>() @@ -444,6 +445,7 @@ function bindActiveUserTurnCompletion( conversationService.removeOutputCallback(sessionId, callback) clearActiveUserTurn(sessionId, activeTurn) + applyDeferredPermissionModeAfterActiveTurn(ws, sessionId) applyDeferredRuntimeRestartAfterActiveTurn(ws, sessionId) } @@ -455,6 +457,20 @@ function shouldDeferRuntimeRestartForActiveTurn(sessionId: string): boolean { return activeUserTurns.get(sessionId)?.messageSent === true } +function applyDeferredPermissionModeAfterActiveTurn( + ws: ServerWebSocket, + sessionId: string, +): void { + const deferredMode = deferredPermissionModes.get(sessionId) + if (!deferredMode) return + + deferredPermissionModes.delete(sessionId) + void enqueueRuntimeTransition(sessionId, async () => { + if (!conversationService.hasSession(sessionId)) return + await applyPermissionModeToActiveSession(ws, sessionId, deferredMode) + }) +} + function applyDeferredRuntimeRestartAfterActiveTurn( ws: ServerWebSocket, sessionId: string, @@ -626,8 +642,13 @@ async function applyPermissionModeToActiveSession( mode: string, ): Promise { const currentMode = conversationService.getSessionPermissionMode(sessionId) - if (currentMode === mode) return + if (shouldDeferRuntimeRestartForActiveTurn(sessionId)) { + deferredPermissionModes.set(sessionId, mode) + await persistSessionPermissionMode(sessionId, mode) + return + } + if (currentMode === mode) return const needsRestart = shouldRestartForPermissionMode(currentMode, mode) if (needsRestart) { @@ -1159,6 +1180,7 @@ function cleanupSessionRuntimeState(sessionId: string) { runtimeOverrides.delete(sessionId) activeUserTurns.delete(sessionId) deferredRuntimeRestarts.delete(sessionId) + deferredPermissionModes.delete(sessionId) runtimeTransitionPromises.delete(sessionId) sessionStartupPromises.delete(sessionId) lastResolvedStartupWorkDirs.delete(sessionId)