mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +08:00
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
This commit is contained in:
parent
d7a1306790
commit
e8fcccebf0
@ -2927,6 +2927,137 @@ describe('WebSocket Chat Integration', () => {
|
|||||||
})
|
})
|
||||||
}, 20_000)
|
}, 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<void>((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 () => {
|
it('should keep the session idle in the UI while restarting for a bypass permission switch', async () => {
|
||||||
await fetch(`${baseUrl}/api/permissions/mode`, {
|
await fetch(`${baseUrl}/api/permissions/mode`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
|
|||||||
@ -89,6 +89,7 @@ type ActiveUserTurnState = {
|
|||||||
const runtimeOverrides = new Map<string, RuntimeOverride>()
|
const runtimeOverrides = new Map<string, RuntimeOverride>()
|
||||||
const activeUserTurns = new Map<string, ActiveUserTurnState>()
|
const activeUserTurns = new Map<string, ActiveUserTurnState>()
|
||||||
const deferredRuntimeRestarts = new Map<string, RuntimeOverride>()
|
const deferredRuntimeRestarts = new Map<string, RuntimeOverride>()
|
||||||
|
const deferredPermissionModes = new Map<string, string>()
|
||||||
|
|
||||||
const runtimeTransitionPromises = new Map<string, Promise<void>>()
|
const runtimeTransitionPromises = new Map<string, Promise<void>>()
|
||||||
const sessionStartupPromises = new Map<string, Promise<void>>()
|
const sessionStartupPromises = new Map<string, Promise<void>>()
|
||||||
@ -444,6 +445,7 @@ function bindActiveUserTurnCompletion(
|
|||||||
|
|
||||||
conversationService.removeOutputCallback(sessionId, callback)
|
conversationService.removeOutputCallback(sessionId, callback)
|
||||||
clearActiveUserTurn(sessionId, activeTurn)
|
clearActiveUserTurn(sessionId, activeTurn)
|
||||||
|
applyDeferredPermissionModeAfterActiveTurn(ws, sessionId)
|
||||||
applyDeferredRuntimeRestartAfterActiveTurn(ws, sessionId)
|
applyDeferredRuntimeRestartAfterActiveTurn(ws, sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -455,6 +457,20 @@ function shouldDeferRuntimeRestartForActiveTurn(sessionId: string): boolean {
|
|||||||
return activeUserTurns.get(sessionId)?.messageSent === true
|
return activeUserTurns.get(sessionId)?.messageSent === true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyDeferredPermissionModeAfterActiveTurn(
|
||||||
|
ws: ServerWebSocket<WebSocketData>,
|
||||||
|
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(
|
function applyDeferredRuntimeRestartAfterActiveTurn(
|
||||||
ws: ServerWebSocket<WebSocketData>,
|
ws: ServerWebSocket<WebSocketData>,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
@ -626,8 +642,13 @@ async function applyPermissionModeToActiveSession(
|
|||||||
mode: string,
|
mode: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const currentMode = conversationService.getSessionPermissionMode(sessionId)
|
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)
|
const needsRestart = shouldRestartForPermissionMode(currentMode, mode)
|
||||||
|
|
||||||
if (needsRestart) {
|
if (needsRestart) {
|
||||||
@ -1159,6 +1180,7 @@ function cleanupSessionRuntimeState(sessionId: string) {
|
|||||||
runtimeOverrides.delete(sessionId)
|
runtimeOverrides.delete(sessionId)
|
||||||
activeUserTurns.delete(sessionId)
|
activeUserTurns.delete(sessionId)
|
||||||
deferredRuntimeRestarts.delete(sessionId)
|
deferredRuntimeRestarts.delete(sessionId)
|
||||||
|
deferredPermissionModes.delete(sessionId)
|
||||||
runtimeTransitionPromises.delete(sessionId)
|
runtimeTransitionPromises.delete(sessionId)
|
||||||
sessionStartupPromises.delete(sessionId)
|
sessionStartupPromises.delete(sessionId)
|
||||||
lastResolvedStartupWorkDirs.delete(sessionId)
|
lastResolvedStartupWorkDirs.delete(sessionId)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user