mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
4a53a1659f
commit
892f5e193a
@ -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: {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<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' }]
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user