Files
makelore/tests/unit/learning-agent-client.test.ts
inman 6882ff687b
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: sync origin/main and preserve learning stream fix
2026-08-17 18:11:16 +08:00

264 lines
13 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();
const chunkedStream = (...chunks: string[]) => new Response(new ReadableStream<Uint8Array>({
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<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('parses production CRLF-delimited SSE frames', async () => {
const event = (type: string, payload: Record<string, unknown>) => (
`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();
});
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();
});
});