cc-haha/src/server/__tests__/ws-memory-events.test.ts
程序员阿江(Relakkes) cb922b018d fix: prevent desktop compact state from sticking
Desktop compact events can arrive as a start status followed by a null
CLI status or by a summary boundary. Preserve the visible transcript while
making either completion path clear transient compacting UI so the session
cannot remain stuck after auto-compact or cancellation.

Constraint: Desktop receives compact state through translated CLI SDK status events.
Rejected: Collapse old transcript into a single compact card | hides useful history and caused issue #568.
Confidence: high
Scope-risk: moderate
Directive: Do not drop status=null compact exits without validating desktop chat state transitions.
Tested: bun run check:desktop
Tested: bun run check:server
Tested: bun run check:policy
Tested: bun run check:native
Tested: bun run check:coverage
Tested: SKIP_INSTALL=1 PRESERVE_TAURI_TARGET=1 ./desktop/scripts/build-macos-arm64.sh
Tested: Real packaged macOS app with GPT-5.5 completed two live turns without staying in running state.
Not-tested: Full live auto-compact threshold trigger in packaged app; Tauri sidecar did not inherit CLAUDE_AUTOCOMPACT_PCT_OVERRIDE during manual smoke.
Related: https://github.com/NanmiCoder/cc-haha/issues/567
Related: https://github.com/NanmiCoder/cc-haha/issues/568
2026-05-22 19:16:53 +08:00

459 lines
13 KiB
TypeScript

import { describe, expect, it } from 'bun:test'
import {
createCurrentTurnLocalCommandForwarder,
translateCliMessage,
} from '../ws/handler.js'
import { parseSlashCommand } from '../../utils/slashCommandParsing.js'
describe('WebSocket memory events', () => {
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 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 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 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' },
])
})
})