Files
makelore/tests/unit/learning-agent-client.test.ts
brother7 f7171a471a
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
merge: integrate remote learning module safely
2026-08-17 01:05:49 +08:00

184 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @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<typeof fetch>();
const getAccessToken = vi.fn();
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<string, unknown>) => `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('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();
});
it('bounds compatibility history and anchor before network access', async () => {
const client = createLearningAgentClient({ fetchImpl, getAccessToken });
await expect(client.ask({
courseId: 'course-1',
contentHash: 'a'.repeat(64),
message: '你好',
history: [{ role: 'user', content: 'x'.repeat(4_001) }],
})).rejects.toThrow('助教请求无效');
await expect(client.ask({
courseId: 'course-1',
contentHash: 'a'.repeat(64),
message: '你好',
anchor: { sceneId: 'x'.repeat(257) },
})).rejects.toThrow('助教请求无效');
expect(fetchImpl).not.toHaveBeenCalled();
});
it('serializes concurrent turns for the same durable session', async () => {
let firstStreamResolve!: (response: Response) => void;
const firstStream = new Promise<Response>((resolve) => { firstStreamResolve = resolve; });
let commandCount = 0;
fetchImpl.mockImplementation(async (input, init) => {
const url = String(input);
if (url.endsWith('/api/agents/sessions')) {
return new Response(JSON.stringify({ session_id: 'session-1' }), { status: 201 });
}
if (url.endsWith('/commands')) {
commandCount += 1;
return new Response(JSON.stringify({ run_id: `run-${commandCount}` }), { status: 202 });
}
if (url.endsWith('/stream-tickets')) {
return new Response(JSON.stringify({ stream_url: '/api/agents/sessions/session-1/events' }), { status: 200 });
}
if (url.includes('/events')) {
if (commandCount === 1) return firstStream;
return new Response(
`data: ${JSON.stringify({ run_id: 'run-2', type: 'learning.assistant.delta', payload: { delta: '第二轮' } })}\n\n`
+ `data: ${JSON.stringify({ run_id: 'run-2', type: 'learning.assistant.completed', payload: {} })}\n\n`,
{ status: 200 },
);
}
throw new Error(`unexpected ${url} ${init?.method}`);
});
const client = createLearningAgentClient({ fetchImpl, getAccessToken, apiBaseUrl: 'https://square.example' });
const binding = { courseId: 'course-1', contentHash: 'a'.repeat(64) };
const one = client.ask({ ...binding, message: '第一问' });
await vi.waitFor(() => expect(commandCount).toBe(1));
const two = client.ask({ ...binding, message: '第二问' });
await Promise.resolve();
expect(commandCount).toBe(1);
firstStreamResolve(new Response(
`data: ${JSON.stringify({ run_id: 'run-1', type: 'learning.assistant.delta', payload: { delta: '第一轮' } })}\n\n`
+ `data: ${JSON.stringify({ run_id: 'run-1', type: 'learning.assistant.completed', payload: {} })}\n\n`,
{ status: 200 },
));
await expect(Promise.all([one, two])).resolves.toEqual([{ text: '第一轮' }, { text: '第二轮' }]);
expect(commandCount).toBe(2);
});
it('redacts SSE failure payloads and malformed event data', async () => {
const responses = (stream: string) => [
new Response(JSON.stringify({ session_id: 'session-1' }), { status: 201 }),
new Response(JSON.stringify({ run_id: 'run-1' }), { status: 202 }),
new Response(JSON.stringify({ stream_url: '/api/agents/sessions/session-1/events' }), { status: 200 }),
new Response(stream, { status: 200 }),
];
fetchImpl.mockImplementationOnce(async () => responses('')[0]);
fetchImpl.mockResolvedValueOnce(responses('')[1]).mockResolvedValueOnce(responses('')[2]).mockResolvedValueOnce(
responses(`data: ${JSON.stringify({ run_id: 'run-1', type: 'learning.assistant.failed', payload: { message: 'provider secret traceback' } })}\n\n`)[3],
);
const client = createLearningAgentClient({ fetchImpl, getAccessToken, apiBaseUrl: 'https://square.example' });
await expect(client.ask({ courseId: 'course-1', contentHash: 'a'.repeat(64), message: '你好' }))
.rejects.toThrow('助教回答失败');
});
it('does not create a session after its captured account changes during token lookup', async () => {
let resolveToken!: (token: string) => void;
getAccessToken.mockReturnValueOnce(new Promise((resolve) => { resolveToken = resolve; }));
let current = true;
const client = createLearningAgentClient({ fetchImpl, getAccessToken, apiBaseUrl: 'https://square.example' });
const result = client.ask(
{ courseId: 'course-1', contentHash: 'a'.repeat(64), message: '你好' },
() => { if (!current) throw Object.assign(new Error('account changed'), { code: 'LEARNING_ACCOUNT_CHANGED' }); },
);
current = false;
resolveToken('new-account-token');
await expect(result).rejects.toThrow('account changed');
expect(fetchImpl).not.toHaveBeenCalled();
});
it('does not retry a 401 after its captured account changes during refresh', async () => {
let resolveRefresh!: (token: string) => void;
getAccessToken.mockResolvedValueOnce('old-token').mockReturnValueOnce(new Promise((resolve) => { resolveRefresh = resolve; }));
fetchImpl.mockResolvedValueOnce(new Response(null, { status: 401 }));
let current = true;
const client = createLearningAgentClient({ fetchImpl, getAccessToken, apiBaseUrl: 'https://square.example' });
const result = client.ask(
{ courseId: 'course-1', contentHash: 'a'.repeat(64), message: '你好' },
() => { if (!current) throw Object.assign(new Error('account changed'), { code: 'LEARNING_ACCOUNT_CHANGED' }); },
);
await vi.waitFor(() => expect(getAccessToken).toHaveBeenCalledTimes(2));
current = false;
resolveRefresh('new-account-token');
await expect(result).rejects.toMatchObject({ code: 'LEARNING_ACCOUNT_CHANGED' });
expect(fetchImpl).toHaveBeenCalledOnce();
});
});