- 后端:文本/转写后 AI 生成改为独立任务,避免断连取消整轮;按需 TTS 等与 WS 改动 - 前端:RealtimeSession 重绑 UI 时恢复流式 buffer;列表 onPressIn/挂载预热、已有会话立即 push - 同步会话相关类型、i18n、测试与 env/资源等累计改动 Co-authored-by: Cursor <cursoragent@cursor.com>
185 lines
4.6 KiB
TypeScript
185 lines
4.6 KiB
TypeScript
import { WsClient } from '@/core/ws/client';
|
|
import type { WsEvent, WsConnectionState } from '@/core/ws/types';
|
|
|
|
jest.mock('@/core/auth/token-manager', () => ({
|
|
tokenManager: {
|
|
getAccessToken: jest.fn().mockResolvedValue('test-token'),
|
|
},
|
|
}));
|
|
|
|
jest.mock('@/core/config', () => ({
|
|
config: {
|
|
wsBaseUrl: 'ws://localhost:8000/',
|
|
ws: {
|
|
reconnectMaxRetries: 3,
|
|
reconnectBaseDelayMs: 10,
|
|
reconnectMaxDelayMs: 100,
|
|
heartbeatIntervalMs: 60000,
|
|
},
|
|
},
|
|
}));
|
|
|
|
// Mock WebSocket
|
|
class MockWebSocket {
|
|
static OPEN = 1;
|
|
static CLOSED = 3;
|
|
static instances: MockWebSocket[] = [];
|
|
|
|
readyState = MockWebSocket.OPEN;
|
|
onopen: (() => void) | null = null;
|
|
onmessage: ((event: { data: string }) => void) | null = null;
|
|
onclose: (() => void) | null = null;
|
|
onerror: (() => void) | null = null;
|
|
sentMessages: string[] = [];
|
|
|
|
constructor(public url: string) {
|
|
MockWebSocket.instances.push(this);
|
|
setTimeout(() => this.onopen?.(), 0);
|
|
}
|
|
|
|
send(data: string) {
|
|
this.sentMessages.push(data);
|
|
}
|
|
|
|
close() {
|
|
this.readyState = MockWebSocket.CLOSED;
|
|
}
|
|
|
|
simulateMessage(data: Record<string, unknown>) {
|
|
this.onmessage?.({ data: JSON.stringify(data) });
|
|
}
|
|
}
|
|
|
|
(global as Record<string, unknown>).WebSocket = MockWebSocket;
|
|
|
|
describe('WsClient', () => {
|
|
afterEach(() => {
|
|
jest.clearAllMocks();
|
|
MockWebSocket.instances = [];
|
|
});
|
|
|
|
test('connects with token and conversation id in URL', async () => {
|
|
const client = new WsClient('conv-123');
|
|
|
|
const states: WsConnectionState[] = [];
|
|
client.onStateChange((s) => states.push(s));
|
|
|
|
await client.connect();
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
|
|
expect(states).toContain('connecting');
|
|
expect(states).toContain('connected');
|
|
expect(MockWebSocket.instances[0]?.url).toBe(
|
|
'ws://localhost:8000/ws/conversation/conv-123?token=test-token',
|
|
);
|
|
|
|
client.dispose();
|
|
});
|
|
|
|
test('maps server messages to domain events', async () => {
|
|
const client = new WsClient('conv-123');
|
|
const events: WsEvent[] = [];
|
|
client.onEvent((e) => events.push(e));
|
|
|
|
await client.connect();
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
|
|
const ws = (client as unknown as { ws: MockWebSocket }).ws;
|
|
|
|
ws.simulateMessage({
|
|
type: 'connect',
|
|
conversation_id: 'conv-123',
|
|
data: { status: 'connected' },
|
|
timestamp: '2026-01-01T00:00:00Z',
|
|
});
|
|
|
|
ws.simulateMessage({
|
|
type: 'agent_response',
|
|
conversation_id: 'conv-123',
|
|
data: { text: 'Hello!', index: 0, total: 1 },
|
|
timestamp: '2026-01-01T00:00:00Z',
|
|
});
|
|
|
|
expect(events).toHaveLength(2);
|
|
expect(events[0]).toEqual({
|
|
kind: 'connected',
|
|
conversationId: 'conv-123',
|
|
});
|
|
expect(events[1]).toEqual({
|
|
kind: 'agent_response',
|
|
conversationId: 'conv-123',
|
|
text: 'Hello!',
|
|
index: 0,
|
|
total: 1,
|
|
isTransition: undefined,
|
|
segmentIndex: undefined,
|
|
});
|
|
|
|
client.dispose();
|
|
});
|
|
|
|
test('sends text messages', async () => {
|
|
const client = new WsClient('conv-123');
|
|
|
|
await client.connect();
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
|
|
client.sendText('Hi there');
|
|
|
|
const ws = (client as unknown as { ws: MockWebSocket }).ws;
|
|
expect(ws.sentMessages).toHaveLength(1);
|
|
|
|
const sent = JSON.parse(ws.sentMessages[0]);
|
|
expect(sent).toEqual({
|
|
type: 'text',
|
|
conversation_id: 'conv-123',
|
|
data: { text: 'Hi there' },
|
|
});
|
|
|
|
client.dispose();
|
|
});
|
|
|
|
test('sends text with tts_this_turn when requested', async () => {
|
|
const client = new WsClient('conv-123');
|
|
|
|
await client.connect();
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
|
|
client.sendText('Hello', { ttsThisTurn: true });
|
|
|
|
const ws = (client as unknown as { ws: MockWebSocket }).ws;
|
|
expect(ws.sentMessages).toHaveLength(1);
|
|
|
|
const sent = JSON.parse(ws.sentMessages[0]);
|
|
expect(sent).toEqual({
|
|
type: 'text',
|
|
conversation_id: 'conv-123',
|
|
data: { text: 'Hello', tts_this_turn: true },
|
|
});
|
|
|
|
client.dispose();
|
|
});
|
|
|
|
test('ignores unknown message types without crashing', async () => {
|
|
const client = new WsClient('conv-123');
|
|
const events: WsEvent[] = [];
|
|
client.onEvent((e) => events.push(e));
|
|
|
|
await client.connect();
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
|
|
const ws = (client as unknown as { ws: MockWebSocket }).ws;
|
|
ws.simulateMessage({
|
|
type: 'unknown_type' as string,
|
|
conversation_id: 'conv-123',
|
|
data: {},
|
|
timestamp: '2026-01-01T00:00:00Z',
|
|
});
|
|
|
|
// Unknown types are silently ignored — no events emitted
|
|
expect(events).toHaveLength(0);
|
|
|
|
client.dispose();
|
|
});
|
|
});
|