diff --git a/cli/src/claude/claudeLocalLauncher.test.ts b/cli/src/claude/claudeLocalLauncher.test.ts new file mode 100644 index 00000000..fedf6a0a --- /dev/null +++ b/cli/src/claude/claudeLocalLauncher.test.ts @@ -0,0 +1,108 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const harness = vi.hoisted(() => ({ + launches: [] as Array>, + scannerOnMessage: null as ((message: Record) => void) | null +})) + +vi.mock('./claudeLocal', () => ({ + claudeLocal: async (opts: Record) => { + harness.launches.push(opts) + } +})) + +vi.mock('./utils/sessionScanner', () => ({ + createSessionScanner: async (opts: { onMessage: (message: Record) => void }) => { + harness.scannerOnMessage = opts.onMessage + return { + cleanup: async () => {}, + onNewSession: () => {} + } + } +})) + +vi.mock('@/modules/common/launcher/BaseLocalLauncher', () => ({ + BaseLocalLauncher: class { + constructor(private readonly opts: { launch: (signal: AbortSignal) => Promise }) {} + async run(): Promise<'exit'> { + await this.opts.launch(new AbortController().signal) + return 'exit' + } + } +})) + +import { claudeLocalLauncher } from './claudeLocalLauncher' + +function createSessionStub() { + const sentMessages: Array> = [] + return { + session: { + sessionId: 'test-session', + path: '/tmp/test', + startedBy: 'terminal' as const, + startingMode: 'local' as const, + claudeEnvVars: {}, + claudeArgs: [], + mcpServers: [], + allowedTools: [], + hookSettingsPath: null, + queue: { size: () => 0, reset: () => {}, setOnMessage: () => {} }, + client: { + sendClaudeSessionMessage: (msg: Record) => { sentMessages.push(msg) }, + rpcHandlerManager: { registerHandler: () => {} } + }, + addSessionFoundCallback: () => {}, + removeSessionFoundCallback: () => {}, + consumeOneTimeFlags: () => {}, + recordLocalLaunchFailure: () => {} + }, + sentMessages + } +} + +describe('claudeLocalLauncher message filtering', () => { + afterEach(() => { + harness.launches = [] + harness.scannerOnMessage = null + }) + + it('filters out summary messages', async () => { + const { session, sentMessages } = createSessionStub() + await claudeLocalLauncher(session as never) + + harness.scannerOnMessage!({ type: 'summary', leafUuid: '1' }) + + expect(sentMessages).toHaveLength(0) + }) + + it('filters out invisible system messages', async () => { + const { session, sentMessages } = createSessionStub() + await claudeLocalLauncher(session as never) + + harness.scannerOnMessage!({ type: 'system', subtype: 'init', uuid: '1' }) + harness.scannerOnMessage!({ type: 'system', subtype: 'stop_hook_summary', uuid: '2' }) + harness.scannerOnMessage!({ type: 'system', uuid: '3' }) + + expect(sentMessages).toHaveLength(0) + }) + + it('forwards visible system messages', async () => { + const { session, sentMessages } = createSessionStub() + await claudeLocalLauncher(session as never) + + harness.scannerOnMessage!({ type: 'system', subtype: 'api_error', uuid: '1' }) + harness.scannerOnMessage!({ type: 'system', subtype: 'turn_duration', uuid: '2' }) + + expect(sentMessages).toHaveLength(2) + }) + + it('forwards normal conversation messages', async () => { + const { session, sentMessages } = createSessionStub() + await claudeLocalLauncher(session as never) + + harness.scannerOnMessage!({ type: 'user', uuid: '1' }) + harness.scannerOnMessage!({ type: 'assistant', uuid: '2' }) + + expect(sentMessages).toHaveLength(2) + }) +}) diff --git a/cli/src/claude/claudeLocalLauncher.ts b/cli/src/claude/claudeLocalLauncher.ts index 5c1c5ac6..5bada013 100644 --- a/cli/src/claude/claudeLocalLauncher.ts +++ b/cli/src/claude/claudeLocalLauncher.ts @@ -1,6 +1,7 @@ import { claudeLocal } from "./claudeLocal"; import { Session } from "./session"; import { createSessionScanner } from "./utils/sessionScanner"; +import { isClaudeChatVisibleMessage } from "./utils/chatVisibility"; import { BaseLocalLauncher } from "@/modules/common/launcher/BaseLocalLauncher"; export async function claudeLocalLauncher(session: Session): Promise<'switch' | 'exit'> { @@ -9,11 +10,17 @@ export async function claudeLocalLauncher(session: Session): Promise<'switch' | const scanner = await createSessionScanner({ sessionId: session.sessionId, workingDirectory: session.path, - onMessage: (message) => { + onMessage: (message) => { // Block SDK summary messages - we generate our own - if (message.type !== 'summary') { - session.client.sendClaudeSessionMessage(message) + if (message.type === 'summary') { + return } + // Filter out invisible system messages (e.g. init, stop_hook_summary) + // to avoid them showing as raw JSON in the web UI + if (!isClaudeChatVisibleMessage(message)) { + return + } + session.client.sendClaudeSessionMessage(message) } });