Files
zclaw_openfang/tests/desktop/chatStore.test.ts
iven 6a66ce159d refactor(phase-10): complete type safety enhancement
- Add types/api-responses.ts with ApiResponse<T>, PaginatedResponse<T>
- Add types/errors.ts with comprehensive error type hierarchy
- Replace all any usage (53 → 0, 100% reduction)
- Add RawAPI response interfaces for type-safe mapping
- Update catch blocks to use unknown with type narrowing
- Add getState mock to chatStore tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 19:52:52 +08:00

502 lines
14 KiB
TypeScript

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,
getState: () => 'disconnected',
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();
});
});