// @vitest-environment node import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createLearningAgentClient } from '@electron/services/learning-agent-client'; describe('Learning Agent client', () => { const fetchImpl = vi.fn(); const getAccessToken = vi.fn(); const chunkedStream = (...chunks: string[]) => new Response(new ReadableStream({ start(controller) { const encoder = new TextEncoder(); for (const chunk of chunks) controller.enqueue(encoder.encode(chunk)); controller.close(); }, }), { status: 200, headers: { 'Content-Type': 'text/event-stream' } }); const mockRun = (stream: Response) => { fetchImpl .mockResolvedValueOnce(new Response(JSON.stringify({ session_id: 'session-1' }), { status: 201 })) .mockResolvedValueOnce(new Response(JSON.stringify({ run_id: 'run-1' }), { status: 202 })) .mockResolvedValueOnce(new Response(JSON.stringify({ stream_url: '/api/agents/sessions/session-1/events?ticket=ticket-1' }), { status: 200 })) .mockResolvedValueOnce(stream); }; const ask = () => createLearningAgentClient({ fetchImpl, getAccessToken, apiBaseUrl: 'https://square.example', }).ask({ courseId: 'course-1', contentHash: 'a'.repeat(64), message: '为什么要学 Python?', }); beforeEach(() => { fetchImpl.mockReset(); getAccessToken.mockReset(); getAccessToken.mockResolvedValue('works-token'); }); it('binds the exact course hash and collects the Learning Runtime answer', async () => { const event = (type: string, payload: Record) => `event: agent.event\ndata: ${JSON.stringify({ run_id: 'run-1', type, payload })}\n\n`; fetchImpl .mockResolvedValueOnce(new Response(JSON.stringify({ session_id: 'session-1' }), { status: 201 })) .mockResolvedValueOnce(new Response(JSON.stringify({ run_id: 'run-1' }), { status: 202 })) .mockResolvedValueOnce(new Response(JSON.stringify({ stream_url: '/api/agents/sessions/session-1/events?ticket=ticket-1' }), { status: 200 })) .mockResolvedValueOnce(new Response( event('learning.assistant.delta', { delta: 'Python ' }) + event('learning.assistant.delta', { delta: '很适合入门。' }) + event('learning.assistant.completed', { sources: [] }), { status: 200, headers: { 'Content-Type': 'text/event-stream' } }, )); const client = createLearningAgentClient({ fetchImpl, getAccessToken, apiBaseUrl: 'https://square.example' }); await expect(client.ask({ courseId: 'course-1', contentHash: 'a'.repeat(64), message: '为什么要学 Python?', anchor: { sceneId: 'scene-1', sceneOrder: 1 }, })).resolves.toEqual({ text: 'Python 很适合入门。' }); expect(JSON.parse(String(fetchImpl.mock.calls[0][1]?.body))).toMatchObject({ runtime: 'learning', runtime_version: 'v1', binding: { kind: 'learning-course', key: `course-1:${'a'.repeat(64)}` }, }); expect(fetchImpl.mock.calls[3][1]?.headers).toEqual({ Accept: 'text/event-stream' }); expect(fetchImpl.mock.calls.some((call) => call[1]?.method === 'DELETE')).toBe(false); }); it('reuses one course-bound Works Agent session across multiple turns', async () => { const stream = (runId: string, answer: string) => new Response( `data: ${JSON.stringify({ run_id: runId, type: 'learning.assistant.delta', payload: { delta: answer } })}\n\n` + `data: ${JSON.stringify({ run_id: runId, type: 'learning.assistant.completed', payload: {} })}\n\n`, { status: 200, headers: { 'Content-Type': 'text/event-stream' } }, ); fetchImpl .mockResolvedValueOnce(new Response(JSON.stringify({ session_id: 'session-shared' }), { status: 201 })) .mockResolvedValueOnce(new Response(JSON.stringify({ run_id: 'run-1' }), { status: 202 })) .mockResolvedValueOnce(new Response(JSON.stringify({ stream_url: '/api/agents/sessions/session-shared/events?ticket=1' }), { status: 200 })) .mockResolvedValueOnce(stream('run-1', '第一轮')) .mockResolvedValueOnce(new Response(JSON.stringify({ run_id: 'run-2' }), { status: 202 })) .mockResolvedValueOnce(new Response(JSON.stringify({ stream_url: '/api/agents/sessions/session-shared/events?ticket=2' }), { status: 200 })) .mockResolvedValueOnce(stream('run-2', '第二轮')); const client = createLearningAgentClient({ fetchImpl, getAccessToken, apiBaseUrl: 'https://square.example' }); const binding = { courseId: 'course-1', contentHash: 'a'.repeat(64) }; await expect(client.ask({ ...binding, message: '第一问' })).resolves.toEqual({ text: '第一轮' }); await expect(client.ask({ ...binding, message: '追问' })).resolves.toEqual({ text: '第二轮' }); const urls = fetchImpl.mock.calls.map(([url]) => String(url)); expect(urls.filter((url) => url === 'https://square.example/api/agents/sessions')).toHaveLength(1); expect(urls.filter((url) => url.endsWith('/sessions/session-shared/commands'))).toHaveLength(2); expect(fetchImpl.mock.calls.some((call) => call[1]?.method === 'DELETE')).toBe(false); }); it('parses production CRLF-delimited SSE frames', async () => { const event = (type: string, payload: Record) => ( `id: 1\r\nevent: agent.event\r\ndata: ${JSON.stringify({ run_id: 'run-1', type, payload })}\r\n\r\n` ); mockRun(chunkedStream( event('learning.assistant.delta', { delta: 'CRLF 正常。' }), event('learning.assistant.completed', {}), )); await expect(ask()).resolves.toEqual({ text: 'CRLF 正常。' }); }); it('parses a CRLF frame delimiter split across stream chunks', async () => { const delta = JSON.stringify({ run_id: 'run-1', type: 'learning.assistant.delta', payload: { delta: '跨块正常。' }, }); const completed = JSON.stringify({ run_id: 'run-1', type: 'learning.assistant.completed', payload: {}, }); mockRun(chunkedStream( `data: ${delta}\r\n\r`, `\ndata: ${completed}\r`, '\n\r', '\n', )); await expect(ask()).resolves.toEqual({ text: '跨块正常。' }); }); it('joins multiple data lines and ignores other SSE fields and comments', async () => { const delta = JSON.stringify({ run_id: 'run-1', type: 'learning.assistant.delta', payload: { delta: '多行正常。' }, }); const split = delta.indexOf(',"type"'); const completed = JSON.stringify({ run_id: 'run-1', type: 'learning.assistant.completed', payload: {}, }); mockRun(chunkedStream( ': keep-alive\r\nid: 9\r\nevent: agent.event\r\nretry: 3000\r\n', `data: ${delta.slice(0, split)},\r\ndata: ${delta.slice(split + 1)}\r\n\r\n`, `data: ${completed}\r\n\r\n`, )); await expect(ask()).resolves.toEqual({ text: '多行正常。' }); }); it('stops before the network for an invalid package hash', async () => { const client = createLearningAgentClient({ fetchImpl, getAccessToken }); await expect(client.ask({ courseId: 'course-1', contentHash: 'bad', message: '你好' })).rejects.toThrow('助教请求无效'); expect(fetchImpl).not.toHaveBeenCalled(); }); });