Integrate project classroom with teacher and friend consultations
This commit is contained in:
@@ -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()));
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user