import { beforeEach, describe, expect, it, vi } from 'vitest'; const chatMock = vi.fn(); const onAgentStreamMock = vi.fn((_callback?: (delta: any) => void) => () => {}); let agentStreamHandler: ((delta: any) => void) | null = null; vi.mock('../../desktop/src/lib/gateway-client', () => ({ getGatewayClient: () => ({ chat: chatMock, onAgentStream: (callback: (delta: any) => void) => { agentStreamHandler = callback; return onAgentStreamMock(); }, }), })); // Re-export AgentStreamDelta type for test usage type AgentStreamDelta = { stream: 'assistant' | 'tool' | 'lifecycle' | 'hand' | 'workflow'; delta?: string; content?: string; tool?: string; toolInput?: string; toolOutput?: string; phase?: 'start' | 'end' | 'error'; runId?: string; error?: string; handName?: string; handStatus?: string; handResult?: unknown; workflowId?: string; workflowStep?: string; workflowStatus?: string; workflowResult?: unknown; }; describe('chatStore sendMessage', () => { beforeEach(() => { chatMock.mockReset(); onAgentStreamMock.mockClear(); agentStreamHandler = null; vi.resetModules(); }); it('sends the first message with a generated sessionKey and persists it after success', async () => { chatMock.mockResolvedValue({ runId: 'run_1' }); const { useChatStore } = await import('../../desktop/src/store/chatStore'); useChatStore.setState({ messages: [], conversations: [], currentConversationId: null, agents: [], currentAgent: null, isStreaming: false, currentModel: 'glm-5', sessionKey: null, }); await useChatStore.getState().sendMessage('hello'); expect(chatMock).toHaveBeenCalledTimes(1); const [, options] = chatMock.mock.calls[0]; expect(options).toMatchObject({ sessionKey: expect.stringMatching(/^session_/), }); expect(options.agentId).toBeUndefined(); expect(useChatStore.getState().sessionKey).toBe(options.sessionKey); }); it('does not persist a generated sessionKey when the request fails', async () => { chatMock.mockRejectedValue(new Error('boom')); const { useChatStore } = await import('../../desktop/src/store/chatStore'); useChatStore.setState({ messages: [], conversations: [], currentConversationId: null, agents: [], currentAgent: null, isStreaming: false, currentModel: 'glm-5', sessionKey: null, }); await useChatStore.getState().sendMessage('hello'); expect(chatMock).toHaveBeenCalledTimes(1); expect(chatMock.mock.calls[0][1].sessionKey).toMatch(/^session_/); expect(useChatStore.getState().sessionKey).toBeNull(); }); it('does not send local clone ids directly as gateway agent ids', async () => { chatMock.mockResolvedValue({ runId: 'run_clone' }); const { useChatStore } = await import('../../desktop/src/store/chatStore'); useChatStore.setState({ messages: [], conversations: [], currentConversationId: null, agents: [], currentAgent: { id: 'clone_alpha', name: 'Alpha', icon: 'A', color: 'bg-orange-500', lastMessage: '', time: '', }, isStreaming: false, currentModel: 'glm-5', sessionKey: null, }); await useChatStore.getState().sendMessage('hello'); expect(chatMock).toHaveBeenCalledTimes(1); expect(chatMock.mock.calls[0][1]).toMatchObject({ sessionKey: expect.stringMatching(/^session_/), }); expect(chatMock.mock.calls[0][1].agentId).toBeUndefined(); }); it('does not send the placeholder default agent id to the gateway', async () => { chatMock.mockResolvedValue({ runId: 'run_default' }); const { useChatStore } = await import('../../desktop/src/store/chatStore'); useChatStore.setState({ messages: [], conversations: [], currentConversationId: null, agents: [], currentAgent: { id: '1', name: 'ZCLAW', icon: '🦞', color: 'bg-gradient-to-br from-orange-500 to-red-500', lastMessage: '发送消息开始对话', time: '', }, isStreaming: false, currentModel: 'glm-5', sessionKey: null, }); await useChatStore.getState().sendMessage('hello'); expect(chatMock).toHaveBeenCalledTimes(1); expect(chatMock.mock.calls[0][1].agentId).toBeUndefined(); }); }); describe('chatStore agent/session isolation', () => { beforeEach(() => { chatMock.mockReset(); onAgentStreamMock.mockClear(); agentStreamHandler = null; vi.resetModules(); }); it('saves the active conversation and clears session state when switching to a different agent', async () => { const { useChatStore } = await import('../../desktop/src/store/chatStore'); useChatStore.setState({ messages: [ { id: 'user_1', role: 'user', content: 'hello', timestamp: new Date('2026-03-13T00:00:00.000Z'), }, ], conversations: [], currentConversationId: null, agents: [], currentAgent: { id: 'clone_alpha', name: 'Alpha', icon: 'A', color: 'bg-orange-500', lastMessage: '', time: '', }, isStreaming: false, currentModel: 'glm-5', sessionKey: 'session_alpha', }); useChatStore.getState().setCurrentAgent({ id: 'clone_beta', name: 'Beta', icon: 'B', color: 'bg-blue-500', lastMessage: '', time: '', }); const state = useChatStore.getState(); expect(state.currentAgent?.id).toBe('clone_beta'); expect(state.messages).toEqual([]); expect(state.sessionKey).toBeNull(); expect(state.currentConversationId).toBeNull(); expect(state.conversations).toHaveLength(1); expect(state.conversations[0]).toMatchObject({ sessionKey: 'session_alpha', agentId: 'clone_alpha', title: 'hello', }); }); it('restores the conversation agent when switching back to a saved conversation', async () => { const { useChatStore } = await import('../../desktop/src/store/chatStore'); useChatStore.setState({ messages: [], conversations: [ { id: 'conv_saved', title: 'saved', messages: [ { id: 'user_saved', role: 'user', content: 'saved', timestamp: new Date('2026-03-13T00:01:00.000Z'), }, ], sessionKey: 'session_saved', agentId: 'clone_alpha', createdAt: new Date('2026-03-13T00:01:00.000Z'), updatedAt: new Date('2026-03-13T00:01:00.000Z'), }, ], currentConversationId: null, agents: [ { id: 'clone_alpha', name: 'Alpha', icon: 'A', color: 'bg-orange-500', lastMessage: '', time: '', }, ], currentAgent: { id: 'clone_beta', name: 'Beta', icon: 'B', color: 'bg-blue-500', lastMessage: '', time: '', }, isStreaming: false, currentModel: 'glm-5', sessionKey: null, }); useChatStore.getState().switchConversation('conv_saved'); const state = useChatStore.getState(); expect(state.currentConversationId).toBe('conv_saved'); expect(state.sessionKey).toBe('session_saved'); expect(state.currentAgent?.id).toBe('clone_alpha'); expect(state.messages).toHaveLength(1); expect(state.messages[0].content).toBe('saved'); }); }); describe('chatStore stream correlation', () => { beforeEach(() => { chatMock.mockReset(); onAgentStreamMock.mockClear(); agentStreamHandler = null; vi.resetModules(); }); it('correlates assistant deltas to the matching runId instead of the last streaming message', async () => { const { useChatStore } = await import('../../desktop/src/store/chatStore'); useChatStore.setState({ messages: [ { id: 'assistant_old', role: 'assistant', content: 'old:', timestamp: new Date('2026-03-13T00:00:00.000Z'), streaming: true, runId: 'run_old', }, { id: 'assistant_new', role: 'assistant', content: 'new:', timestamp: new Date('2026-03-13T00:00:01.000Z'), streaming: true, runId: 'run_new', }, ], conversations: [], currentConversationId: null, agents: [], currentAgent: null, isStreaming: true, currentModel: 'glm-5', sessionKey: 'session_test', }); const unsubscribe = useChatStore.getState().initStreamListener(); expect(agentStreamHandler).toBeTypeOf('function'); agentStreamHandler?.({ stream: 'assistant', runId: 'run_old', delta: 'delta-for-old', }); const state = useChatStore.getState(); expect(state.messages.find((message) => message.id === 'assistant_old')?.content).toBe('old:delta-for-old'); expect(state.messages.find((message) => message.id === 'assistant_new')?.content).toBe('new:'); unsubscribe(); }); it('marks only the matching runId message as completed on lifecycle end', async () => { const { useChatStore } = await import('../../desktop/src/store/chatStore'); useChatStore.setState({ messages: [ { id: 'assistant_old', role: 'assistant', content: 'old', timestamp: new Date('2026-03-13T00:00:00.000Z'), streaming: true, runId: 'run_old', }, { id: 'assistant_new', role: 'assistant', content: 'new', timestamp: new Date('2026-03-13T00:00:01.000Z'), streaming: true, runId: 'run_new', }, ], conversations: [], currentConversationId: null, agents: [], currentAgent: null, isStreaming: true, currentModel: 'glm-5', sessionKey: 'session_test', }); const unsubscribe = useChatStore.getState().initStreamListener(); agentStreamHandler?.({ stream: 'lifecycle', runId: 'run_old', phase: 'end', }); const state = useChatStore.getState(); expect(state.messages.find((message) => message.id === 'assistant_old')?.streaming).toBe(false); expect(state.messages.find((message) => message.id === 'assistant_new')?.streaming).toBe(true); unsubscribe(); }); }); describe('chatStore OpenFang events', () => { beforeEach(() => { chatMock.mockReset(); onAgentStreamMock.mockClear(); agentStreamHandler = null; vi.resetModules(); }); it('creates a hand message when receiving hand stream events', async () => { const { useChatStore } = await import('../../desktop/src/store/chatStore'); useChatStore.setState({ messages: [ { id: 'assistant_1', role: 'assistant', content: 'Processing...', timestamp: new Date('2026-03-13T00:00:00.000Z'), streaming: true, runId: 'run_1', }, ], conversations: [], currentConversationId: null, agents: [], currentAgent: null, isStreaming: true, currentModel: 'glm-5', sessionKey: 'session_test', }); const unsubscribe = useChatStore.getState().initStreamListener(); agentStreamHandler?.({ stream: 'hand', runId: 'run_1', handName: 'summarize', handStatus: 'completed', handResult: { summary: 'This is a test summary' }, }); const state = useChatStore.getState(); const handMsg = state.messages.find((m) => m.role === 'hand'); expect(handMsg).toBeDefined(); expect(handMsg?.handName).toBe('summarize'); expect(handMsg?.handStatus).toBe('completed'); expect(handMsg?.content).toContain('summary'); unsubscribe(); }); it('creates a workflow message when receiving workflow stream events', async () => { const { useChatStore } = await import('../../desktop/src/store/chatStore'); useChatStore.setState({ messages: [ { id: 'assistant_1', role: 'assistant', content: 'Running workflow...', timestamp: new Date('2026-03-13T00:00:00.000Z'), streaming: true, runId: 'run_1', }, ], conversations: [], currentConversationId: null, agents: [], currentAgent: null, isStreaming: true, currentModel: 'glm-5', sessionKey: 'session_test', }); const unsubscribe = useChatStore.getState().initStreamListener(); agentStreamHandler?.({ stream: 'workflow', runId: 'run_1', workflowId: 'wf_analysis', workflowStep: 'step_2', workflowStatus: 'running', }); const state = useChatStore.getState(); const workflowMsg = state.messages.find((m) => m.role === 'workflow'); expect(workflowMsg).toBeDefined(); expect(workflowMsg?.workflowId).toBe('wf_analysis'); expect(workflowMsg?.workflowStep).toBe('step_2'); expect(workflowMsg?.workflowStatus).toBe('running'); unsubscribe(); }); it('includes workflow result in the message content when available', async () => { const { useChatStore } = await import('../../desktop/src/store/chatStore'); useChatStore.setState({ messages: [ { id: 'assistant_1', role: 'assistant', content: 'Running workflow...', timestamp: new Date('2026-03-13T00:00:00.000Z'), streaming: true, runId: 'run_1', }, ], conversations: [], currentConversationId: null, agents: [], currentAgent: null, isStreaming: true, currentModel: 'glm-5', sessionKey: 'session_test', }); const unsubscribe = useChatStore.getState().initStreamListener(); agentStreamHandler?.({ stream: 'workflow', runId: 'run_1', workflowId: 'wf_analysis', workflowStep: 'final', workflowStatus: 'completed', workflowResult: { output: 'Analysis complete', score: 95 }, }); const state = useChatStore.getState(); const workflowMsg = state.messages.find((m) => m.role === 'workflow'); expect(workflowMsg).toBeDefined(); expect(workflowMsg?.content).toContain('output'); expect(workflowMsg?.content).toContain('score'); unsubscribe(); }); });