merge: integrate remote learning module safely
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

This commit is contained in:
2026-08-17 01:05:49 +08:00
125 changed files with 11451 additions and 12132 deletions

View File

@@ -0,0 +1,183 @@
// @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();
});
});