mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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
459 lines
13 KiB
TypeScript
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' },
|
|
])
|
|
})
|
|
})
|