cc-haha/src/server/__tests__/ws-memory-events.test.ts
程序员阿江(Relakkes) 892f5e193a 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>
2026-06-04 22:29:01 +08:00

563 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, expect, it } from 'bun:test'
import {
createCurrentTurnLocalCommandForwarder,
shouldRestartForPermissionMode,
translateCliMessage,
} from '../ws/handler.js'
import { parseSlashCommand } from '../../utils/slashCommandParsing.js'
describe('WebSocket memory events', () => {
it('forwards assistant business error codes to the desktop client', () => {
expect(translateCliMessage({
type: 'assistant',
error: 'invalid_request',
isApiErrorMessage: true,
businessErrorCode: 'image_unsupported',
message: {
role: 'assistant',
content: [{ type: 'text', text: 'This model does not support images.' }],
},
}, 'session-1')).toEqual([
{
type: 'error',
message: 'This model does not support images.',
code: 'invalid_request',
businessErrorCode: 'image_unsupported',
},
])
})
it('forwards CLI memory_saved system messages to the desktop client', () => {
const messages = translateCliMessage(
{
type: 'system',
subtype: 'memory_saved',
writtenPaths: [
'/Users/test/.claude/projects/example/memory/preferences.md',
'/Users/test/.claude/projects/example/memory/team/MEMORY.md',
],
teamCount: 1,
verb: 'Saved',
},
'session-1',
)
expect(messages).toEqual([
{
type: 'system_notification',
subtype: 'memory_saved',
message: undefined,
data: {
writtenPaths: [
'/Users/test/.claude/projects/example/memory/preferences.md',
'/Users/test/.claude/projects/example/memory/team/MEMORY.md',
],
teamCount: 1,
verb: 'Saved',
},
},
])
})
})
describe('WebSocket AskUserQuestion events', () => {
it('forwards structured AskUserQuestion answers from CLI toolUseResult metadata', () => {
expect(translateCliMessage({
type: 'user',
message: {
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'ask-1',
content: 'User has answered your questions: "Pick one?"="A". You can now continue with the user\'s answers in mind.',
},
],
},
toolUseResult: {
questions: [{ question: 'Pick one?', options: [{ label: 'A' }] }],
answers: { 'Pick one?': 'A' },
},
}, 'session-1')).toEqual([
{
type: 'tool_result',
toolUseId: 'ask-1',
content: {
questions: [{ question: 'Pick one?', options: [{ label: 'A' }] }],
answers: { 'Pick one?': 'A' },
},
isError: false,
parentToolUseId: undefined,
},
])
})
})
describe('WebSocket compact events', () => {
it('forwards CLI compacting status to the desktop client', () => {
expect(translateCliMessage({
type: 'system',
subtype: 'status',
status: 'compacting',
}, 'session-1')).toEqual([
{
type: 'status',
state: 'compacting',
verb: 'Compacting conversation',
},
])
expect(translateCliMessage({
type: 'system',
subtype: 'status',
status: null,
}, 'session-1')).toEqual([
{
type: 'status',
state: 'thinking',
verb: 'Thinking',
},
])
expect(translateCliMessage({
type: 'system',
subtype: 'status',
status: 'warming',
}, '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.',
'',
'Built the compact UI and verified the WebSocket event path.',
].join('\n')
expect(translateCliMessage({
type: 'user',
message: {
role: 'user',
content: summary,
},
isSynthetic: true,
}, 'session-1')).toEqual([
{
type: 'system_notification',
subtype: 'compact_summary',
message: summary,
data: { isSynthetic: true },
},
])
})
it('suppresses compact local command output after the compact summary', () => {
expect(translateCliMessage({
type: 'user',
message: {
role: 'user',
content: '<local-command-stdout>Compacted </local-command-stdout>',
},
}, 'session-1')).toEqual([])
})
})
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({
type: 'system',
subtype: 'api_retry',
attempt: 2,
max_retries: 10,
retry_delay_ms: 1500,
error_status: 503,
error: 'server_error',
}, 'session-1')).toEqual([
{
type: 'api_retry',
attempt: 2,
maxRetries: 10,
retryDelayMs: 1500,
errorStatus: 503,
errorType: 'server_error',
},
])
})
})
describe('WebSocket background task events', () => {
it('forwards task start and progress as structured desktop notifications', () => {
const started = {
type: 'system',
subtype: 'task_started',
task_id: 'agent-task-1',
tool_use_id: 'agent-tool-1',
description: 'Verify the todo app',
task_type: 'local_agent',
prompt: 'Run E2E checks',
}
expect(translateCliMessage(started, 'session-1')).toEqual([
{
type: 'system_notification',
subtype: 'task_started',
message: 'Verify the todo app',
data: started,
},
{
type: 'status',
state: 'tool_executing',
verb: 'Verify the todo app',
},
])
const progress = {
type: 'system',
subtype: 'task_progress',
task_id: 'agent-task-1',
tool_use_id: 'agent-tool-1',
description: 'Verify the todo app',
summary: 'Running Playwright checks',
last_tool_name: 'Bash',
usage: {
total_tokens: 1200,
tool_uses: 4,
duration_ms: 45000,
},
}
expect(translateCliMessage(progress, 'session-1')).toEqual([
{
type: 'system_notification',
subtype: 'task_progress',
message: 'Running Playwright checks',
data: progress,
},
{
type: 'status',
state: 'tool_executing',
verb: 'Running Playwright checks',
},
])
})
})
describe('WebSocket goal command events', () => {
const goalStatusOutput = 'Goal set: ship the smoke test'
const runGoalCommand = (sessionId: string, args: string, output: string, type: 'system' | 'user' = 'system') => {
expect(translateCliMessage({
type: 'system',
subtype: 'local_command',
content: [
{ text: '<command-name>/goal</command-name>' },
{ text: `<command-args>${args}</command-args>` },
],
}, sessionId)).toEqual([])
if (type === 'user') {
return translateCliMessage({
type: 'user',
message: {
content: [{
type: 'text',
text: `<local-command-stdout>${output}</local-command-stdout>`,
}],
},
}, sessionId)
}
return translateCliMessage({
type: 'system',
subtype: 'local_command_output',
content: `<local-command-stdout>${output}</local-command-stdout>`,
}, sessionId)
}
it('turns confirmed /goal local command output into a desktop goal event', () => {
const sessionId = `goal-event-${crypto.randomUUID()}`
expect(translateCliMessage({
type: 'system',
subtype: 'local_command',
content: '<command-name>/goal</command-name>\n<command-args>ship the smoke test</command-args>',
}, sessionId)).toEqual([])
expect(translateCliMessage({
type: 'system',
subtype: 'local_command',
content: [
'<local-command-stdout>',
goalStatusOutput,
'</local-command-stdout>',
].join('\n'),
}, sessionId)).toEqual([
{
type: 'system_notification',
subtype: 'goal_event',
message: goalStatusOutput,
data: {
action: 'created',
status: 'active',
objective: 'ship the smoke test',
message: goalStatusOutput,
},
},
])
})
it('classifies /goal clear and completion output for the desktop client', () => {
expect(runGoalCommand(`goal-complete-${crypto.randomUUID()}`, 'complete', 'Goal marked complete.')).toEqual([
expect.objectContaining({
type: 'system_notification',
subtype: 'goal_event',
data: { action: 'completed', message: 'Goal marked complete.' },
}),
])
expect(runGoalCommand(`goal-clear-${crypto.randomUUID()}`, 'clear', 'Goal cleared: ship docs')).toEqual([
expect.objectContaining({
type: 'system_notification',
subtype: 'goal_event',
data: { action: 'cleared', message: 'Goal cleared: ship docs' },
}),
])
})
it('allows direct /goal local command output through the pre-turn mute gate', () => {
const shouldForward = createCurrentTurnLocalCommandForwarder(
parseSlashCommand('/goal ship the smoke test'),
)
expect(shouldForward({
type: 'system',
subtype: 'local_command_output',
content: '<local-command-stdout>Goal set: ship the smoke test</local-command-stdout>',
})).toBe(true)
})
it('keeps negative /goal command output visible as a goal message event', () => {
expect(runGoalCommand(`goal-empty-${crypto.randomUUID()}`, '', 'No active goal.', 'user')).toEqual([
{
type: 'system_notification',
subtype: 'goal_event',
message: 'No active goal.',
data: { action: 'message', message: 'No active goal.' },
},
])
})
it('does not turn unrelated local command output into a goal event', () => {
const sessionId = `goal-unrelated-${crypto.randomUUID()}`
expect(translateCliMessage({
type: 'system',
subtype: 'local_command',
content: '<command-name>/status</command-name>',
}, sessionId)).toEqual([])
expect(translateCliMessage({
type: 'system',
subtype: 'local_command_output',
content: '<local-command-stdout>Goal: active</local-command-stdout>',
}, sessionId)).toEqual([
{ type: 'content_start', blockType: 'text' },
{ type: 'content_delta', text: 'Goal: active' },
])
})
it('allows the current slash command lifecycle through the pre-turn mute gate', () => {
const shouldForward = createCurrentTurnLocalCommandForwarder(
parseSlashCommand('/goal ship the smoke test'),
)
expect(shouldForward({
type: 'system',
subtype: 'init',
})).toBe(false)
expect(shouldForward({
type: 'system',
subtype: 'local_command',
content: '<command-name>/cost</command-name>\n<command-args></command-args>',
})).toBe(false)
expect(shouldForward({
type: 'system',
subtype: 'local_command',
content: '<command-name>/goal</command-name>\n<command-args>ship the smoke test</command-args>',
})).toBe(true)
expect(shouldForward({
type: 'system',
subtype: 'local_command',
content: '<local-command-stdout>Goal set: ship the smoke test</local-command-stdout>',
})).toBe(true)
expect(shouldForward({
type: 'system',
subtype: 'local_command_output',
content: 'late unrelated output',
})).toBe(false)
})
})
describe('WebSocket stream event translation', () => {
it('keeps subagent parent linkage when later stream events omit the parent id', () => {
const sessionId = `subagent-parent-${crypto.randomUUID()}`
expect(translateCliMessage({
type: 'stream_event',
parent_tool_use_id: 'agent-1',
event: {
type: 'content_block_start',
index: 0,
content_block: { type: 'tool_use', id: 'read-1', name: 'Read' },
},
}, sessionId)).toEqual([
{
type: 'content_start',
blockType: 'tool_use',
toolName: 'Read',
toolUseId: 'read-1',
parentToolUseId: 'agent-1',
},
])
expect(translateCliMessage({
type: 'stream_event',
event: {
type: 'content_block_delta',
index: 0,
delta: { type: 'input_json_delta', partial_json: '{"file_path":"src/App.tsx"}' },
},
}, sessionId)).toEqual([
{ type: 'content_delta', toolInput: '{"file_path":"src/App.tsx"}' },
])
expect(translateCliMessage({
type: 'stream_event',
event: { type: 'content_block_stop', index: 0 },
}, sessionId)).toEqual([
{
type: 'tool_use_complete',
toolName: 'Read',
toolUseId: 'read-1',
input: { file_path: 'src/App.tsx' },
parentToolUseId: 'agent-1',
},
])
expect(translateCliMessage({
type: 'user',
message: {
content: [
{ type: 'tool_result', tool_use_id: 'read-1', content: 'ok' },
],
},
}, sessionId)).toEqual([
{
type: 'tool_result',
toolUseId: 'read-1',
content: 'ok',
isError: false,
parentToolUseId: 'agent-1',
},
])
})
it('keeps DeepSeek-style thinking blocks in thinking state until text starts', () => {
const sessionId = `deepseek-thinking-${crypto.randomUUID()}`
expect(translateCliMessage({
type: 'stream_event',
event: { type: 'message_start' },
}, sessionId)).toEqual([
{ type: 'status', state: 'thinking' },
])
expect(translateCliMessage({
type: 'stream_event',
event: {
type: 'content_block_start',
index: 0,
content_block: { type: 'thinking', thinking: '' },
},
}, sessionId)).toEqual([
{ type: 'status', state: 'thinking', verb: 'Thinking' },
])
expect(translateCliMessage({
type: 'stream_event',
event: {
type: 'content_block_delta',
index: 0,
delta: { type: 'thinking_delta', thinking: 'Let me think' },
},
}, sessionId)).toEqual([
{ type: 'thinking', text: 'Let me think' },
])
expect(translateCliMessage({
type: 'stream_event',
event: { type: 'content_block_stop', index: 0 },
}, sessionId)).toEqual([])
expect(translateCliMessage({
type: 'stream_event',
event: {
type: 'content_block_start',
index: 1,
content_block: { type: 'text', text: '' },
},
}, sessionId)).toEqual([
{ type: 'content_start', blockType: 'text' },
])
})
})