fix(desktop): restore permission mode after exiting plan mode (#623)

桌面端用 bypassPermissions 进入计划模式、退出后,权限选择器停留在"计划模式",
导致下一个工具调用又弹权限申请。两层根因都修:

- 回传链路断裂:服务端 WS handler 把 CLI 广播的权限模式变化(status:null +
  permissionMode)当成 thinking 丢弃,桌面端协议也没有承载权限模式的入站消息,
  CLI 恢复后的权限永远同步不到 UI。新增 permission_mode_changed 回传通道,桌面端
  据此校正选择器(只更新本地、不回发避免回环;未知模式忽略)。

- 重启抹掉 prePlanMode:bypass→plan 因策略"切换涉及 bypass 就重启"而重启 CLI,
  新进程直接以 plan 启动、prePlanMode 为空,ExitPlanMode 只能恢复成 default 而非
  bypassPermissions。收窄 needsRestart 为只在"进入 bypass"时重启;从 bypass 切出
  保持进程不变、走进程内 transition,CLI 才会栈存 prePlanMode 并在退出 plan 时
  正确恢复 bypass,与 TUI 行为一致。

测试:服务端 ws-memory-events 新增权限回传 + 重启策略用例;桌面端 chatStore 新增
回传校正 + 防回环 + 未知模式忽略用例。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-06-04 22:28:17 +08:00
parent 4a53a1659f
commit 892f5e193a
6 changed files with 128 additions and 7 deletions

View File

@ -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: {

View File

@ -1342,6 +1342,18 @@ export const useChatStore = create<ChatStore>((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

View File

@ -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

View File

@ -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({

View File

@ -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

View File

@ -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 bypassplan
* CLI plan prePlanMode ExitPlanMode default
* bypassPermissions setPermissionMode transitionCLI 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<WebSocketData>,
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' }]
}