350 lines
11 KiB
TypeScript
350 lines
11 KiB
TypeScript
// @vitest-environment node
|
|
import { afterEach, expect, it, vi } from 'vitest';
|
|
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import {
|
|
prepareCloudTeacher,
|
|
type TeacherCloudTransport,
|
|
} from '../../electron/coding-teacher/cloud-runner';
|
|
import { TeacherError } from '../../electron/coding-teacher/config-client';
|
|
import type { TeacherTopic } from '../../shared/coding-teacher';
|
|
|
|
const roots: string[] = [];
|
|
afterEach(async () => {
|
|
for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true });
|
|
});
|
|
const requestId = '11111111-1111-4111-8111-111111111111';
|
|
async function fixture() {
|
|
const projectPath = await mkdtemp(path.join(tmpdir(), 'cloud-teacher-'));
|
|
roots.push(projectPath);
|
|
await mkdir(path.join(projectPath, 'src'));
|
|
await writeFile(path.join(projectPath, 'src/game.ts'), 'const gravity = 0.6;');
|
|
const topic: TeacherTopic = {
|
|
id: '22222222-2222-4222-8222-222222222222',
|
|
revision: 0,
|
|
schemaVersion: 1,
|
|
accountId: 'student',
|
|
projectId: 'project',
|
|
sourceConversationId: 'pi-session',
|
|
version: 9,
|
|
createdAt: 'now',
|
|
updatedAt: 'now',
|
|
requests: [],
|
|
definition: {
|
|
runtime: 'yuxi',
|
|
schema_version: 1,
|
|
teacher_id: 'coding-teacher',
|
|
name: '老师',
|
|
description: '',
|
|
avatar_id: 'avatar-01',
|
|
welcome_message: '',
|
|
suggested_questions: [],
|
|
system_prompt: '',
|
|
skills: [],
|
|
model: { model_id: 'deepseek-flash', reasoning_choice: { mode: 'default' } },
|
|
limits: { max_input_tokens: 8000, max_output_tokens: 4096 },
|
|
yuxi: { agent_slug: 'teacher', agent_version: 4 },
|
|
},
|
|
};
|
|
const assertCurrent = vi.fn();
|
|
const access = {
|
|
projectPath,
|
|
assertCurrent,
|
|
source: {
|
|
messages: [{ id: 'pi-message', role: 'user' as const, text: '创建小游戏\n调整重力' }],
|
|
cursor: { workerGeneration: 1, seq: 3 },
|
|
capturedAt: 'now',
|
|
},
|
|
};
|
|
const account = { id: 'student', binding: { accountKey: 'student', epoch: 1 } };
|
|
const progress = vi.fn(),
|
|
saveRequest = vi.fn();
|
|
return { topic, access, account, progress, saveRequest };
|
|
}
|
|
|
|
it('returns all three local reads with matching ids, then displays the resumed reply', async () => {
|
|
const f = await fixture();
|
|
const results: unknown[] = [];
|
|
const transport: TeacherCloudTransport = {
|
|
json: vi.fn(async (url, body) => {
|
|
if (url === '/questions') {
|
|
expect(body).toMatchObject({
|
|
teacher_version: 9,
|
|
request_id: requestId,
|
|
thread_id: f.topic.id,
|
|
local_context: {
|
|
id: requestId,
|
|
scope: { project_id: 'project', source_session_id: 'pi-session' },
|
|
},
|
|
});
|
|
expect(JSON.stringify(body)).not.toContain(f.access.projectPath);
|
|
return { request_id: 'cloud-request', run_id: 'run-1' };
|
|
}
|
|
if (url === '/runs/run-1')
|
|
return {
|
|
status: 'interrupted',
|
|
interrupt: {
|
|
source: 'client_read_tools',
|
|
context_id: requestId,
|
|
calls: [
|
|
{ tool_call_id: 'ls', name: 'list_project_files', arguments: { path: '.' } },
|
|
{
|
|
tool_call_id: 'file',
|
|
name: 'read_project_file',
|
|
arguments: { path: 'src/game.ts' },
|
|
},
|
|
{
|
|
tool_call_id: 'chat',
|
|
name: 'read_conversation',
|
|
arguments: { message_id: 'pi-message', start_line: 2, line_count: 1 },
|
|
},
|
|
],
|
|
},
|
|
};
|
|
if (url.endsWith('/tool-results')) {
|
|
results.push(body);
|
|
return { run_id: 'run-2' };
|
|
}
|
|
if (url === '/runs/run-2')
|
|
return { status: 'completed', output: '建议调整 src/game.ts:1 的重力。' };
|
|
throw new Error('unexpected ' + url);
|
|
}),
|
|
events: vi.fn(),
|
|
};
|
|
const model = prepareCloudTeacher(
|
|
f.account,
|
|
f.topic,
|
|
requestId,
|
|
f.access,
|
|
f.progress,
|
|
f.saveRequest,
|
|
transport
|
|
);
|
|
const text = vi.fn();
|
|
await model.run(
|
|
[
|
|
{ role: 'system', content: '旧本地提示词不能上传' },
|
|
{ role: 'user', content: '项目有问题吗?' },
|
|
],
|
|
new AbortController().signal,
|
|
text
|
|
);
|
|
expect(results).toEqual([
|
|
{
|
|
context_id: requestId,
|
|
results: [
|
|
expect.objectContaining({
|
|
tool_call_id: 'ls',
|
|
status: 'success',
|
|
content: expect.stringContaining('src/'),
|
|
}),
|
|
expect.objectContaining({
|
|
tool_call_id: 'file',
|
|
status: 'success',
|
|
content: expect.stringContaining('const gravity = 0.6'),
|
|
}),
|
|
expect.objectContaining({
|
|
tool_call_id: 'chat',
|
|
status: 'success',
|
|
content: expect.stringContaining('2: 调整重力'),
|
|
}),
|
|
],
|
|
},
|
|
]);
|
|
expect(f.saveRequest).toHaveBeenCalledWith('cloud-request');
|
|
expect(text).toHaveBeenCalledWith('建议调整 src/game.ts:1 的重力。');
|
|
expect(f.progress).toHaveBeenCalledWith('正在读取项目与会话…');
|
|
});
|
|
|
|
it('rejects a stale context before reading or returning any project data', async () => {
|
|
const f = await fixture();
|
|
const transport: TeacherCloudTransport = {
|
|
events: vi.fn(),
|
|
json: vi.fn(async (url) => {
|
|
if (url === '/questions') return { request_id: 'question', run_id: 'one' };
|
|
if (url.endsWith('/cancel')) return { status: 'cancelled' };
|
|
return {
|
|
status: 'interrupted',
|
|
interrupt: {
|
|
source: 'client_read_tools',
|
|
context_id: 'another-question',
|
|
calls: [{ name: 'read_project_file', arguments: { path: 'src/game.ts' } }],
|
|
},
|
|
};
|
|
}),
|
|
};
|
|
const run = prepareCloudTeacher(
|
|
f.account,
|
|
f.topic,
|
|
requestId,
|
|
f.access,
|
|
f.progress,
|
|
f.saveRequest,
|
|
transport
|
|
);
|
|
await expect(
|
|
run.run([{ role: 'user', content: '检查代码' }], new AbortController().signal, vi.fn())
|
|
).rejects.toThrow('读取请求已失效');
|
|
expect(transport.json).not.toHaveBeenCalledWith(
|
|
expect.stringContaining('/tool-results'),
|
|
expect.anything(),
|
|
expect.anything()
|
|
);
|
|
expect(transport.json).toHaveBeenCalledWith(
|
|
'/questions/' + requestId + '/cancel',
|
|
{},
|
|
expect.anything()
|
|
);
|
|
});
|
|
|
|
it('follows a saved continuation without executing an old tool batch again', async () => {
|
|
const f = await fixture();
|
|
const transport: TeacherCloudTransport = {
|
|
events: vi.fn(),
|
|
json: vi.fn(async (url) => {
|
|
if (url === '/questions') return { request_id: 'question', run_id: 'one' };
|
|
if (url === '/runs/one') return { status: 'interrupted', continued_run_id: 'two' };
|
|
return { status: 'completed', output: '恢复后的回答' };
|
|
}),
|
|
};
|
|
const text = vi.fn();
|
|
await prepareCloudTeacher(
|
|
f.account,
|
|
f.topic,
|
|
requestId,
|
|
f.access,
|
|
f.progress,
|
|
f.saveRequest,
|
|
transport
|
|
).run([{ role: 'user', content: '继续' }], new AbortController().signal, text);
|
|
expect(text).toHaveBeenCalledWith('恢复后的回答');
|
|
expect(transport.json).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it('stops by the original question id even when submission acknowledgment is lost', async () => {
|
|
const f = await fixture();
|
|
const transport: TeacherCloudTransport = {
|
|
events: vi.fn(),
|
|
json: vi.fn(async (url) => {
|
|
if (url === '/questions') throw new TeacherError(502, 'connection', '连接中断');
|
|
return { status: 'cancelled' };
|
|
}),
|
|
};
|
|
await expect(
|
|
prepareCloudTeacher(
|
|
f.account,
|
|
f.topic,
|
|
requestId,
|
|
f.access,
|
|
f.progress,
|
|
f.saveRequest,
|
|
transport
|
|
).run([{ role: 'user', content: '检查' }], new AbortController().signal, vi.fn())
|
|
).rejects.toThrow('连接中断');
|
|
expect(transport.json).toHaveBeenCalledWith(
|
|
'/questions/' + requestId + '/cancel',
|
|
{},
|
|
expect.anything()
|
|
);
|
|
});
|
|
|
|
it('reconnects from the last cursor and fills the final message after an earlier assistant preamble', async () => {
|
|
const f = await fixture();
|
|
let reads = 0,
|
|
streams = 0;
|
|
const text: string[] = [];
|
|
const transport: TeacherCloudTransport = {
|
|
json: vi.fn(async (url) => {
|
|
if (url === '/questions') return { request_id: 'question', run_id: 'one' };
|
|
return ++reads < 3
|
|
? { status: 'running', thread_id: 'teacher-thread' }
|
|
: { status: 'completed', output: '最终建议' };
|
|
}),
|
|
events: vi.fn(async (url, _signal, accept) => {
|
|
const message = (id: string, content: string) => ({
|
|
thread_id: 'teacher-thread',
|
|
payload: { items: [{ stream_event: { type: 'message_delta', message_id: id, content } }] },
|
|
});
|
|
if (++streams === 1) {
|
|
accept('message', message('preamble', '我先检查。'), '1-0');
|
|
throw new Error('connection interrupted');
|
|
}
|
|
expect(url).toContain('after_seq=1-0');
|
|
accept('message', message('answer', '最终'), '2-0');
|
|
}),
|
|
};
|
|
await prepareCloudTeacher(
|
|
f.account,
|
|
f.topic,
|
|
requestId,
|
|
f.access,
|
|
f.progress,
|
|
f.saveRequest,
|
|
transport
|
|
).run([{ role: 'user', content: '检查' }], new AbortController().signal, (delta) =>
|
|
text.push(delta)
|
|
);
|
|
expect(text.join('')).toBe('我先检查。\n\n最终建议');
|
|
expect(f.progress).toHaveBeenCalledWith('连接中断,正在恢复老师回复…');
|
|
});
|
|
|
|
it('keeps only the cloud main-thread answer while advancing past child events on reconnect', async () => {
|
|
const f = await fixture();
|
|
let reads = 0;
|
|
let streams = 0;
|
|
const text: string[] = [];
|
|
const streamUrls: string[] = [];
|
|
const transport: TeacherCloudTransport = {
|
|
json: vi.fn(async (url) => {
|
|
if (url === '/questions') return { request_id: 'question', run_id: 'one' };
|
|
return ++reads < 3
|
|
? { status: 'running', thread_id: 'teacher-thread' }
|
|
: { status: 'completed', output: '老师最终答复' };
|
|
}),
|
|
events: vi.fn(async (url, _signal, accept) => {
|
|
streamUrls.push(url);
|
|
const message = (threadId: string, id: string, content: string) => ({
|
|
thread_id: threadId,
|
|
payload: { items: [{ stream_event: { type: 'message_delta', message_id: id, content } }] },
|
|
});
|
|
if (++streams === 1) {
|
|
accept('message', message('teacher-thread', 'answer', '老师'), '1-0');
|
|
accept('message', message('child-thread', 'child-answer', '子智能体内容'), '2-0');
|
|
throw new Error('connection interrupted');
|
|
}
|
|
accept(
|
|
'message',
|
|
{
|
|
thread_id: 'child-thread',
|
|
payload: {
|
|
chunk: {
|
|
stream_event: {
|
|
type: 'message_delta',
|
|
message_id: 'child-answer',
|
|
content: '子智能体后续内容',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
'3-0'
|
|
);
|
|
accept('message', message('teacher-thread', 'answer', '最终答复'), '4-0');
|
|
}),
|
|
};
|
|
await prepareCloudTeacher(
|
|
f.account,
|
|
f.topic,
|
|
requestId,
|
|
f.access,
|
|
f.progress,
|
|
f.saveRequest,
|
|
transport
|
|
).run([{ role: 'user', content: '检查' }], new AbortController().signal, (delta) =>
|
|
text.push(delta)
|
|
);
|
|
expect(text.join('')).toBe('老师最终答复');
|
|
expect(transport.events).toHaveBeenCalledTimes(2);
|
|
expect(streamUrls[1]).toContain('after_seq=2-0');
|
|
});
|