Integrate project classroom with teacher and friend consultations

This commit is contained in:
鲨鱼辣椒
2026-09-22 15:50:18 +08:00
parent e5d271bc45
commit 68e676cb62
28 changed files with 957 additions and 314 deletions

View File

@@ -3,11 +3,12 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { CodingTeacherService } from '../../electron/coding-teacher/service';
import { CodingTeacherService, type TeacherScope } from '../../electron/coding-teacher/service';
import { TeacherTopicStore } from '../../electron/coding-teacher/store';
import { compileTeacherContext, sourceContext } from '../../electron/coding-teacher/context';
import { streamTeacherReply } from '../../electron/coding-teacher/model-runner';
import { TeacherError } from '../../electron/coding-teacher/config-client';
import { consultationDefinition } from '../../electron/coding-teacher/consultation-role';
import {
createCodingProjectStore,
createMemoryCodingProjectStorage,
@@ -18,7 +19,7 @@ import {
createCodingProjectConfigV2,
} from '../../electron/coding-projects/project-config';
import { InMemoryConversationRuntime } from '../../electron/coding-runtime/in-memory-conversation-runtime';
import type { TeacherDefinition, TeacherSourceContext } from '../../shared/coding-teacher';
import type { ConsultationRole, TeacherDefinition, TeacherSourceContext } from '../../shared/coding-teacher';
import type { ConversationSnapshot } from '../../shared/coding-conversation-contracts';
import { parseNianCodeDeepLinkUrl } from '../../electron/main/app-deep-link';
@@ -85,10 +86,12 @@ async function fixture() {
});
return { inputTokens: 20, outputTokens: 10 };
});
const account = {
let account = {
id: '11111111-1111-4111-8111-111111111111',
binding: { accountKey: 'test', epoch: 1 },
};
const readSource = vi.fn(async (_scope: TeacherScope) => structuredClone(context));
const prepareModel = vi.fn(async () => ({ inputLimit: 8000, run }));
const service = new CodingTeacherService({
projects,
runtime: new InMemoryConversationRuntime(),
@@ -106,8 +109,8 @@ async function fixture() {
if (revision !== 2) throw new TeacherError(409, 'teacher_draft_changed', '草稿变化');
return { draft_revision: revision, payload: definition };
},
readSource: async () => structuredClone(context),
prepareModel: async () => ({ inputLimit: 8000, run }),
readSource,
prepareModel,
});
services.push(service);
return {
@@ -117,6 +120,8 @@ async function fixture() {
scope,
service,
run,
readSource,
prepareModel,
finish: () => finish(),
disable: () => {
enabled = false;
@@ -127,6 +132,12 @@ async function fixture() {
switchAccount: () => {
accountCurrent = false;
},
useOtherAccount: () => {
account = {
id: '99999999-9999-4999-8999-999999999999',
binding: { accountKey: 'another-account', epoch: 2 },
};
},
};
}
describe('cloud coding teacher', () => {
@@ -270,6 +281,183 @@ describe('cloud coding teacher', () => {
expect(f.run).not.toHaveBeenCalled();
});
});
describe('project teacher and friend consultations', () => {
const requestId = '22222222-2222-4222-8222-222222222222';
const nextRequestId = '33333333-3333-4333-8333-333333333333';
const roles: ConsultationRole[] = ['teacher', 'friend'];
it('keeps teacher and friend histories and personas separate without running on open', async () => {
const f = await fixture();
const teacherScope = { projectId: f.scope.projectId, sourceId: 'project', role: 'teacher' as const };
const friendScope = { ...teacherScope, role: 'friend' as const };
const teacher = await f.service.create(teacherScope);
const friend = await f.service.create(friendScope);
expect((await f.service.list(teacherScope)).items.map((item) => item.id)).toEqual([teacher.id]);
expect((await f.service.list(friendScope)).items.map((item) => item.id)).toEqual([friend.id]);
expect((await f.service.read(teacherScope, teacher.id)).role).toBe('teacher');
const onSnapshot = vi.fn();
const unsubscribe = await f.service.subscribe(friendScope, friend.id, onSnapshot);
expect(onSnapshot).toHaveBeenCalledOnce();
unsubscribe();
expect(f.readSource).not.toHaveBeenCalled();
expect(f.prepareModel).not.toHaveBeenCalled();
expect(f.run).not.toHaveBeenCalled();
await expect(f.service.read(friendScope, teacher.id)).rejects.toMatchObject({ code: 'teacher_topic_not_found' });
await expect(f.service.read(teacherScope, friend.id)).rejects.toMatchObject({ code: 'teacher_topic_not_found' });
await f.service.send(teacherScope, teacher.id, { requestId, text: '我应该怎么想?' });
await f.service.send(friendScope, friend.id, { requestId, text: '你有什么感受?' });
const teacherPrompt = f.run.mock.calls[0][0][0].content;
const friendPrompt = f.run.mock.calls[1][0][0].content;
expect(teacherPrompt).toContain('引导思考');
expect(teacherPrompt).toContain('使用具体的小例子');
expect(friendPrompt).toContain('数字朋友');
expect(friendPrompt).toContain('不要假装运行、试玩');
expect(friendPrompt).not.toContain('使用具体的小例子');
expect(friendPrompt).not.toContain('你是编程老师');
expect(friend.definition.model).toEqual(teacher.definition.model);
expect(friend.version).toBe(teacher.version);
expect((await f.service.read(teacherScope, teacher.id)).requests[0].text).toBe('我应该怎么想?');
expect((await f.service.read(friendScope, friend.id)).requests[0].text).toBe('你有什么感受?');
});
it('selects the current operation conversation per request and deduplicates retries by source', async () => {
const f = await fixture();
const scope = { projectId: f.scope.projectId, sourceId: 'project' };
const other = await f.projects.conversationStore(f.created.project.path).create({
agentId: f.created.config.defaultAgentId!, title: '另一个操作对话', model: null, modelResolution: 'required',
});
f.readSource.mockImplementation(async (selected) => ({
...context,
messages: [{ id: selected.sourceId, role: 'user', text: selected.sourceId === f.scope.sourceId ? '第一段操作' : '第二段操作' }],
}));
const topic = await f.service.create(scope);
const input = { requestId, text: '我这样理解对吗?', sourceConversationId: f.scope.sourceId };
await Promise.all([f.service.send(scope, topic.id, input), f.service.send(scope, topic.id, input)]);
expect(f.run).toHaveBeenCalledOnce();
expect(f.readSource).toHaveBeenCalledWith({ ...scope, sourceId: f.scope.sourceId });
await expect(f.service.send(scope, topic.id, { ...input, sourceConversationId: other.id }))
.rejects.toMatchObject({ code: 'teacher_request_conflict' });
let complete!: () => void;
const completed = new Promise<void>((resolve) => { complete = resolve; });
const unsubscribe = await f.service.subscribe(scope, topic.id, (snapshot) => {
if (snapshot.requests[0].status === 'completed') complete();
});
f.finish();
await completed;
unsubscribe();
await f.service.send(scope, topic.id, { ...input, requestId: nextRequestId, sourceConversationId: other.id });
const latest = await f.service.read(scope, topic.id);
expect(latest.requests.map((request) => request.sourceConversationId)).toEqual([f.scope.sourceId, other.id]);
expect(latest.requests[1].includedSourceMessageIds).toEqual([other.id]);
expect(JSON.stringify(f.run.mock.calls[1][0])).toContain('第二段操作');
expect(JSON.stringify(f.run.mock.calls[1][0])).not.toContain('第一段操作');
expect(latest.id).toBe(topic.id);
});
it.each(roles)('rejects cross-project sources and isolates %s topics by project and account', async (role) => {
const f = await fixture();
const scope = { projectId: f.scope.projectId, sourceId: 'project', role };
const topic = await f.service.create(scope);
const other = await f.projects.createProject({ projectPath: path.join(f.root, 'other-project'), identity: { kind: 'create' } });
const foreign = await f.projects.conversationStore(other.project.path).create({
agentId: other.config.defaultAgentId!, title: '外部对话', model: null, modelResolution: 'required',
});
await expect(f.service.send(scope, topic.id, { requestId, text: '帮我理解', sourceConversationId: foreign.id }))
.rejects.toMatchObject({ code: 'teacher_source_not_found' });
expect(f.readSource).not.toHaveBeenCalled();
expect(f.prepareModel).not.toHaveBeenCalled();
expect((await f.service.read(scope, topic.id)).requests).toEqual([]);
const otherScope = { ...scope, projectId: other.project.id };
expect((await f.service.list(otherScope)).items).toEqual([]);
await expect(f.service.read(otherScope, topic.id)).rejects.toMatchObject({ code: 'teacher_topic_not_found' });
f.useOtherAccount();
expect((await f.service.list(scope)).items).toEqual([]);
await expect(f.service.read(scope, topic.id)).rejects.toMatchObject({ code: 'teacher_topic_not_found' });
expect(f.run).not.toHaveBeenCalled();
});
it.each(roles)('cancels an active %s reply when its operation source is deleted, preserving project history', async (role) => {
const f = await fixture();
const scope = { projectId: f.scope.projectId, sourceId: 'project', role };
const topic = await f.service.create(scope);
await f.service.send(scope, topic.id, { requestId, text: '问题', sourceConversationId: f.scope.sourceId });
await f.service.removeSource(f.scope.projectId, f.scope.sourceId);
const saved = await f.service.read(scope, topic.id);
expect(saved.requests[0]).toMatchObject({ status: 'cancelled', response: '计数器保存一个数字。' });
expect((await f.service.list(scope)).items.map((item) => item.id)).toEqual([topic.id]);
await expect(f.service.send(scope, topic.id, { requestId: nextRequestId, text: '再问一次', sourceConversationId: f.scope.sourceId }))
.rejects.toMatchObject({ code: 'teacher_source_not_found' });
expect(f.run).toHaveBeenCalledOnce();
});
it.each(roles)('does not dispatch a preparing %s request after its source is deleted', async (role) => {
const f = await fixture();
const scope = { projectId: f.scope.projectId, sourceId: 'project', role };
const topic = await f.service.create(scope);
let releasePreparation!: () => void;
f.prepareModel.mockImplementationOnce(async () => {
await new Promise<void>((resolve) => { releasePreparation = resolve; });
return { inputLimit: 8000, run: f.run };
});
const sending = f.service.send(scope, topic.id, { requestId, text: '问题', sourceConversationId: f.scope.sourceId });
const rejected = expect(sending).rejects.toMatchObject({ code: 'teacher_source_not_found' });
await vi.waitFor(() => expect(f.prepareModel).toHaveBeenCalledOnce());
const deleting = f.service.removeSource(f.scope.projectId, f.scope.sourceId);
await vi.waitFor(async () => expect(f.service.list(f.scope)).rejects.toMatchObject({ code: 'teacher_source_not_found' }));
releasePreparation();
await rejected;
await deleting;
expect(f.run).not.toHaveBeenCalled();
expect((await f.service.read(scope, topic.id)).requests).toEqual([]);
});
it('settles a persisted project request as cancelled if deletion races its initial save', async () => {
const f = await fixture();
const scope = { projectId: f.scope.projectId, sourceId: 'project', role: 'friend' as const };
const topic = await f.service.create(scope);
let saved!: () => void;
let releaseSave!: () => void;
const savedToDisk = new Promise<void>((resolve) => { saved = resolve; });
const allowSaveReturn = new Promise<void>((resolve) => { releaseSave = resolve; });
const originalSave = TeacherTopicStore.prototype.save;
const spy = vi.spyOn(TeacherTopicStore.prototype, 'save').mockImplementation(async function (value) {
await originalSave.call(this, value);
if (value.id === topic.id && value.requests[0]?.status === 'preparing') {
saved();
await allowSaveReturn;
}
});
try {
const sending = f.service.send(scope, topic.id, { requestId, text: '问题', sourceConversationId: f.scope.sourceId });
await savedToDisk;
const deleting = f.service.removeSource(f.scope.projectId, f.scope.sourceId);
await vi.waitFor(async () => expect(f.service.list(f.scope)).rejects.toMatchObject({ code: 'teacher_source_not_found' }));
releaseSave();
await sending;
await deleting;
expect(f.run).not.toHaveBeenCalled();
expect((await f.service.read(scope, topic.id)).requests[0].status).toBe('cancelled');
const stored = JSON.parse(await readFile(path.join(
f.created.project.path, '.makelore/friend-conversations', topic.accountId, 'project', topic.id + '.json'
), 'utf8'));
expect(stored.requests[0].status).toBe('cancelled');
} finally {
releaseSave();
spy.mockRestore();
}
});
it('derives friend configuration without mutating published teacher prompts or skills', () => {
const original = structuredClone(definition);
const friend = consultationDefinition(definition, 'friend');
expect(friend.teacher_id).toBe('coding-friend');
expect(friend.skills).toEqual([]);
expect(definition).toEqual(original);
expect(consultationDefinition(definition, 'teacher')).toBe(definition);
});
});
describe('teacher context and wire contract', () => {
it('takes only complete user/assistant text and preserves the read cursor', () => {
const snapshot = {
@@ -435,3 +623,46 @@ it('serves topic acceptance and SSE snapshots without cancelling on stream close
expect((await fetch(origin+'/api/coding/teacher/config',{method:'POST'})).status).toBe(405);
} finally {server.closeAllConnections();await new Promise<void>((resolve,reject)=>server.close(error=>error?reject(error):resolve()));}
});
it.each(['teacher', 'friend'] as const)('routes project-level %s config and topic messages without a source-scoped URL', async (role) => {
const f = await fixture();
const server = createServer((req, res) => {
void handleCodingTeacherRoutes(req, res, new URL(req.url!, 'http://localhost'), {
codingProducts: { teacher: f.service },
} as HostApiContext);
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
if (!address || typeof address === 'string') throw new Error('no address');
const origin = 'http://127.0.0.1:' + address.port;
const base = `${origin}/api/coding/projects/${f.scope.projectId}/${role}-topics`;
const post = (url: string, body = {}) => fetch(url, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
try {
const config = await fetch(`${origin}/api/coding/${role}/config`);
expect(config.status).toBe(200);
expect((await config.json()).definition.teacher_id).toBe('coding-' + role);
expect((await post(`${origin}/api/coding/${role}/config`)).status).toBe(405);
const created = await post(base);
expect(created.status).toBe(201);
const topic = await created.json();
expect(topic).toMatchObject({ role, sourceConversationId: 'project', projectId: f.scope.projectId });
const listed = await fetch(base);
expect((await listed.json()).items.map((item: { id: string }) => item.id)).toEqual([topic.id]);
expect((await fetch(`${base}/${topic.id}`)).status).toBe(200);
expect(f.run).not.toHaveBeenCalled();
const requestId = '22222222-2222-4222-8222-222222222222';
const input = { requestId, text: '我想聊聊', sourceConversationId: f.scope.sourceId };
expect((await post(`${base}/${topic.id}/messages`, input)).status).toBe(202);
expect((await post(`${base}/${topic.id}/messages`, input)).status).toBe(202);
expect(f.run).toHaveBeenCalledOnce();
const accepted = await fetch(`${base}/${topic.id}`);
expect((await accepted.json()).requests[0].sourceConversationId).toBe(f.scope.sourceId);
expect((await post(`${base}/${topic.id}/requests/${requestId}/cancel`)).status).toBe(200);
await vi.waitFor(async () => expect((await f.service.read({ projectId: f.scope.projectId, sourceId: 'project', role }, topic.id)).requests[0].status).toBe('cancelled'));
} finally {
server.closeAllConnections();
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
}
});