316 lines
11 KiB
TypeScript
316 lines
11 KiB
TypeScript
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<string, string>,
|
|
sessionLastActivity: {} as Record<string, number>,
|
|
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<string, unknown> | ((state: typeof chatStateMock.state) => Record<string, unknown>)) => {
|
|
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<string, (payload: unknown) => 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<string, (payload: unknown) => 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<string, (payload: unknown) => 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<string, (payload: unknown) => 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<string, (payload: unknown) => 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<string, (payload: unknown) => 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<string, (payload: unknown) => 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<string, (payload: unknown) => 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);
|
|
});
|
|
});
|