mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
桌面端用 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>
563 lines
17 KiB
TypeScript
563 lines
17 KiB
TypeScript
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' },
|
||
])
|
||
})
|
||
})
|