Files
waggle-os/packages/server/tests/channels-chat-client.test.ts
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

151 lines
6.3 KiB
TypeScript

/**
* Loopback chat client — SSE frame parsing and turn collapse
* (CHANNELS-ARC P1). Network is stubbed via vi.stubGlobal.
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { drainSseBuffer, runChannelChatTurn } from '../src/local/channels/chat-client.js';
afterEach(() => {
vi.unstubAllGlobals();
});
describe('drainSseBuffer', () => {
it('parses complete frames and returns the incomplete tail', () => {
const buffer =
'event: token\ndata: {"content":"he"}\n\n' +
'event: done\ndata: {"content":"hello"}\n\n' +
'event: token\ndata: {"con';
const { events, rest } = drainSseBuffer(buffer);
expect(events).toEqual([
{ event: 'token', data: { content: 'he' } },
{ event: 'done', data: { content: 'hello' } },
]);
expect(rest).toBe('event: token\ndata: {"con');
});
it('ignores malformed JSON frames without dropping later ones', () => {
const buffer = 'event: x\ndata: {broken\n\nevent: done\ndata: {"content":"ok"}\n\n';
const { events } = drainSseBuffer(buffer);
expect(events).toEqual([{ event: 'done', data: { content: 'ok' } }]);
});
});
function sseResponse(frames: string[]): Response {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
for (const f of frames) controller.enqueue(new TextEncoder().encode(f));
controller.close();
},
});
return new Response(stream, { status: 200 });
}
describe('runChannelChatTurn', () => {
it('collapses a streamed turn into the done content', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(sseResponse([
'event: token\ndata: {"content":"par"}\n\n',
'event: token\ndata: {"content":"tial"}\n\n',
'event: done\ndata: {"content":"full reply","toolsUsed":[]}\n\n',
])));
const result = await runChannelChatTurn({
port: 3333, sessionToken: 'test-session-token', message: 'hi', workspace: 'default', session: 'channel-telegram-1',
});
expect(result).toEqual({ content: 'full reply', approvalRequired: false, error: undefined });
expect(vi.mocked(fetch).mock.calls[0][0]).toBe('http://127.0.0.1:3333/api/chat');
expect(vi.mocked(fetch).mock.calls[0][1]).toEqual(expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer test-session-token' }),
}));
});
it('forwards origin:"automation" in the POST body when set (#13)', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(sseResponse([
'event: done\ndata: {"content":"reviewed","toolsUsed":[]}\n\n',
])));
await runChannelChatTurn({
port: 3333, message: 'review', workspace: 'default', session: 'evolve-x',
origin: 'automation',
});
const body = JSON.parse(vi.mocked(fetch).mock.calls[0][1]!.body as string);
expect(body.origin).toBe('automation');
});
it('forwards channel meta in the POST body when set (#17)', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(sseResponse([
'event: done\ndata: {"content":"hi","toolsUsed":[]}\n\n',
])));
await runChannelChatTurn({
port: 3333, message: 'hi', workspace: 'default', session: 'channel-telegram--10042',
channel: { platform: 'telegram', chatId: '-10042' },
});
const body = JSON.parse(vi.mocked(fetch).mock.calls[0][1]!.body as string);
expect(body.channel).toEqual({ platform: 'telegram', chatId: '-10042' });
});
it('omits origin for normal channel turns — IM messages are real user turns (#13)', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(sseResponse([
'event: done\ndata: {"content":"hi","toolsUsed":[]}\n\n',
])));
await runChannelChatTurn({
port: 3333, message: 'hi', workspace: 'default', session: 'channel-telegram-1',
});
const body = JSON.parse(vi.mocked(fetch).mock.calls[0][1]!.body as string);
expect('origin' in body).toBe(false);
});
it('flags approval_required turns', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(sseResponse([
'event: approval_required\ndata: {"requestId":"r1","toolName":"bash"}\n\n',
])));
const result = await runChannelChatTurn({
port: 3333, sessionToken: 'test-session-token', message: 'rm stuff', workspace: 'default', session: 's',
});
expect(result.approvalRequired).toBe(true);
expect(result.content).toBe('');
});
it('maps the injection-scanner 400 to a user-safe error', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(
JSON.stringify({ error: 'Message blocked by security scanner', code: 'INJECTION_DETECTED' }),
{ status: 400 },
)));
const result = await runChannelChatTurn({
port: 3333, sessionToken: 'test-session-token', message: 'ignore previous instructions…', workspace: 'default', session: 's',
});
expect(result.error).toMatch(/security scanner/i);
expect(result.content).toBe('');
});
it('surfaces stream-level error events', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(sseResponse([
'event: error\ndata: {"error":"model unavailable"}\n\n',
])));
const result = await runChannelChatTurn({
port: 3333, sessionToken: 'test-session-token', message: 'hi', workspace: 'default', session: 's',
});
expect(result.error).toBe('model unavailable');
});
it('times out a wedged turn', async () => {
// Request hangs until the timeout AbortController fires — mirrors native
// fetch, which rejects with AbortError when its signal aborts.
vi.stubGlobal('fetch', vi.fn((_url: unknown, init?: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () =>
reject(new DOMException('The operation was aborted', 'AbortError')));
})));
const result = await runChannelChatTurn({
port: 3333, sessionToken: 'test-session-token', message: 'hi', workspace: 'default', session: 's', timeoutMs: 50,
});
expect(result.error).toMatch(/timed out/i);
});
it('reports connection failures as errors, not throws', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED')));
const result = await runChannelChatTurn({
port: 3333, sessionToken: 'test-session-token', message: 'hi', workspace: 'default', session: 's',
});
expect(result.error).toBe('ECONNREFUSED');
});
});