import { beforeEach, describe, expect, it, vi } from 'vitest' import type { MessageEntry } from '../types/session' import { useSessionRuntimeStore } from './sessionRuntimeStore' const { sendMock, getMemberBySessionIdMock, sendMessageToMemberMock, handleTeamCreatedMock, handleTeamUpdateMock, handleTeamDeletedMock, fetchSessionTasksMock, clearTasksMock, setTasksFromTodosMock, markCompletedAndDismissedMock, resetCompletedTasksMock, refreshTasksMock, notifyDesktopMock, updateTabTitleMock, updateTabStatusMock, updateSessionTitleMock, updateSessionPermissionModeMock, sessionStoreSnapshot, cliTaskStoreSnapshot, } = vi.hoisted(() => ({ sendMock: vi.fn(), getMemberBySessionIdMock: vi.fn<(sessionId: string) => any>(() => null), sendMessageToMemberMock: vi.fn(async () => {}), handleTeamCreatedMock: vi.fn(), handleTeamUpdateMock: vi.fn(), handleTeamDeletedMock: vi.fn(), fetchSessionTasksMock: vi.fn(), clearTasksMock: vi.fn(), setTasksFromTodosMock: vi.fn(), markCompletedAndDismissedMock: vi.fn(), resetCompletedTasksMock: vi.fn(async () => {}), refreshTasksMock: vi.fn(), notifyDesktopMock: vi.fn(), updateTabTitleMock: vi.fn(), updateTabStatusMock: vi.fn(), updateSessionTitleMock: vi.fn(), updateSessionPermissionModeMock: vi.fn(), sessionStoreSnapshot: { sessions: [] as Array<{ id: string title: string createdAt: string modifiedAt: string messageCount: number projectPath: string workDir: string | null workDirExists: boolean }>, }, cliTaskStoreSnapshot: { tasks: [] as Array<{ id: string; subject: string; status: string; activeForm?: string }>, sessionId: null as string | null, }, })) vi.mock('../lib/desktopNotifications', () => ({ notifyDesktop: notifyDesktopMock, })) vi.mock('../api/websocket', () => ({ wsManager: { connect: vi.fn(), disconnect: vi.fn(), onMessage: vi.fn(() => () => {}), clearHandlers: vi.fn(), send: sendMock, }, })) vi.mock('../api/sessions', () => ({ sessionsApi: { getMessages: vi.fn(async () => ({ messages: [] })), getSlashCommands: vi.fn(async () => ({ commands: [] })), }, })) vi.mock('./teamStore', () => ({ useTeamStore: { getState: () => ({ getMemberBySessionId: getMemberBySessionIdMock, sendMessageToMember: sendMessageToMemberMock, handleTeamCreated: handleTeamCreatedMock, handleTeamUpdate: handleTeamUpdateMock, handleTeamDeleted: handleTeamDeletedMock, }), }, })) vi.mock('./tabStore', () => ({ useTabStore: { getState: () => ({ updateTabStatus: updateTabStatusMock, updateTabTitle: updateTabTitleMock, }), }, })) vi.mock('./sessionStore', () => ({ useSessionStore: { getState: () => ({ sessions: sessionStoreSnapshot.sessions, updateSessionTitle: updateSessionTitleMock, updateSessionPermissionMode: updateSessionPermissionModeMock, }), }, })) vi.mock('./cliTaskStore', () => ({ useCLITaskStore: { getState: () => ({ fetchSessionTasks: fetchSessionTasksMock, tasks: cliTaskStoreSnapshot.tasks, sessionId: cliTaskStoreSnapshot.sessionId, clearTasks: clearTasksMock, setTasksFromTodos: setTasksFromTodosMock, markCompletedAndDismissed: markCompletedAndDismissedMock, resetCompletedTasks: resetCompletedTasksMock, refreshTasks: refreshTasksMock, }), }, })) import { sessionsApi } from '../api/sessions' import { useSettingsStore } from './settingsStore' import { mapHistoryMessagesToUiMessages, reconstructAgentNotifications, stripGeneratedImageMetadataLines, type PerSessionState, useChatStore, } from './chatStore' const TEST_SESSION_ID = 'test-session-1' const initialState = useChatStore.getState() function makeSession(overrides: Partial = {}): PerSessionState { return { messages: [], chatState: 'streaming', connectionState: 'connected', historyStatus: 'idle', historyError: null, streamingText: '', streamingToolInput: '', activeToolUseId: null, activeToolName: null, activeThinkingId: null, pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', apiRetry: null, slashCommands: [], agentTaskNotifications: {}, backgroundAgentTasks: {}, elapsedTimer: null, ...overrides, } } describe('stripGeneratedImageMetadataLines', () => { it('removes simple, detailed, and resize metadata lines but keeps the prompt body', () => { const text = [ 'first line of the prompt', 'second line', '[Image source: C:\\Users\\Relakkes\\.claude\\uploads\\sid\\a.png]', '[Image: source: /Users/me/.claude/uploads/sid/b.png, original 1024x768, displayed at 512x384. Multiply coordinates by 2 to map to original image.]', '[Image: original 800x600, displayed at 400x300. Multiply coordinates by 2 to map to original image.]', ].join('\n') expect(stripGeneratedImageMetadataLines(text)).toBe('first line of the prompt\nsecond line') }) it('normalizes CRLF and leaves metadata-free text untouched', () => { expect(stripGeneratedImageMetadataLines('a\r\nb\r\n')).toBe('a\nb') expect(stripGeneratedImageMetadataLines('just a normal prompt')).toBe('just a normal prompt') }) it('returns empty string when the text is only metadata', () => { expect(stripGeneratedImageMetadataLines('[Image source: /tmp/x.png]')).toBe('') }) }) describe('chatStore tool settlement', () => { beforeEach(() => { sendMock.mockReset() updateTabStatusMock.mockReset() notifyDesktopMock.mockReset() localStorage.clear() useSettingsStore.setState({ locale: 'en' }) useChatStore.setState({ ...initialState, sessions: { [TEST_SESSION_ID]: makeSession(), }, }) }) it('marks sibling pending tool calls stopped when a parallel tool result fails and the turn completes', () => { const store = useChatStore.getState() store.handleServerMessage(TEST_SESSION_ID, { type: 'content_start', blockType: 'tool_use', toolName: 'Grep', toolUseId: 'grep-1', }) store.handleServerMessage(TEST_SESSION_ID, { type: 'tool_use_complete', toolName: 'Grep', toolUseId: 'grep-1', input: { pattern: 'needle' }, }) store.handleServerMessage(TEST_SESSION_ID, { type: 'content_start', blockType: 'tool_use', toolName: 'Read', toolUseId: 'read-1', }) store.handleServerMessage(TEST_SESSION_ID, { type: 'tool_use_complete', toolName: 'Read', toolUseId: 'read-1', input: { file_path: '/missing.md' }, }) store.handleServerMessage(TEST_SESSION_ID, { type: 'tool_result', toolUseId: 'read-1', content: 'File does not exist', isError: true, }) store.handleServerMessage(TEST_SESSION_ID, { type: 'message_complete', usage: { input_tokens: 1, output_tokens: 0 }, }) const session = useChatStore.getState().sessions[TEST_SESSION_ID] expect(session).toBeTruthy() if (!session) return const grep = session.messages.find((message) => message.type === 'tool_use' && message.toolUseId === 'grep-1', ) const read = session.messages.find((message) => message.type === 'tool_use' && message.toolUseId === 'read-1', ) expect(read).toMatchObject({ type: 'tool_use', isPending: false }) expect(grep).toMatchObject({ type: 'tool_use', isPending: false, status: 'stopped', }) expect(session.chatState).toBe('idle') }) }) describe('chatStore history mapping', () => { beforeEach(() => { sendMock.mockReset() getMemberBySessionIdMock.mockReset() getMemberBySessionIdMock.mockReturnValue(null) sendMessageToMemberMock.mockReset() fetchSessionTasksMock.mockReset() clearTasksMock.mockReset() setTasksFromTodosMock.mockReset() markCompletedAndDismissedMock.mockReset() resetCompletedTasksMock.mockReset() refreshTasksMock.mockReset() notifyDesktopMock.mockReset() updateTabTitleMock.mockReset() updateTabStatusMock.mockReset() updateSessionTitleMock.mockReset() vi.mocked(sessionsApi.getMessages).mockReset() vi.mocked(sessionsApi.getMessages).mockResolvedValue({ messages: [] }) sessionStoreSnapshot.sessions = [] cliTaskStoreSnapshot.tasks = [] cliTaskStoreSnapshot.sessionId = null useSessionRuntimeStore.setState({ selections: {} }) localStorage.clear() useSettingsStore.setState({ locale: 'en' }) useChatStore.setState({ ...initialState, sessions: {}, }) }) it('does not prewarm an existing transcript when opening it for history review', () => { sessionStoreSnapshot.sessions = [{ id: TEST_SESSION_ID, title: 'Existing transcript', createdAt: '2026-06-20T10:00:00.000Z', modifiedAt: '2026-06-20T10:30:00.000Z', messageCount: 4, projectPath: '/workspace/project', workDir: '/workspace/project', workDirExists: true, }] useChatStore.getState().connectToSession(TEST_SESSION_ID) expect(sendMock).not.toHaveBeenCalledWith(TEST_SESSION_ID, { type: 'prewarm_session' }) }) it('still prewarms empty placeholder sessions so new chats start quickly', () => { sessionStoreSnapshot.sessions = [{ id: TEST_SESSION_ID, title: 'New Session', createdAt: '2026-06-20T10:00:00.000Z', modifiedAt: '2026-06-20T10:00:00.000Z', messageCount: 0, projectPath: '/workspace/project', workDir: '/workspace/project', workDirExists: true, }] useChatStore.getState().connectToSession(TEST_SESSION_ID) expect(sendMock).toHaveBeenCalledWith(TEST_SESSION_ID, { type: 'prewarm_session' }) }) it('preserves thinking blocks when restoring transcript history', () => { const messages: MessageEntry[] = [ { id: 'assistant-1', type: 'assistant', timestamp: '2026-04-06T00:00:00.000Z', model: 'opus', parentToolUseId: 'agent-1', content: [ { type: 'thinking', thinking: 'internal reasoning' }, { type: 'text', text: '目录结构分析' }, { type: 'tool_use', name: 'Read', id: 'tool-1', input: { file_path: 'src/App.tsx' } }, ], }, { id: 'user-1', type: 'user', timestamp: '2026-04-06T00:00:01.000Z', parentToolUseId: 'agent-1', content: [ { type: 'tool_result', tool_use_id: 'tool-1', content: 'ok', is_error: false }, ], }, ] const mapped = mapHistoryMessagesToUiMessages(messages) expect(mapped.map((message) => message.type)).toEqual([ 'thinking', 'assistant_text', 'tool_use', 'tool_result', ]) expect(mapped[2]).toMatchObject({ parentToolUseId: 'agent-1' }) expect(mapped[3]).toMatchObject({ parentToolUseId: 'agent-1' }) }) it('maps AskUserQuestion transcript answers from toolUseResult metadata', () => { const messages: MessageEntry[] = [ { id: 'assistant-ask', type: 'assistant', timestamp: '2026-04-06T00:00:00.000Z', content: [ { type: 'tool_use', name: 'AskUserQuestion', id: 'ask-1', input: { questions: [ { question: 'Pick one?', options: [{ label: 'A' }, { label: 'B' }], }, ], }, }, ], }, { id: 'user-answer', type: 'tool_result', timestamp: '2026-04-06T00:00:01.000Z', 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' }, { label: 'B' }], }, ], answers: { 'Pick one?': 'A' }, }, }, ] const mapped = mapHistoryMessagesToUiMessages(messages) expect(mapped).toHaveLength(2) expect(mapped[1]).toMatchObject({ type: 'tool_result', toolUseId: 'ask-1', content: { answers: { 'Pick one?': 'A' }, }, }) }) it('maps compact boundary and summary history without hiding pre-compact messages', () => { const messages: MessageEntry[] = [ { id: 'old-user', type: 'user', content: 'Build the billing import flow', timestamp: '2026-05-19T09:59:58.000Z', }, { id: 'old-assistant', type: 'assistant', content: 'Implemented the flow.', timestamp: '2026-05-19T09:59:59.000Z', }, { id: 'compact-boundary', type: 'system', content: 'Conversation compacted', timestamp: '2026-05-19T10:00:00.000Z', }, { id: 'compact-summary', type: 'user', content: [ 'This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.', '', 'Kept the billing import implementation details and next verification steps.', '', 'If you need specific details from before compaction (like exact code snippets, error messages, or content you generated), read the full transcript at: /tmp/transcript.jsonl', ].join('\n'), timestamp: '2026-05-19T10:00:01.000Z', }, ] const mapped = mapHistoryMessagesToUiMessages(messages) expect(mapped).toHaveLength(3) expect(mapped).toMatchObject([ { id: 'old-user', type: 'user_text', content: 'Build the billing import flow', }, { id: 'old-assistant', type: 'assistant_text', content: 'Implemented the flow.', }, { type: 'compact_summary', title: 'Context compacted', summary: 'Kept the billing import implementation details and next verification steps.', }, ]) }) it('drops compact local command stdout after mapping compact history', () => { const messages: MessageEntry[] = [ { id: 'compact-summary', type: 'user', content: [ 'This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.', '', 'Kept the billing import implementation details.', ].join('\n'), timestamp: '2026-05-19T10:00:01.000Z', }, { id: 'compact-stdout', type: 'user', content: 'Compacted ', timestamp: '2026-05-19T10:00:02.000Z', }, ] const mapped = mapHistoryMessagesToUiMessages(messages) expect(mapped).toHaveLength(1) expect(mapped[0]).toMatchObject({ type: 'compact_summary', summary: 'Kept the billing import implementation details.', }) }) it('restores saved memory system events from transcript history', () => { const messages: MessageEntry[] = [ { id: 'memory-1', type: 'system', timestamp: '2026-04-06T00:00:00.000Z', content: { subtype: 'memory_saved', writtenPaths: ['/Users/test/.claude/projects/example/memory/preferences.md'], teamCount: 0, }, }, ] const mapped = mapHistoryMessagesToUiMessages(messages) expect(mapped).toMatchObject([ { id: 'memory-1', type: 'memory_event', event: 'saved', files: [ { path: '/Users/test/.claude/projects/example/memory/preferences.md', action: 'saved', }, ], }, ]) }) it('preserves transcript message ids on natural-language history messages', () => { const messages: MessageEntry[] = [ { id: 'transcript-user-1', type: 'user', timestamp: '2026-04-06T00:00:00.000Z', content: '请从这里继续', }, { id: 'transcript-assistant-1', type: 'assistant', timestamp: '2026-04-06T00:00:01.000Z', model: 'opus', content: [ { type: 'text', text: '这里是答复。' }, { type: 'tool_use', name: 'Read', id: 'tool-1', input: { file_path: 'src/App.tsx' } }, ], }, ] const mapped = mapHistoryMessagesToUiMessages(messages) expect(mapped).toMatchObject([ { id: 'transcript-user-1', type: 'user_text', transcriptMessageId: 'transcript-user-1', }, { type: 'assistant_text', transcriptMessageId: 'transcript-assistant-1', }, { type: 'tool_use', }, ]) }) it('does not restore internal slash-command breadcrumbs as user history bubbles', () => { const messages: MessageEntry[] = [ { id: 'agent-command-string', type: 'user', timestamp: '2026-06-15T03:32:13.000Z', content: [ 'agent', '/agent', 'Plan 222', ].join('\n'), }, { id: 'agent-command-array', type: 'user', timestamp: '2026-06-15T03:32:14.000Z', content: [ { type: 'text', text: [ 'agent', '/agent', 'Plan 333', ].join('\n'), }, ], }, { id: 'transcript-user-1', type: 'user', timestamp: '2026-06-15T03:32:15.000Z', content: '继续处理这个问题', }, ] expect(mapHistoryMessagesToUiMessages(messages)).toMatchObject([ { id: 'transcript-user-1', type: 'user_text', content: '继续处理这个问题', }, ]) }) it('restores persisted image user messages as renderable attachments without exposing image metadata text', () => { const messages: MessageEntry[] = [ { id: 'image-user-1', type: 'user', timestamp: '2026-06-04T08:07:15.803Z', content: [ { type: 'text', text: '解释一下这张图片讲了什么东西' }, { type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: 'JPEGBASE64', }, }, { type: 'text', text: '[Image source: /Users/test/.claude/uploads/session-1/pasted-image.jpeg]', }, ], }, ] const mapped = mapHistoryMessagesToUiMessages(messages) expect(mapped).toMatchObject([ { id: 'image-user-1', type: 'user_text', content: '解释一下这张图片讲了什么东西', modelContent: [ '解释一下这张图片讲了什么东西', '[Image source: /Users/test/.claude/uploads/session-1/pasted-image.jpeg]', ].join('\n'), attachments: [{ type: 'image', name: 'pasted-image.jpeg', path: '/Users/test/.claude/uploads/session-1/pasted-image.jpeg', data: 'data:image/jpeg;base64,JPEGBASE64', mimeType: 'image/jpeg', }], }, ]) }) it('restores multiple persisted images with their matching source paths in order', () => { const mapped = mapHistoryMessagesToUiMessages([ { id: 'multi-image-user-1', type: 'user', timestamp: '2026-06-04T08:07:15.803Z', content: [ { type: 'text', text: '对比这两张图' }, { type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: 'FIRSTJPEG', }, }, { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'SECONDPNG', }, }, { type: 'text', text: '[Image source: /Users/test/.claude/uploads/session-1/first-pasted-image.jpeg]', }, { type: 'text', text: '[Image source: /Users/test/.claude/uploads/session-1/second-pasted-image.png]', }, ], }, ]) expect(mapped).toMatchObject([ { id: 'multi-image-user-1', type: 'user_text', content: '对比这两张图', attachments: [ { type: 'image', name: 'first-pasted-image.jpeg', path: '/Users/test/.claude/uploads/session-1/first-pasted-image.jpeg', data: 'data:image/jpeg;base64,FIRSTJPEG', mimeType: 'image/jpeg', }, { type: 'image', name: 'second-pasted-image.png', path: '/Users/test/.claude/uploads/session-1/second-pasted-image.png', data: 'data:image/png;base64,SECONDPNG', mimeType: 'image/png', }, ], }, ]) }) it('keeps image-looking text visible when history has no image block', () => { const mapped = mapHistoryMessagesToUiMessages([ { id: 'plain-text-user-1', type: 'user', timestamp: '2026-06-04T08:07:15.803Z', content: [ { type: 'text', text: '[Image source: /tmp/example.png]' }, ], }, ]) expect(mapped).toMatchObject([ { id: 'plain-text-user-1', type: 'user_text', content: '[Image source: /tmp/example.png]', }, ]) }) it('restores visual selection history as annotated screenshot attachment without exposing model prompt', () => { const modelPrompt = [ '请根据截图中编号 1 的蓝色标注修改本地前端。', '目标元素: