import { beforeEach, describe, expect, it, vi } from 'vitest'; const hostApiFetchMock = vi.fn(); const subscribeHostEventMock = vi.fn(); const chatStateMock = vi.hoisted(() => ({ state: { currentSessionKey: 'session-1', sessions: [] as Array<{ key: string }>, messages: [] as Array<{ id?: string; role: string; content: unknown; timestamp?: number }>, sessionLabels: {} as Record, sessionLastActivity: {} as Record, sending: true, activeRunId: 'run-1' as string | null, pendingFinal: true, lastUserMessageAt: 123, error: null as string | null, loadHistory: vi.fn(), loadSessions: vi.fn(), handleChatEvent: vi.fn(), }, })); vi.mock('@/lib/host-api', () => ({ hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args), })); vi.mock('@/lib/host-events', () => ({ subscribeHostEvent: (...args: unknown[]) => subscribeHostEventMock(...args), })); vi.mock('@/stores/chat', () => ({ useChatStore: { getState: () => chatStateMock.state, setState: (patch: Record | ((state: typeof chatStateMock.state) => Record)) => { Object.assign(chatStateMock.state, typeof patch === 'function' ? patch(chatStateMock.state) : patch); }, }, })); describe('gateway store event wiring', () => { beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); Object.assign(chatStateMock.state, { currentSessionKey: 'session-1', sessions: [], messages: [], sessionLabels: {}, sessionLastActivity: {}, sending: true, activeRunId: 'run-1', pendingFinal: true, lastUserMessageAt: 123, error: null, }); }); it('subscribes to host events through subscribeHostEvent on init', async () => { hostApiFetchMock.mockResolvedValueOnce({ state: 'running', port: 18789 }); const handlers = new Map void>(); subscribeHostEventMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { handlers.set(eventName, handler); return () => {}; }); const { useGatewayStore } = await import('@/stores/gateway'); await useGatewayStore.getState().init(); expect(subscribeHostEventMock).toHaveBeenCalledWith('gateway:status', expect.any(Function)); expect(subscribeHostEventMock).toHaveBeenCalledWith('gateway:error', expect.any(Function)); expect(subscribeHostEventMock).toHaveBeenCalledWith('gateway:notification', expect.any(Function)); expect(subscribeHostEventMock).toHaveBeenCalledWith('gateway:chat-message', expect.any(Function)); expect(subscribeHostEventMock).toHaveBeenCalledWith('gateway:channel-status', expect.any(Function)); handlers.get('gateway:status')?.({ state: 'stopped', port: 18789 }); expect(useGatewayStore.getState().status.state).toBe('stopped'); }); it('propagates gatewayReady field from status events', async () => { hostApiFetchMock.mockResolvedValueOnce({ state: 'running', port: 18789, gatewayReady: false }); const handlers = new Map void>(); subscribeHostEventMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { handlers.set(eventName, handler); return () => {}; }); const { useGatewayStore } = await import('@/stores/gateway'); await useGatewayStore.getState().init(); // Initially gatewayReady=false from the status fetch expect(useGatewayStore.getState().status.gatewayReady).toBe(false); // Simulate gateway.ready event setting gatewayReady=true handlers.get('gateway:status')?.({ state: 'running', port: 18789, gatewayReady: true }); expect(useGatewayStore.getState().status.gatewayReady).toBe(true); }); it('treats undefined gatewayReady as ready for backwards compatibility', async () => { hostApiFetchMock.mockResolvedValueOnce({ state: 'running', port: 18789 }); const handlers = new Map void>(); subscribeHostEventMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { handlers.set(eventName, handler); return () => {}; }); const { useGatewayStore } = await import('@/stores/gateway'); await useGatewayStore.getState().init(); const status = useGatewayStore.getState().status; // gatewayReady is undefined (old gateway version) — should be treated as ready expect(status.gatewayReady).toBeUndefined(); expect(status.state === 'running' && status.gatewayReady !== false).toBe(true); }); it('does not clear sending state on intermediate agent phase=end notifications', async () => { hostApiFetchMock.mockResolvedValueOnce({ state: 'running', port: 18789 }); const handlers = new Map void>(); subscribeHostEventMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { handlers.set(eventName, handler); return () => {}; }); const { useGatewayStore } = await import('@/stores/gateway'); await useGatewayStore.getState().init(); handlers.get('gateway:notification')?.({ method: 'agent', params: { phase: 'end', runId: 'run-1', sessionKey: 'session-1', }, }); await vi.waitFor(() => { expect(chatStateMock.state.loadHistory).toHaveBeenCalled(); }); expect(chatStateMock.state.sending).toBe(true); expect(chatStateMock.state.activeRunId).toBe('run-1'); expect(chatStateMock.state.pendingFinal).toBe(true); }); it('makes newly started background sessions visible before sessions.list refreshes', async () => { const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1773300000000); hostApiFetchMock.mockResolvedValueOnce({ state: 'running', port: 18789 }); const handlers = new Map void>(); subscribeHostEventMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { handlers.set(eventName, handler); return () => {}; }); const { useGatewayStore } = await import('@/stores/gateway'); await useGatewayStore.getState().init(); handlers.get('gateway:notification')?.({ method: 'agent', params: { phase: 'started', runId: 'run-cron-1', sessionKey: 'agent:main:cron:job-1', }, }); await vi.waitFor(() => { expect(chatStateMock.state.sessions).toContainEqual(expect.objectContaining({ key: 'agent:main:cron:job-1', updatedAt: 1773300000000, })); }); expect(chatStateMock.state.sessionLastActivity['agent:main:cron:job-1']).toBe(1773300000000); nowSpy.mockRestore(); }); it('folds isolated cron run events back into the fixed task conversation', async () => { const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1773300000000); hostApiFetchMock.mockResolvedValueOnce({ state: 'running', port: 18789 }); Object.assign(chatStateMock.state, { currentSessionKey: 'agent:main:cron:job-1', activeRunId: null, activeRunSessionKey: 'agent:main:cron:job-1', sending: true, }); const handlers = new Map void>(); subscribeHostEventMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { handlers.set(eventName, handler); return () => {}; }); const { useGatewayStore } = await import('@/stores/gateway'); const { useCronStore } = await import('@/stores/cron'); useCronStore.setState({ jobs: [{ id: 'job-1', name: '每日经营日报', message: '生成日报', schedule: '0 9 * * *', enabled: true, createdAt: '2026-05-13T00:00:00.000Z', updatedAt: '2026-05-13T00:00:00.000Z', agentId: 'main', }], loading: false, error: null, }); await useGatewayStore.getState().init(); handlers.get('gateway:notification')?.({ method: 'agent', params: { phase: 'started', runId: 'run-cron-1', sessionKey: 'agent:main:cron:job-1:run:session-a', }, }); await vi.waitFor(() => { expect(chatStateMock.state.sessions).toContainEqual(expect.objectContaining({ key: 'agent:main:cron:job-1', updatedAt: 1773300000000, })); }); expect(chatStateMock.state.sessions.find((session) => session.key === 'agent:main:cron:job-1:run:session-a')).toBeUndefined(); expect(chatStateMock.state.handleChatEvent).toHaveBeenCalledWith(expect.objectContaining({ state: 'started', runId: 'run-cron-1', sessionKey: 'agent:main:cron:job-1', })); expect(chatStateMock.state.messages).toContainEqual(expect.objectContaining({ role: 'user', content: '生成日报', timestamp: 1773300000000, })); expect(chatStateMock.state.messages).toContainEqual(expect.objectContaining({ role: 'assistant', content: '任务已开始执行,完成后会自动在这里显示结果。', })); handlers.get('gateway:notification')?.({ method: 'agent', params: { phase: 'completed', runId: 'run-cron-1', sessionKey: 'agent:main:cron:job-1:run:session-a', }, }); await vi.waitFor(() => { expect(chatStateMock.state.loadHistory).toHaveBeenCalledWith(false); }); expect(chatStateMock.state.sending).toBe(false); expect(chatStateMock.state.activeRunSessionKey).toBeNull(); nowSpy.mockRestore(); }); it('clears sending state on terminal agent completion notifications', async () => { hostApiFetchMock.mockResolvedValueOnce({ state: 'running', port: 18789 }); const handlers = new Map void>(); subscribeHostEventMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { handlers.set(eventName, handler); return () => {}; }); const { useGatewayStore } = await import('@/stores/gateway'); await useGatewayStore.getState().init(); handlers.get('gateway:notification')?.({ method: 'agent', params: { phase: 'completed', runId: 'run-1', sessionKey: 'session-1', }, }); await vi.waitFor(() => { expect(chatStateMock.state.sending).toBe(false); }); expect(chatStateMock.state.activeRunId).toBeNull(); expect(chatStateMock.state.pendingFinal).toBe(false); }); it('does not clear sending state for terminal notifications from a different active run', async () => { hostApiFetchMock.mockResolvedValueOnce({ state: 'running', port: 18789 }); const handlers = new Map void>(); subscribeHostEventMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { handlers.set(eventName, handler); return () => {}; }); const { useGatewayStore } = await import('@/stores/gateway'); await useGatewayStore.getState().init(); handlers.get('gateway:notification')?.({ method: 'agent', params: { phase: 'completed', runId: 'stale-run', sessionKey: 'session-1', }, }); await new Promise((resolve) => setTimeout(resolve, 0)); expect(chatStateMock.state.sending).toBe(true); expect(chatStateMock.state.activeRunId).toBe('run-1'); expect(chatStateMock.state.pendingFinal).toBe(true); }); });