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, 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(), 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, }), }, })) 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 { mapHistoryMessagesToUiMessages, reconstructAgentNotifications, 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', streamingText: '', streamingToolInput: '', activeToolUseId: null, activeToolName: null, activeThinkingId: null, pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, elapsedSeconds: 0, statusVerb: '', slashCommands: [], agentTaskNotifications: {}, elapsedTimer: null, ...overrides, } } 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() sessionStoreSnapshot.sessions = [] cliTaskStoreSnapshot.tasks = [] cliTaskStoreSnapshot.sessionId = null useSessionRuntimeStore.setState({ selections: {} }) localStorage.clear() useChatStore.setState({ ...initialState, sessions: {}, }) }) 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('merges consecutive assistant text blocks when restoring transcript history', () => { const messages: MessageEntry[] = [ { id: 'assistant-merge-1', type: 'assistant', timestamp: '2026-04-06T00:00:00.000Z', model: 'opus', content: [ { type: 'text', text: '第一段:Windows 下的桌面端输出。' }, { type: 'text', text: '\r\n第二段:刷新后也不应该被拆开。' }, ], }, ] const mapped = mapHistoryMessagesToUiMessages(messages) expect(mapped).toMatchObject([ { type: 'assistant_text', content: '第一段:Windows 下的桌面端输出。\r\n第二段:刷新后也不应该被拆开。', }, ]) }) it('skips whitespace-only assistant transcript messages', () => { const messages: MessageEntry[] = [ { id: 'assistant-empty', type: 'assistant', timestamp: '2026-04-06T00:00:00.000Z', model: 'opus', content: '\n\n ', }, { id: 'assistant-real', type: 'assistant', timestamp: '2026-04-06T00:00:01.000Z', model: 'opus', content: '可见回复', }, ] const mapped = mapHistoryMessagesToUiMessages(messages) expect(mapped).toMatchObject([ { id: 'assistant-real', type: 'assistant_text', content: '可见回复', }, ]) }) it('filters task-notification turns and resumes at the next real user message', () => { const messages: MessageEntry[] = [ { id: 'user-real-1', type: 'user', timestamp: '2026-04-06T00:00:00.000Z', content: '创建项目', }, { id: 'assistant-real-1', type: 'assistant', timestamp: '2026-04-06T00:00:01.000Z', content: [{ type: 'text', text: '项目创建好了' }], }, { id: 'task-notification', type: 'user', timestamp: '2026-04-06T00:00:02.000Z', content: '\nbg-1\ntoolu_bg\ncompleted\nBackground command completed\n', }, { id: 'assistant-task-response', type: 'assistant', timestamp: '2026-04-06T00:00:03.000Z', content: [{ type: 'text', text: '旧后台任务通知,无需处理' }], }, { id: 'user-real-2', type: 'user', timestamp: '2026-04-06T00:00:04.000Z', content: '继续真实问题', }, ] const mapped = mapHistoryMessagesToUiMessages(messages) expect(mapped).toMatchObject([ { id: 'user-real-1', type: 'user_text', content: '创建项目', }, { type: 'assistant_text', content: '项目创建好了', }, { id: 'user-real-2', type: 'user_text', content: '继续真实问题', }, ]) expect(JSON.stringify(mapped)).not.toContain('') expect(JSON.stringify(mapped)).not.toContain('旧后台任务通知') }) it('reconstructs task notifications from transcript XML before filtering it from UI', () => { const restored = reconstructAgentNotifications([ { id: 'task-notification', type: 'user', timestamp: '2026-04-06T00:00:00.000Z', content: '\nbg-1\ntoolu_bg\ncompleted\nBackground command & agent done\nC:\\Temp\\bg.output\n', }, ]) expect(restored).toEqual({ toolu_bg: { taskId: 'bg-1', toolUseId: 'toolu_bg', status: 'completed', summary: 'Background command & agent done', outputFile: 'C:\\Temp\\bg.output', }, }) }) it('surfaces teammate prompt content when mapping member transcript history', () => { const messages: MessageEntry[] = [ { id: 'user-1', type: 'user', timestamp: '2026-04-06T00:00:00.000Z', content: 'Review the auth diff and call out risks.', }, ] const mapped = mapHistoryMessagesToUiMessages(messages, { includeTeammateMessages: true, }) expect(mapped).toMatchObject([ { type: 'user_text', content: 'Review the auth diff and call out risks.', }, ]) }) it('preserves source user ids when restoring array-content user prompts', () => { const messages: MessageEntry[] = [ { id: 'user-with-attachment', type: 'user', timestamp: '2026-04-06T00:00:00.000Z', content: [ { type: 'text', text: '请看这个文件' }, { type: 'file', name: 'report.md' }, ], }, ] const mapped = mapHistoryMessagesToUiMessages(messages) expect(mapped).toMatchObject([ { id: 'user-with-attachment', type: 'user_text', content: '请看这个文件', attachments: [{ type: 'file', name: 'report.md' }], }, ]) }) it('restores CLI file mentions as visible attachment chips from transcript history', () => { const messages: MessageEntry[] = [ { id: 'user-with-file-mention', type: 'user', timestamp: '2026-04-06T00:00:00.000Z', content: '@"/private/tmp/example/src/sentinel.ts" 这个常量是什么?', }, ] const mapped = mapHistoryMessagesToUiMessages(messages) expect(mapped).toMatchObject([ { id: 'user-with-file-mention', type: 'user_text', content: '这个常量是什么?', modelContent: '@"/private/tmp/example/src/sentinel.ts" 这个常量是什么?', attachments: [{ type: 'file', name: 'sentinel.ts', path: '/private/tmp/example/src/sentinel.ts', }], }, ]) }) it('keeps workspace reference chips visible while sending CLI attachment paths', () => { useChatStore.setState({ sessions: { [TEST_SESSION_ID]: { messages: [], chatState: 'idle', connectionState: 'connected', streamingText: '', streamingToolInput: '', activeToolUseId: null, activeToolName: null, activeThinkingId: null, pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, elapsedSeconds: 0, statusVerb: '', slashCommands: [], agentTaskNotifications: {}, elapsedTimer: null, }, }, }) useChatStore.getState().sendMessage( TEST_SESSION_ID, 'Referenced workspace context:\n@"src/App.tsx:L4":\nComment: tighten this\n```tsx\nconst value = 1\n```', [{ type: 'file', name: 'App.tsx', path: '/repo/src/App.tsx', lineStart: 4, lineEnd: 4, note: 'tighten this', quote: 'const value = 1', }], { displayContent: '改这里', displayAttachments: [{ type: 'file', name: 'App.tsx', path: 'src/App.tsx', lineStart: 4, lineEnd: 4, note: 'tighten this', quote: 'const value = 1', }], }, ) expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([ { type: 'user_text', content: '改这里', modelContent: '@"/repo/src/App.tsx" Referenced workspace context:\n@"src/App.tsx:L4":\nComment: tighten this\n```tsx\nconst value = 1\n```', attachments: [{ type: 'file', name: 'App.tsx', path: 'src/App.tsx', lineStart: 4, lineEnd: 4, note: 'tighten this', quote: 'const value = 1', }], }, ]) expect(sendMock).toHaveBeenCalledWith( TEST_SESSION_ID, { type: 'user_message', content: 'Referenced workspace context:\n@"src/App.tsx:L4":\nComment: tighten this\n```tsx\nconst value = 1\n```', attachments: [{ type: 'file', name: 'App.tsx', path: '/repo/src/App.tsx', lineStart: 4, lineEnd: 4, note: 'tighten this', quote: 'const value = 1', }], }, ) }) it('stores server-materialized attachment prefixes for rewind matching', () => { useChatStore.setState({ sessions: { [TEST_SESSION_ID]: { messages: [], chatState: 'idle', connectionState: 'connected', streamingText: '', streamingToolInput: '', activeToolUseId: null, activeToolName: null, activeThinkingId: null, pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, elapsedSeconds: 0, statusVerb: '', slashCommands: [], agentTaskNotifications: {}, elapsedTimer: null, }, }, }) useChatStore.getState().sendMessage( TEST_SESSION_ID, '记一下这个文件讲了什么东西。', [{ type: 'file', name: 'conditions.py', path: '/repo/backend/conditions.py' }], { displayContent: '记一下这个文件讲了什么东西。', displayAttachments: [{ type: 'file', name: 'conditions.py', path: 'backend/conditions.py' }], }, ) expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([ { type: 'user_text', content: '记一下这个文件讲了什么东西。', modelContent: '@"/repo/backend/conditions.py" 记一下这个文件讲了什么东西。', attachments: [{ type: 'file', name: 'conditions.py', path: 'backend/conditions.py', }], }, ]) }) it('hydrates TodoWrite history into the currently tracked task store only', async () => { const todos = [{ content: 'Session task', status: 'in_progress' }] vi.mocked(sessionsApi.getMessages).mockResolvedValueOnce({ messages: [ { id: 'assistant-todo', type: 'assistant', timestamp: '2026-04-06T00:00:00.000Z', content: [ { type: 'tool_use', name: 'TodoWrite', id: 'todo-1', input: { todos } }, ], }, ], }) cliTaskStoreSnapshot.sessionId = TEST_SESSION_ID cliTaskStoreSnapshot.tasks = [] useChatStore.setState({ sessions: { [TEST_SESSION_ID]: makeSession({ messages: [] }), }, }) await useChatStore.getState().loadHistory(TEST_SESSION_ID) expect(setTasksFromTodosMock).toHaveBeenCalledWith(todos, TEST_SESSION_ID) }) it('marks history task completion dismissed when the user already continued', async () => { vi.mocked(sessionsApi.getMessages).mockResolvedValueOnce({ messages: [ { id: 'assistant-task', type: 'assistant', timestamp: '2026-04-06T00:00:00.000Z', content: [ { type: 'tool_use', name: 'TaskCreate', id: 'task-1', input: { subject: 'Done' } }, ], }, { id: 'user-next', type: 'user', timestamp: '2026-04-06T00:00:01.000Z', content: '继续下一步', }, ], }) useChatStore.setState({ sessions: { [TEST_SESSION_ID]: makeSession({ messages: [] }), }, }) await useChatStore.getState().loadHistory(TEST_SESSION_ID) expect(setTasksFromTodosMock).toHaveBeenCalledWith([], TEST_SESSION_ID) expect(markCompletedAndDismissedMock).toHaveBeenCalledWith(TEST_SESSION_ID) }) it('reloads history task state for the requested session', async () => { const todos = [{ content: 'Reloaded task', status: 'pending' }] vi.mocked(sessionsApi.getMessages).mockResolvedValueOnce({ messages: [ { id: 'assistant-todo', type: 'assistant', timestamp: '2026-04-06T00:00:00.000Z', content: [ { type: 'tool_use', name: 'TodoWrite', id: 'todo-1', input: { todos } }, ], }, ], }) useChatStore.setState({ sessions: { [TEST_SESSION_ID]: makeSession({ messages: [{ id: 'old', type: 'assistant_text', content: 'old', timestamp: 1 }] }), }, }) await useChatStore.getState().reloadHistory(TEST_SESSION_ID) expect(setTasksFromTodosMock).toHaveBeenCalledWith(todos, TEST_SESSION_ID) }) it('clears reloaded task state after completed history is followed by a user turn', async () => { vi.mocked(sessionsApi.getMessages).mockResolvedValueOnce({ messages: [ { id: 'assistant-task', type: 'assistant', timestamp: '2026-04-06T00:00:00.000Z', content: [ { type: 'tool_use', name: 'TaskUpdate', id: 'task-1', input: { subject: 'Done' } }, ], }, { id: 'user-next', type: 'user', timestamp: '2026-04-06T00:00:01.000Z', content: '新的问题', }, ], }) useChatStore.setState({ sessions: { [TEST_SESSION_ID]: makeSession({ messages: [{ id: 'old', type: 'assistant_text', content: 'old', timestamp: 1 }] }), }, }) await useChatStore.getState().reloadHistory(TEST_SESSION_ID) expect(setTasksFromTodosMock).toHaveBeenCalledWith([], TEST_SESSION_ID) expect(markCompletedAndDismissedMock).toHaveBeenCalledWith(TEST_SESSION_ID) }) it('keeps parent tool linkage for live tool events', () => { // Initialize the session first useChatStore.setState({ sessions: { [TEST_SESSION_ID]: { messages: [], chatState: 'idle', connectionState: 'connected', streamingText: '', streamingToolInput: '', activeToolUseId: null, activeToolName: null, activeThinkingId: null, pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, elapsedSeconds: 0, statusVerb: '', slashCommands: [{ name: 'old-command', description: 'Old command' }], agentTaskNotifications: {}, elapsedTimer: null, }, }, }) useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { type: 'tool_use_complete', toolName: 'Read', toolUseId: 'tool-1', input: { file_path: 'src/App.tsx' }, parentToolUseId: 'agent-1', }) useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { type: 'tool_result', toolUseId: 'tool-1', content: 'ok', isError: false, parentToolUseId: 'agent-1', }) expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([ { type: 'tool_use', toolUseId: 'tool-1', parentToolUseId: 'agent-1', }, { type: 'tool_result', toolUseId: 'tool-1', parentToolUseId: 'agent-1', }, ]) }) it('syncs live TodoWrite tool input into the task store for that session', () => { const todos = [{ content: 'Live todo', status: 'in_progress' }] useChatStore.setState({ sessions: { [TEST_SESSION_ID]: makeSession({ chatState: 'tool_executing' }), }, }) useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { type: 'tool_use_complete', toolName: 'TodoWrite', toolUseId: 'todo-live', input: { todos }, }) expect(setTasksFromTodosMock).toHaveBeenCalledWith(todos, TEST_SESSION_ID) }) it('replays saved runtime selection when reconnecting a session', () => { useSessionRuntimeStore.getState().setSelection(TEST_SESSION_ID, { providerId: 'provider-1', modelId: 'kimi-k2.6', }) useChatStore.getState().connectToSession(TEST_SESSION_ID) expect(sendMock).toHaveBeenCalledWith(TEST_SESSION_ID, { type: 'set_runtime_config', providerId: 'provider-1', modelId: 'kimi-k2.6', }) expect(sendMock.mock.calls.slice(0, 2)).toEqual([ [ TEST_SESSION_ID, { type: 'set_runtime_config', providerId: 'provider-1', modelId: 'kimi-k2.6', }, ], [TEST_SESSION_ID, { type: 'prewarm_session' }], ]) }) it('prewarms regular desktop sessions when connecting', () => { useChatStore.getState().connectToSession(TEST_SESSION_ID) expect(sendMock).toHaveBeenCalledWith(TEST_SESSION_ID, { type: 'prewarm_session', }) }) it('does not prewarm team member sessions', () => { getMemberBySessionIdMock.mockReturnValue({ agentId: 'reviewer@test-team', role: 'reviewer', status: 'running', }) useChatStore.getState().connectToSession(TEST_SESSION_ID) expect(sendMock).not.toHaveBeenCalledWith(TEST_SESSION_ID, { type: 'prewarm_session', }) }) it('does not prewarm synthetic app tabs', () => { useChatStore.getState().connectToSession('__settings__') expect(sendMock).not.toHaveBeenCalledWith('__settings__', { type: 'prewarm_session', }) }) it('sends explicit runtime overrides over websocket', () => { useChatStore.getState().setSessionRuntime(TEST_SESSION_ID, { providerId: null, modelId: 'claude-opus-4-7', }) expect(sendMock).toHaveBeenCalledWith(TEST_SESSION_ID, { type: 'set_runtime_config', providerId: null, modelId: 'claude-opus-4-7', }) }) it('keeps AskUserQuestion permission requests out of the message list while tracking the pending request', () => { useChatStore.setState({ sessions: { [TEST_SESSION_ID]: { messages: [ { id: 'ask-1', type: 'tool_use', toolName: 'AskUserQuestion', toolUseId: 'tool-ask-1', input: { questions: [ { question: 'Should we persist data?', options: [{ label: 'No' }, { label: 'Yes' }], }, ], }, timestamp: 1, }, ], chatState: 'idle', connectionState: 'connected', streamingText: '', streamingToolInput: '', activeToolUseId: null, activeToolName: null, activeThinkingId: null, pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, elapsedSeconds: 0, statusVerb: '', slashCommands: [], agentTaskNotifications: {}, elapsedTimer: null, }, }, }) useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { type: 'permission_request', requestId: 'perm-ask-1', toolName: 'AskUserQuestion', toolUseId: 'tool-ask-1', input: { questions: [ { question: 'Should we persist data?', options: [{ label: 'No' }, { label: 'Yes' }], }, ], }, }) const session = useChatStore.getState().sessions[TEST_SESSION_ID] expect(session?.pendingPermission).toMatchObject({ requestId: 'perm-ask-1', toolName: 'AskUserQuestion', toolUseId: 'tool-ask-1', }) expect(session?.messages).toHaveLength(1) expect(session?.messages[0]).toMatchObject({ type: 'tool_use', toolUseId: 'tool-ask-1', }) expect(notifyDesktopMock).toHaveBeenCalledWith({ dedupeKey: 'permission:perm-ask-1', cooldownScope: 'permission-prompt', requestAttention: true, title: 'Claude Code Haha 需要你的确认', body: 'AskUserQuestion 请求执行,正在等待允许。', target: { type: 'session', sessionId: TEST_SESSION_ID }, }) }) it('sends permission mode updates to the active session only', () => { useChatStore.getState().setSessionPermissionMode('nonexistent-session', 'acceptEdits') expect(sendMock).not.toHaveBeenCalled() useChatStore.setState({ sessions: { 'session-1': { messages: [], chatState: 'idle', connectionState: 'connected', streamingText: '', streamingToolInput: '', activeToolUseId: null, activeToolName: null, activeThinkingId: null, pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, elapsedSeconds: 0, statusVerb: '', slashCommands: [], agentTaskNotifications: {}, elapsedTimer: null, }, }, }) useChatStore.getState().setSessionPermissionMode('session-1', 'acceptEdits') expect(sendMock).toHaveBeenCalledWith('session-1', { type: 'set_permission_mode', mode: 'acceptEdits', }) }) it('stores terminal task notifications for agent tool cards', () => { useChatStore.setState({ sessions: { [TEST_SESSION_ID]: { messages: [], chatState: 'idle', connectionState: 'connected', streamingText: '', streamingToolInput: '', activeToolUseId: null, activeToolName: null, activeThinkingId: null, pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, elapsedSeconds: 0, statusVerb: '', slashCommands: [], agentTaskNotifications: {}, elapsedTimer: null, }, }, }) useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { type: 'system_notification', subtype: 'task_notification', data: { task_id: 'agent-task-1', tool_use_id: 'agent-tool-1', status: 'completed', summary: 'Agent "修复异常处理" completed', output_file: '/tmp/agent-output.txt', }, }) expect( useChatStore.getState().sessions[TEST_SESSION_ID]?.agentTaskNotifications[ 'agent-tool-1' ], ).toMatchObject({ taskId: 'agent-task-1', toolUseId: 'agent-tool-1', status: 'completed', summary: 'Agent "修复异常处理" completed', outputFile: '/tmp/agent-output.txt', }) }) it('clears local desktop chat state when the server confirms /clear', () => { vi.useFakeTimers() useChatStore.setState({ sessions: { [TEST_SESSION_ID]: { messages: [ { id: 'u1', type: 'user_text', content: '/clear', timestamp: Date.now() }, { id: 'a1', type: 'assistant_text', content: 'old context', timestamp: Date.now() }, ], chatState: 'thinking', connectionState: 'connected', streamingText: 'pending', streamingToolInput: 'tool', activeToolUseId: 'tool-1', activeToolName: 'Read', activeThinkingId: 'thinking-1', pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 12, output_tokens: 34 }, elapsedSeconds: 5, statusVerb: 'Thinking', slashCommands: [], agentTaskNotifications: {}, elapsedTimer: null, }, }, }) useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { type: 'content_delta', text: 'stale throttled delta', }) useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { type: 'system_notification', subtype: 'session_cleared', message: 'Conversation cleared', }) const session = useChatStore.getState().sessions[TEST_SESSION_ID] expect(session?.messages).toEqual([]) expect(session?.streamingText).toBe('') expect(session?.chatState).toBe('idle') expect(session?.tokenUsage).toEqual({ input_tokens: 0, output_tokens: 0 }) expect(session?.slashCommands).toEqual([]) expect(clearTasksMock).toHaveBeenCalledWith(TEST_SESSION_ID) vi.advanceTimersByTime(60) expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.streamingText).toBe('') vi.useRealTimers() }) it('clears local message state for only the requested session', () => { useChatStore.setState({ sessions: { 'session-a': makeSession({ messages: [{ id: 'a1', type: 'assistant_text', content: 'A old', timestamp: 1 }], streamingText: 'A pending', }), 'session-b': makeSession({ messages: [{ id: 'b1', type: 'assistant_text', content: 'B old', timestamp: 1 }], streamingText: 'B pending', }), }, }) useChatStore.getState().clearMessages('session-a') expect(useChatStore.getState().sessions['session-a']?.messages).toEqual([]) expect(useChatStore.getState().sessions['session-a']?.streamingText).toBe('') expect(useChatStore.getState().sessions['session-b']?.messages).toMatchObject([ { content: 'B old' }, ]) }) it('renders compact boundary notifications as system messages', () => { useChatStore.setState({ sessions: { [TEST_SESSION_ID]: { messages: [], chatState: 'idle', connectionState: 'connected', streamingText: '', streamingToolInput: '', activeToolUseId: null, activeToolName: null, activeThinkingId: null, pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, elapsedSeconds: 0, statusVerb: '', slashCommands: [], agentTaskNotifications: {}, elapsedTimer: null, }, }, }) useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { type: 'system_notification', subtype: 'compact_boundary', message: 'Context compacted', }) expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([ { type: 'system', content: 'Context compacted' }, ]) }) it('flushes the previous assistant draft before starting a new user turn', () => { useChatStore.setState({ sessions: { [TEST_SESSION_ID]: { messages: [], chatState: 'streaming', connectionState: 'connected', streamingText: '上一次分析结果 **还在流式区域**', streamingToolInput: '', activeToolUseId: null, activeToolName: null, activeThinkingId: null, pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, elapsedSeconds: 0, statusVerb: '', slashCommands: [], agentTaskNotifications: {}, elapsedTimer: null, }, }, }) useChatStore.getState().sendMessage(TEST_SESSION_ID, '你是什么模型?') expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([ { type: 'assistant_text', content: '上一次分析结果 **还在流式区域**', }, { type: 'user_text', content: '你是什么模型?', }, ]) expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.streamingText).toBe('') }) it('resets completed CLI tasks before continuing the next user turn', () => { cliTaskStoreSnapshot.sessionId = TEST_SESSION_ID cliTaskStoreSnapshot.tasks = [ { id: '1', subject: 'Existing completed task', status: 'completed' }, { id: '2', subject: 'Another completed task', status: 'completed' }, ] useChatStore.setState({ sessions: { [TEST_SESSION_ID]: { messages: [], chatState: 'idle', connectionState: 'connected', streamingText: '', streamingToolInput: '', activeToolUseId: null, activeToolName: null, activeThinkingId: null, pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, elapsedSeconds: 0, statusVerb: '', slashCommands: [], agentTaskNotifications: {}, elapsedTimer: null, }, }, }) useChatStore.getState().sendMessage(TEST_SESSION_ID, '继续下一轮') expect(resetCompletedTasksMock).toHaveBeenCalledWith(TEST_SESSION_ID) expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([ { type: 'task_summary', tasks: [ { id: '1', subject: 'Existing completed task', status: 'completed' }, { id: '2', subject: 'Another completed task', status: 'completed' }, ], }, { type: 'user_text', content: '继续下一轮', }, ]) }) it('does not attach completed tasks from another tracked session to a new user turn', () => { cliTaskStoreSnapshot.sessionId = 'session-b' cliTaskStoreSnapshot.tasks = [ { id: '1', subject: 'Session B completed task', status: 'completed' }, ] useChatStore.setState({ sessions: { 'session-a': makeSession({ chatState: 'idle' }), 'session-b': makeSession({ chatState: 'idle' }), }, }) useChatStore.getState().sendMessage('session-a', '继续 A 会话') expect(resetCompletedTasksMock).not.toHaveBeenCalled() expect(useChatStore.getState().sessions['session-a']?.messages).toMatchObject([ { type: 'user_text', content: '继续 A 会话', }, ]) }) it('tracks task tool results independently per session even when tool IDs collide', () => { useChatStore.setState({ sessions: { 'session-a': makeSession({ activeToolUseId: 'tool-same', activeToolName: 'TaskCreate', }), 'session-b': makeSession({ activeToolUseId: 'tool-same', activeToolName: 'TaskCreate', }), }, }) for (const sessionId of ['session-a', 'session-b']) { useChatStore.getState().handleServerMessage(sessionId, { type: 'tool_use_complete', toolName: 'TaskCreate', toolUseId: 'tool-same', input: { subject: sessionId }, }) } useChatStore.getState().handleServerMessage('session-a', { type: 'tool_result', toolUseId: 'tool-same', content: 'created A', isError: false, }) useChatStore.getState().handleServerMessage('session-b', { type: 'tool_result', toolUseId: 'tool-same', content: 'created B', isError: false, }) expect(refreshTasksMock).toHaveBeenCalledTimes(2) expect(refreshTasksMock).toHaveBeenNthCalledWith(1, 'session-a') expect(refreshTasksMock).toHaveBeenNthCalledWith(2, 'session-b') }) it('tracks Computer Use approval requests separately from generic tool permissions', () => { useChatStore.setState({ sessions: { [TEST_SESSION_ID]: { messages: [], chatState: 'idle', connectionState: 'connected', streamingText: '', streamingToolInput: '', activeToolUseId: null, activeToolName: null, activeThinkingId: null, pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, elapsedSeconds: 0, statusVerb: '', slashCommands: [], agentTaskNotifications: {}, elapsedTimer: null, }, }, }) useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { type: 'computer_use_permission_request', requestId: 'cu-1', request: { requestId: 'cu-1', reason: 'Open Finder and inspect a file', apps: [ { requestedName: 'Finder', resolved: { bundleId: 'com.apple.finder', displayName: 'Finder', }, isSentinel: false, alreadyGranted: false, proposedTier: 'full', }, ], requestedFlags: { clipboardRead: true }, screenshotFiltering: 'native', }, }) expect( useChatStore.getState().sessions[TEST_SESSION_ID]?.pendingComputerUsePermission, ).toMatchObject({ requestId: 'cu-1', request: { reason: 'Open Finder and inspect a file', }, }) expect( useChatStore.getState().sessions[TEST_SESSION_ID]?.chatState, ).toBe('permission_pending') expect(notifyDesktopMock).toHaveBeenCalledWith({ dedupeKey: 'computer-use-permission:cu-1', cooldownScope: 'permission-prompt', requestAttention: true, title: 'Claude Code Haha 需要你的确认', body: 'Open Finder and inspect a file', target: { type: 'session', sessionId: TEST_SESSION_ID }, }) }) it('keeps delayed text blocks from one streamed assistant turn in a single message', () => { vi.useFakeTimers() useChatStore.setState({ sessions: { [TEST_SESSION_ID]: { messages: [], chatState: 'idle', connectionState: 'connected', streamingText: '', streamingToolInput: '', activeToolUseId: null, activeToolName: null, activeThinkingId: null, pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, elapsedSeconds: 0, statusVerb: '', slashCommands: [], agentTaskNotifications: {}, elapsedTimer: null, }, }, }) useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { type: 'content_start', blockType: 'text', }) useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { type: 'content_delta', text: '第一段:先到达。', }) vi.advanceTimersByTime(60) useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { type: 'content_start', blockType: 'text', }) useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { type: 'content_delta', text: '\r\n第二段:稍后到达,但仍属于同一轮回复。', }) vi.advanceTimersByTime(60) useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { type: 'message_complete', usage: { input_tokens: 1, output_tokens: 2 }, }) expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([ { type: 'assistant_text', content: '第一段:先到达。\r\n第二段:稍后到达,但仍属于同一轮回复。', }, ]) vi.runOnlyPendingTimers() vi.useRealTimers() }) it('keeps throttled streaming deltas isolated per session', () => { vi.useFakeTimers() useChatStore.setState({ sessions: { 'session-a': makeSession(), 'session-b': makeSession(), }, }) useChatStore.getState().handleServerMessage('session-a', { type: 'content_start', blockType: 'text', }) useChatStore.getState().handleServerMessage('session-a', { type: 'content_delta', text: 'A-only response', }) useChatStore.getState().handleServerMessage('session-b', { type: 'content_start', blockType: 'text', }) useChatStore.getState().handleServerMessage('session-b', { type: 'content_delta', text: 'B-only response', }) useChatStore.getState().handleServerMessage('session-a', { type: 'message_complete', usage: { input_tokens: 1, output_tokens: 1 }, }) useChatStore.getState().handleServerMessage('session-b', { type: 'message_complete', usage: { input_tokens: 1, output_tokens: 1 }, }) expect(useChatStore.getState().sessions['session-a']?.messages).toMatchObject([ { type: 'assistant_text', content: 'A-only response' }, ]) expect(useChatStore.getState().sessions['session-b']?.messages).toMatchObject([ { type: 'assistant_text', content: 'B-only response' }, ]) vi.runOnlyPendingTimers() vi.useRealTimers() }) it('flushes pending text before appending a thinking block', () => { vi.useFakeTimers() useChatStore.setState({ sessions: { [TEST_SESSION_ID]: makeSession({ chatState: 'streaming' }), }, }) useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { type: 'content_delta', text: 'visible answer before thinking', }) useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { type: 'thinking', text: 'internal note', }) expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([ { type: 'assistant_text', content: 'visible answer before thinking' }, { type: 'thinking', content: 'internal note' }, ]) vi.runOnlyPendingTimers() vi.useRealTimers() }) it('flushes pending text before appending an error message', () => { vi.useFakeTimers() useChatStore.setState({ sessions: { [TEST_SESSION_ID]: makeSession({ chatState: 'streaming' }), }, }) useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { type: 'content_delta', text: 'partial answer before error', }) useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { type: 'error', message: 'provider failed', code: 'provider_error', }) expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([ { type: 'assistant_text', content: 'partial answer before error' }, { type: 'error', message: 'provider failed' }, ]) vi.runOnlyPendingTimers() vi.useRealTimers() }) it('flushes throttled deltas only for the stopped session', () => { vi.useFakeTimers() useChatStore.setState({ sessions: { 'session-a': makeSession(), 'session-b': makeSession(), }, }) useChatStore.getState().handleServerMessage('session-a', { type: 'content_delta', text: 'A-only response', }) useChatStore.getState().handleServerMessage('session-b', { type: 'content_delta', text: 'B-only response', }) useChatStore.getState().stopGeneration('session-a') expect(useChatStore.getState().sessions['session-a']?.streamingText).toBe('A-only response') expect(useChatStore.getState().sessions['session-b']?.streamingText).toBe('') useChatStore.getState().handleServerMessage('session-b', { type: 'message_complete', usage: { input_tokens: 1, output_tokens: 1 }, }) expect(useChatStore.getState().sessions['session-b']?.messages).toMatchObject([ { type: 'assistant_text', content: 'B-only response' }, ]) vi.runOnlyPendingTimers() vi.useRealTimers() }) it('does not flush one session throttled delta into another disconnected session', () => { vi.useFakeTimers() useChatStore.setState({ sessions: { 'session-a': makeSession(), 'session-b': makeSession(), }, }) useChatStore.getState().handleServerMessage('session-a', { type: 'content_delta', text: 'A-only response', }) useChatStore.getState().handleServerMessage('session-b', { type: 'content_delta', text: 'B-only response', }) useChatStore.getState().disconnectSession('session-a') expect(useChatStore.getState().sessions['session-a']).toBeUndefined() expect(useChatStore.getState().sessions['session-b']?.streamingText).toBe('') useChatStore.getState().handleServerMessage('session-b', { type: 'message_complete', usage: { input_tokens: 1, output_tokens: 1 }, }) expect(useChatStore.getState().sessions['session-b']?.messages).toMatchObject([ { type: 'assistant_text', content: 'B-only response' }, ]) vi.runOnlyPendingTimers() vi.useRealTimers() }) it('ignores late throttled deltas after a session has disconnected', () => { vi.useFakeTimers() useChatStore.setState({ sessions: { 'session-a': makeSession(), }, }) useChatStore.getState().handleServerMessage('session-a', { type: 'content_delta', text: 'before disconnect', }) useChatStore.getState().disconnectSession('session-a') useChatStore.getState().handleServerMessage('session-a', { type: 'content_delta', text: 'late stale delta', }) useChatStore.setState({ sessions: { 'session-a': makeSession({ chatState: 'idle' }), }, }) useChatStore.getState().sendMessage('session-a', 'fresh turn') expect(useChatStore.getState().sessions['session-a']?.messages).toMatchObject([ { type: 'user_text', content: 'fresh turn' }, ]) vi.runOnlyPendingTimers() expect(useChatStore.getState().sessions['session-a']?.streamingText).toBe('') vi.useRealTimers() }) it('does not split one streamed markdown reply when task progress arrives mid-stream', () => { vi.useFakeTimers() useChatStore.setState({ sessions: { [TEST_SESSION_ID]: { messages: [], chatState: 'idle', connectionState: 'connected', streamingText: '', streamingToolInput: '', activeToolUseId: null, activeToolName: null, activeThinkingId: null, pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, elapsedSeconds: 0, statusVerb: '', slashCommands: [], agentTaskNotifications: {}, elapsedTimer: null, }, }, }) useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { type: 'content_start', blockType: 'text', }) useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { type: 'content_delta', text: '1. **`core/audio/waveform.py:19-31`** — 同步阻塞 I/O。', }) vi.advanceTimersByTime(60) useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { type: 'status', state: 'tool_executing', verb: 'Task in progress', }) useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { type: 'content_delta', text: ' 建议直接用 `subprocess.PIPE` 流式处理。', }) vi.advanceTimersByTime(60) useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { type: 'message_complete', usage: { input_tokens: 1, output_tokens: 2 }, }) expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([ { type: 'assistant_text', content: '1. **`core/audio/waveform.py:19-31`** — 同步阻塞 I/O。 建议直接用 `subprocess.PIPE` 流式处理。', }, ]) vi.runOnlyPendingTimers() vi.useRealTimers() }) it('sends a desktop notification when the agent finishes a markdown reply', () => { vi.useFakeTimers() useChatStore.setState({ sessions: { [TEST_SESSION_ID]: { messages: [ { id: 'user-1', type: 'user_text', content: '总结一下', timestamp: Date.now() }, ], chatState: 'streaming', connectionState: 'connected', streamingText: '', streamingToolInput: '', activeToolUseId: null, activeToolName: null, activeThinkingId: null, pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, elapsedSeconds: 0, statusVerb: '', slashCommands: [], agentTaskNotifications: {}, elapsedTimer: null, }, }, }) useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { type: 'content_start', blockType: 'text', }) useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { type: 'content_delta', text: '## 结果\n\n- **修复完成**\n- `bun test` 已通过', }) vi.advanceTimersByTime(60) useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { type: 'message_complete', usage: { input_tokens: 1, output_tokens: 2 }, }) expect(notifyDesktopMock).toHaveBeenCalledWith(expect.objectContaining({ cooldownScope: 'agent-completion', title: 'Claude Code Haha 已完成回复', body: '结果 修复完成 bun test 已通过', target: { type: 'session', sessionId: TEST_SESSION_ID }, })) expect(notifyDesktopMock.mock.calls[0]?.[0].dedupeKey).toMatch( /^agent-completion:test-session-1:msg-/, ) vi.runOnlyPendingTimers() vi.useRealTimers() }) it('does not notify when completion has no assistant text', () => { useChatStore.setState({ sessions: { [TEST_SESSION_ID]: { messages: [], chatState: 'thinking', connectionState: 'connected', streamingText: '', streamingToolInput: '', activeToolUseId: null, activeToolName: null, activeThinkingId: null, pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, elapsedSeconds: 0, statusVerb: '', slashCommands: [], agentTaskNotifications: {}, elapsedTimer: null, }, }, }) useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { type: 'message_complete', usage: { input_tokens: 1, output_tokens: 0 }, }) expect(notifyDesktopMock).not.toHaveBeenCalled() }) it('does not notify when a completion arrives after the session is already idle', () => { useChatStore.setState({ sessions: { [TEST_SESSION_ID]: { messages: [], chatState: 'idle', connectionState: 'connected', streamingText: '用户已停止后的残余文本', streamingToolInput: '', activeToolUseId: null, activeToolName: null, activeThinkingId: null, pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, elapsedSeconds: 0, statusVerb: '', slashCommands: [], agentTaskNotifications: {}, elapsedTimer: null, }, }, }) useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { type: 'message_complete', usage: { input_tokens: 1, output_tokens: 1 }, }) expect(notifyDesktopMock).not.toHaveBeenCalled() }) it('sends Computer Use approval payloads back over websocket', () => { useChatStore.setState({ sessions: { [TEST_SESSION_ID]: { messages: [], chatState: 'permission_pending', connectionState: 'connected', streamingText: '', streamingToolInput: '', activeToolUseId: null, activeToolName: null, activeThinkingId: null, pendingPermission: null, pendingComputerUsePermission: { requestId: 'cu-1', request: { requestId: 'cu-1', reason: 'Open Finder', apps: [], requestedFlags: {}, screenshotFiltering: 'native', }, }, tokenUsage: { input_tokens: 0, output_tokens: 0 }, elapsedSeconds: 0, statusVerb: '', slashCommands: [], agentTaskNotifications: {}, elapsedTimer: null, }, }, }) useChatStore.getState().respondToComputerUsePermission(TEST_SESSION_ID, 'cu-1', { granted: [], denied: [], flags: { clipboardRead: true, clipboardWrite: false, systemKeyCombos: false, }, userConsented: true, }) expect(sendMock).toHaveBeenCalledWith(TEST_SESSION_ID, { type: 'computer_use_permission_response', requestId: 'cu-1', response: { granted: [], denied: [], flags: { clipboardRead: true, clipboardWrite: false, systemKeyCombos: false, }, userConsented: true, }, }) expect( useChatStore.getState().sessions[TEST_SESSION_ID]?.pendingComputerUsePermission, ).toBeNull() expect( useChatStore.getState().sessions[TEST_SESSION_ID]?.chatState, ).toBe('tool_executing') }) it('routes member-session messages through team mailbox delivery instead of websocket', async () => { const memberSessionId = 'team-member:security-reviewer@test-team' getMemberBySessionIdMock.mockReturnValue({ agentId: 'security-reviewer@test-team', role: 'security-reviewer', status: 'running', }) useChatStore.setState({ sessions: { [memberSessionId]: { messages: [], chatState: 'idle', connectionState: 'connected', streamingText: '', streamingToolInput: '', activeToolUseId: null, activeToolName: null, activeThinkingId: null, pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, elapsedSeconds: 0, statusVerb: '', slashCommands: [], agentTaskNotifications: {}, elapsedTimer: null, }, }, }) useChatStore.getState().sendMessage(memberSessionId, 'Check the latest regression') await Promise.resolve() expect(sendMessageToMemberMock).toHaveBeenCalledWith( memberSessionId, 'Check the latest regression', ) expect(sendMock).not.toHaveBeenCalled() const sessionMessages = useChatStore.getState().sessions[memberSessionId]?.messages ?? [] expect(sessionMessages[sessionMessages.length - 1]).toMatchObject({ type: 'user_text', content: 'Check the latest regression', pending: true, }) }) it('refreshes CLI tasks when switching to an already-connected session', () => { useChatStore.setState({ sessions: { [TEST_SESSION_ID]: { messages: [], chatState: 'idle', connectionState: 'connected', streamingText: '', streamingToolInput: '', activeToolUseId: null, activeToolName: null, activeThinkingId: null, pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, elapsedSeconds: 0, statusVerb: '', slashCommands: [], agentTaskNotifications: {}, elapsedTimer: null, }, }, }) useChatStore.getState().connectToSession(TEST_SESSION_ID) expect(fetchSessionTasksMock).toHaveBeenCalledWith(TEST_SESSION_ID) }) it('optimistically titles a new placeholder session from the first user message', () => { sessionStoreSnapshot.sessions = [{ id: TEST_SESSION_ID, title: 'New Session', createdAt: '2026-05-07T00:00:00.000Z', modifiedAt: '2026-05-07T00:00:00.000Z', messageCount: 0, projectPath: '', workDir: '/workspace/project', workDirExists: true, }] useChatStore.getState().sendMessage(TEST_SESSION_ID, '开始优化UI') expect(updateSessionTitleMock).toHaveBeenCalledWith(TEST_SESSION_ID, '开始优化UI') expect(updateTabTitleMock).toHaveBeenCalledWith(TEST_SESSION_ID, '开始优化UI') expect(sendMock).toHaveBeenCalledWith(TEST_SESSION_ID, { type: 'user_message', content: '开始优化UI', attachments: undefined, }) }) })