- 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>
502 lines
14 KiB
TypeScript
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();
|
|
});
|
|
});
|