| 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' }, |
| ]) |
| }) |
| }) |
|
|