diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index 83a5f307..47276030 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -2022,6 +2022,35 @@ describe('chatStore history mapping', () => { expect(updateSessionPermissionModeMock).toHaveBeenCalledWith('session-1', 'acceptEdits') }) + it('mirrors CLI permission-mode broadcasts locally without echoing back to the server', () => { + sendMock.mockReset() + updateSessionPermissionModeMock.mockReset() + + // CLI 退出 plan 后恢复到 bypassPermissions,回传 permission_mode_changed。 + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'permission_mode_changed', + mode: 'bypassPermissions', + }) + + // 本地镜像被校正…… + expect(updateSessionPermissionModeMock).toHaveBeenCalledWith(TEST_SESSION_ID, 'bypassPermissions') + // ……但绝不能再 set_permission_mode 回发给 CLI,否则形成回环。 + expect(sendMock).not.toHaveBeenCalled() + }) + + it('ignores permission-mode broadcasts for modes the selector cannot render', () => { + updateSessionPermissionModeMock.mockReset() + + // 'auto' 不在桌面端 PermissionMode 内(仅在 CLI 启用对应特性时存在), + // 直接忽略,避免选择器拿到无法渲染的值。 + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'permission_mode_changed', + mode: 'auto' as never, + }) + + expect(updateSessionPermissionModeMock).not.toHaveBeenCalled() + }) + it('stores terminal task notifications for agent tool cards', () => { useChatStore.setState({ sessions: { diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index e1de0a2c..d237ef94 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -1342,6 +1342,18 @@ export const useChatStore = create((set, get) => ({ useTabStore.getState().updateTabStatus(sessionId, msg.state === 'idle' ? 'idle' : 'running') break + case 'permission_mode_changed': { + // CLI 是权限模式的真相来源。这里把它恢复/切换后的权威值校正到本地镜像。 + // 注意:只更新本地状态,**不要**走 setSessionPermissionMode —— 那会把 + // set_permission_mode 再回发给 CLI 形成回环。未知模式(如未启用对应特性 + // 的 'auto')直接忽略,避免选择器拿到无法渲染的值。 + const KNOWN_MODES: PermissionMode[] = ['default', 'acceptEdits', 'plan', 'bypassPermissions', 'dontAsk'] + if (KNOWN_MODES.includes(msg.mode)) { + useSessionStore.getState().updateSessionPermissionMode(sessionId, msg.mode) + } + break + } + case 'content_start': { const session = get().sessions[sessionId] if (!session) break diff --git a/desktop/src/types/chat.ts b/desktop/src/types/chat.ts index bc0ce247..3288431b 100644 --- a/desktop/src/types/chat.ts +++ b/desktop/src/types/chat.ts @@ -75,6 +75,9 @@ export type ServerMessage = | { type: 'message_complete'; usage: TokenUsage } | { type: 'thinking'; text: string } | { type: 'status'; state: ChatState; verb?: string; elapsed?: number; tokens?: number } + // CLI 回传的权限模式变化(如 ExitPlanMode 退出 plan 后恢复、Shift+Tab)。 + // 桌面端据此把选择器校正回 CLI 的真实权限,避免本地影子值漂移。 + | { type: 'permission_mode_changed'; mode: PermissionMode } | { type: 'api_retry' attempt: number diff --git a/src/server/__tests__/ws-memory-events.test.ts b/src/server/__tests__/ws-memory-events.test.ts index d4c518a7..93aae215 100644 --- a/src/server/__tests__/ws-memory-events.test.ts +++ b/src/server/__tests__/ws-memory-events.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'bun:test' import { createCurrentTurnLocalCommandForwarder, + shouldRestartForPermissionMode, translateCliMessage, } from '../ws/handler.js' import { parseSlashCommand } from '../../utils/slashCommandParsing.js' @@ -125,6 +126,29 @@ describe('WebSocket compact events', () => { }, 'session-1')).toEqual([]) }) + it('forwards CLI permission-mode broadcasts instead of dropping them as thinking', () => { + // CLI 在退出 plan 模式后恢复权限时会广播一条 status:null + permissionMode + // 的事件。它必须被翻译成 permission_mode_changed,而不是被 null→thinking + // 兜底吞掉 —— 这正是桌面端选择器卡在"计划模式"的根因。 + expect(translateCliMessage({ + type: 'system', + subtype: 'status', + status: null, + permissionMode: 'bypassPermissions', + }, 'session-1')).toEqual([ + { type: 'permission_mode_changed', mode: 'bypassPermissions' }, + ]) + + // 普通 thinking(无 permissionMode)仍走原路径,不受影响。 + expect(translateCliMessage({ + type: 'system', + subtype: 'status', + status: null, + }, 'session-1')).toEqual([ + { type: 'status', state: 'thinking', verb: 'Thinking' }, + ]) + }) + it('forwards compact summaries as system notifications instead of user chat bubbles', () => { const summary = [ 'This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.', @@ -160,6 +184,32 @@ describe('WebSocket compact events', () => { }) }) +describe('WebSocket permission mode restart policy', () => { + it('restarts the CLI only when entering bypassPermissions', () => { + // 进入 bypass 需要带 --dangerously-skip-permissions 重启子进程。 + expect(shouldRestartForPermissionMode('default', 'bypassPermissions')).toBe(true) + expect(shouldRestartForPermissionMode('plan', 'bypassPermissions')).toBe(true) + expect(shouldRestartForPermissionMode('acceptEdits', 'bypassPermissions')).toBe(true) + }) + + it('does NOT restart when leaving bypassPermissions for a stricter mode', () => { + // 从 bypass 切出不重启——否则会冲掉进程内 prePlanMode,导致 ExitPlanMode 后 + // 恢复成 default 而非进入 plan 前的 bypassPermissions。这正是桌面端退出 plan + // 权限回不到 bypass 的根因。 + expect(shouldRestartForPermissionMode('bypassPermissions', 'plan')).toBe(false) + expect(shouldRestartForPermissionMode('bypassPermissions', 'default')).toBe(false) + expect(shouldRestartForPermissionMode('bypassPermissions', 'acceptEdits')).toBe(false) + }) + + it('does not restart for non-bypass transitions or no-op changes', () => { + expect(shouldRestartForPermissionMode('default', 'plan')).toBe(false) + expect(shouldRestartForPermissionMode('plan', 'acceptEdits')).toBe(false) + // 同模式(含 bypass→bypass)是 no-op,不重启。 + expect(shouldRestartForPermissionMode('bypassPermissions', 'bypassPermissions')).toBe(false) + expect(shouldRestartForPermissionMode('plan', 'plan')).toBe(false) + }) +}) + describe('WebSocket API retry events', () => { it('forwards CLI api_retry messages as structured retry status', () => { expect(translateCliMessage({ diff --git a/src/server/ws/events.ts b/src/server/ws/events.ts index aba96873..160262a6 100644 --- a/src/server/ws/events.ts +++ b/src/server/ws/events.ts @@ -63,6 +63,10 @@ export type ServerMessage = | { type: 'message_complete'; usage: TokenUsage } | { type: 'thinking'; text: string } | { type: 'status'; state: ChatState; verb?: string; elapsed?: number; tokens?: number } + // CLI 是权限模式的唯一真相来源。当 CLI 内部 mode 变化(如 ExitPlanMode 后 + // 恢复到进入 plan 前的模式、Shift+Tab 切换)时,把新模式回传给前端,让桌面端 + // 选择器与 CLI 保持同步,而不是停留在本地影子值上。 + | { type: 'permission_mode_changed'; mode: string } | { type: 'api_retry' attempt: number diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index c7bc325f..2ea95be7 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -529,6 +529,27 @@ async function handleSetPermissionMode( await applyPermissionModeToActiveSession(ws, sessionId, message.mode) } +/** + * 决定一次权限模式切换是否需要重启 CLI 子进程。 + * + * 只有"进入 bypassPermissions"才需要重启:CLI 必须带 --dangerously-skip-permissions + * 启动,否则运行时的 set_permission_mode → bypassPermissions 会被拒绝,所以重启子进程 + * 带上该 flag。 + * + * 反过来"从 bypassPermissions 切到更严格的模式"**不要**重启:此时进程已带 flag,运行时 + * 降级即可。更关键的是——重启会把进程内的 prePlanMode 记忆冲掉:若 bypass→plan 走重启, + * 新 CLI 直接以 plan 启动、prePlanMode 为空,ExitPlanMode 只能恢复成 default 而非进入前的 + * bypassPermissions。保持进程不变、走 setPermissionMode 做进程内 transition,CLI 才会像 TUI + * 一样栈存 prePlanMode='bypassPermissions',退出 plan 时正确恢复 bypass。 + */ +export function shouldRestartForPermissionMode( + currentMode: string, + mode: string, +): boolean { + if (currentMode === mode) return false + return mode === 'bypassPermissions' +} + async function applyPermissionModeToActiveSession( ws: ServerWebSocket, sessionId: string, @@ -537,13 +558,7 @@ async function applyPermissionModeToActiveSession( const currentMode = conversationService.getSessionPermissionMode(sessionId) if (currentMode === mode) return - // Switching to/from bypassPermissions requires the CLI to be (re)started with - // --dangerously-skip-permissions. The CLI rejects a runtime set_permission_mode - // to bypassPermissions if it wasn't launched with that flag. Rather than just - // sending the SDK message (which would silently fail), restart the CLI subprocess - // with the correct arguments so the new permission mode takes effect. - const needsRestart = - mode === 'bypassPermissions' || currentMode === 'bypassPermissions' + const needsRestart = shouldRestartForPermissionMode(currentMode, mode) if (needsRestart) { void enqueueRuntimeTransition(sessionId, () => @@ -1597,6 +1612,14 @@ export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessa verb: 'Compacting conversation', }] } + // CLI 在权限模式变化时也会 enqueue 一条 status 事件(status:null + + // permissionMode),用于把恢复后的真实权限(如 ExitPlanMode 退出 plan、 + // Shift+Tab)广播给前端。它带 status:null 但**不是** thinking 信号, + // 必须在下面的 null→thinking 兜底之前拦截,否则字段会被丢弃,桌面端 + // 选择器就会一直卡在"计划模式"。 + if (typeof cliMsg.permissionMode === 'string') { + return [{ type: 'permission_mode_changed', mode: cliMsg.permissionMode }] + } if (cliMsg.status == null) { return [{ type: 'status', state: 'thinking', verb: 'Thinking' }] }