feat(consultation): unify server-distributed agents without fixed roles

This commit is contained in:
2026-09-24 11:56:42 +08:00
parent 248776d855
commit 8a157be4f6
35 changed files with 625 additions and 668 deletions

View File

@@ -6,10 +6,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { CodingTeacherService, type TeacherScope } from '../../electron/coding-teacher/service';
import { TeacherTopicStore } from '../../electron/coding-teacher/store';
import { compileTeacherContext, estimateTeacherTokens, sourceContext } from '../../electron/coding-teacher/context';
import { TEACHER_BEHAVIOR_PROMPT } from '../../electron/coding-teacher/behavior-prompt';
const TEACHER_BEHAVIOR_PROMPT = '你是麦洛的创作老师';
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 { parseTeacherSuggestions } from '../../electron/coding-teacher/suggestions';
import {
createCodingProjectStore,
@@ -21,7 +20,7 @@ import {
createCodingProjectConfigV2,
} from '../../electron/coding-projects/project-config';
import { InMemoryConversationRuntime } from '../../electron/coding-runtime/in-memory-conversation-runtime';
import type { ConsultationRole, TeacherDefinition, TeacherRequest, TeacherRequestIntent, TeacherSourceContext, TeacherTopic } from '../../shared/coding-teacher';
import type { LegacyConsultationRole, TeacherDefinition, TeacherRequest, TeacherRequestIntent, TeacherSourceContext, TeacherTopic } from '../../shared/coding-teacher';
import { TEACHER_CHECK_IN_INTERVAL_MS, TEACHER_UNCHANGED_CHECK_IN_INTERVAL_MS } from '../../shared/coding-teacher';
import * as teacherCloud from '../../electron/coding-teacher/config-client';
import * as teacherTransport from '../../electron/utils/proxy-fetch';
@@ -400,41 +399,27 @@ describe('cloud coding teacher', () => {
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'];
const roles: Array<LegacyConsultationRole | undefined> = [undefined, 'teacher'];
it('keeps teacher and friend histories and personas separate without running on open', async () => {
it('preserves old synthetic friend history read-only and creates only published agents', 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();
const scope = { projectId: f.scope.projectId, sourceId: 'project' };
const legacyScope = { ...scope, role: 'friend' as const };
const current = await f.service.create(scope);
const legacy = { ...current, id: '44444444-4444-4444-8444-444444444444', role: 'friend' as const,
definition: { ...current.definition, name: '小麦', teacher_id: 'coding-friend', system_prompt: '旧版本地人设' } };
const store = new TeacherTopicStore(path.join(f.created.project.path, '.makelore/friend-conversations', current.accountId, 'project'));
await store.save(legacy);
expect((await f.service.list(scope)).items).toEqual(expect.arrayContaining([
expect.objectContaining({ id: legacy.id, legacyRole: 'friend' }), expect.objectContaining({ id: current.id }),
]));
expect(await f.service.read(legacyScope, legacy.id)).toMatchObject({ role: 'friend', definition: legacy.definition });
await expect(f.service.create(legacyScope)).rejects.toMatchObject({ code: 'consultation_legacy_read_only' });
await expect(f.service.send(legacyScope, legacy.id, { requestId, text: '继续' })).rejects.toMatchObject({ code: 'consultation_legacy_read_only' });
expect(current.role).toBeUndefined();
expect(current.definition.system_prompt).toBe(definition.system_prompt);
expect(current.definition.skills).toEqual(definition.skills);
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 () => {
@@ -530,7 +515,7 @@ describe('project teacher and friend consultations', () => {
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 scope = { projectId: f.scope.projectId, sourceId: 'project' };
const topic = await f.service.create(scope);
let saved!: () => void;
let releaseSave!: () => void;
@@ -555,7 +540,7 @@ describe('project teacher and friend consultations', () => {
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'
f.created.project.path, '.makelore/teacher-conversations', topic.accountId, 'project', topic.id + '.json'
), 'utf8'));
expect(stored.requests[0].status).toBe('cancelled');
} finally {
@@ -564,14 +549,7 @@ describe('project teacher and friend consultations', () => {
}
});
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 contextual discussion entry points', () => {
@@ -731,12 +709,10 @@ describe('teacher contextual discussion entry points', () => {
it.each(['suggestions', 'guided-help'] as const)('rejects %s outside the project teacher before preparing a model', async (intent) => {
const f = await fixture();
const friendScope = { ...newScope(f), role: 'friend' as const };
const previewScope = { projectId: 'preview', sourceId: 'preview' };
const friend = await f.service.create(friendScope);
const legacy = await f.service.create(f.scope);
const preview = await f.service.create(previewScope, 2, '示例项目');
for (const [scope, topic] of [[friendScope, friend], [f.scope, legacy], [previewScope, preview]] as const) {
for (const [scope, topic] of [[f.scope, legacy], [previewScope, preview]] as const) {
await expect(f.service.send(scope, topic.id, { requestId, text: '老师,帮我看看', intent }))
.rejects.toMatchObject({ code: 'teacher_intent_invalid' });
expect((await f.service.read(scope, topic.id)).requests).toEqual([]);
@@ -833,9 +809,9 @@ describe('project teacher check-ins', () => {
expect(JSON.stringify(messages)).toContain('创建计数器');
expect(JSON.stringify(messages)).toContain('变量是什么意思');
expect(messages.at(-1)).toMatchObject({ role: 'system' });
expect(messages.at(-1).content).toContain('约 120 字');
expect(messages.at(-1).content).toContain('最多问一个问题');
expect(messages.at(-1).content).toContain('不能假装');
expect(messages.at(-1).content).toContain('保持简短');
expect(messages.at(-1).content).toContain('按照已配置的人设和职责');
expect(messages.at(-1).content).toContain('不声称实际运行');
expect(f.prepareModel).toHaveBeenLastCalledWith(expect.anything(), selected.definition, expect.objectContaining({
projectPath: f.created.project.path, source: context, assertCurrent: expect.any(Function),
}), { finalOnly: false });
@@ -858,7 +834,7 @@ describe('project teacher check-ins', () => {
expect(competing).toEqual({ topic: null, skipped: 'busy' });
expect(f.run).toHaveBeenCalledOnce();
expect((await f.service.list(scope)).items).toHaveLength(1);
expect((await f.service.list(scope)).items[0].title).toBe('和老师聊聊');
expect((await f.service.list(scope)).items[0].title).toBe('和智能体聊聊');
await f.service.create(scope);
expect((await f.service.checkIn(scope, input(f))).topic?.id).toBe(first.topic?.id);
await expect(f.service.checkIn(scope, { ...input(f), sourceConversationId: thirdRequestId }))
@@ -963,7 +939,7 @@ describe('project teacher check-ins', () => {
it('rejects friend, preview, legacy-source scopes and ordinary messages that try to bypass check-in guards', async () => {
const f = await fixture();
for (const scope of [{ ...projectScope(f), role: 'friend' as const }, { projectId: 'preview', sourceId: 'preview' }, f.scope]) {
for (const scope of [{ projectId: 'preview', sourceId: 'preview' }, f.scope]) {
await expect(f.service.checkIn(scope, input(f))).rejects.toMatchObject({ code: 'teacher_intent_invalid' });
}
const scope = projectScope(f);
@@ -1103,7 +1079,7 @@ describe('teacher context and wire contract', () => {
expect(text).toContain('通过问题引导思考');
expect(text).toContain('使用具体的小例子');
expect(text).toContain('count += 1');
expect(compiled.messages[0].content).toContain(TEACHER_BEHAVIOR_PROMPT);
expect(compiled.messages[0].content).not.toContain(TEACHER_BEHAVIOR_PROMPT);
expect(compiled.includedSourceMessageIds).toEqual(['source-user']);
expect(compiled.omittedMessages).toBe(1);
expect(() => compileTeacherContext(definition, context, [], 'x'.repeat(18000), [])).toThrow(
@@ -1255,7 +1231,7 @@ it('serves topic acceptance and SSE snapshots without cancelling on stream close
} 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) => {
it.each(['agent', 'teacher'] 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'), {
@@ -1271,14 +1247,14 @@ it.each(['teacher', 'friend'] as const)('routes project-level %s config and topi
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
try {
const config = await fetch(`${origin}/api/coding/${role}/config`);
const config = await fetch(`${origin}/api/coding/teacher/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);
expect((await config.json()).definition.teacher_id).toBe('coding-teacher');
expect((await post(`${origin}/api/coding/teacher/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 });
expect(topic).toMatchObject({ 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);
@@ -1291,7 +1267,7 @@ it.each(['teacher', 'friend'] as const)('routes project-level %s config and topi
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'));
await vi.waitFor(async () => expect((await f.service.read({ projectId: f.scope.projectId, sourceId: 'project' }, topic.id)).requests[0].status).toBe('cancelled'));
} finally {
server.closeAllConnections();
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
@@ -1368,7 +1344,7 @@ describe('structured teacher service integration', () => {
expect(runtime === 'yuxi' ? f.prepareCloud : f.prepareModel).toHaveBeenCalledOnce();
expect(runtime === 'yuxi' ? f.prepareModel : f.prepareCloud).not.toHaveBeenCalled();
const modelMessages = f.run.mock.calls[0][0] as Array<{ role: string; content: string }>;
expect(modelMessages[0].content).toContain(TEACHER_BEHAVIOR_PROMPT);
expect(modelMessages[0].content).not.toContain(TEACHER_BEHAVIOR_PROMPT);
expect(modelMessages.some(message => message.role === 'system' && message.content.includes('本轮界面协议'))).toBe(true);
expect(pending.requests[0].response).toBe('');
expect(pending.discussion).toBeUndefined();
@@ -1401,26 +1377,21 @@ describe('structured teacher service integration', () => {
describe('project consultations with selected cloud teachers', () => {
it.each(['local', 'yuxi'])('retains the friend persona without project tools with a %s teacher model', async (runtime) => {
it.each(['local', 'yuxi'])('keeps the published identity and project tools on the %s runtime', async (runtime) => {
const f = await fixture({ cloudTeacher: runtime === 'yuxi', mockCloud: runtime === 'yuxi' });
const scope = { ...f.scope, sourceId: 'project', role: 'friend' as const };
const scope = { ...f.scope, sourceId: 'project' };
const topic = await f.service.create(scope, undefined, undefined, runtime === 'yuxi' ? 9 : undefined);
expect(topic.definition).toMatchObject({ teacher_id: 'coding-friend', name: '小麦' });
if (runtime === 'yuxi') expect(topic.definition.runtime).toBe('yuxi');
await f.service.send(scope, topic.id, {
requestId: crypto.randomUUID(), text: '你觉得这个作品怎么样?', sourceConversationId: f.scope.sourceId,
});
const messages = f.run.mock.calls[0][0] as Array<{ role: string; content: string }>;
expect(messages[0].content).toContain('数字朋友');
expect(topic.role).toBeUndefined();
expect(topic.definition.teacher_id).toBe('coding-teacher');
await f.service.send(scope, topic.id, { requestId: crypto.randomUUID(), text: '你觉得怎么样?', sourceConversationId: f.scope.sourceId });
const messages = f.run.mock.calls[0][0];
expect(messages[0].content).not.toContain(TEACHER_BEHAVIOR_PROMPT);
expect(messages[0].content).toContain('本轮没有项目读取工具');
expect(messages[0].content).toContain('你可以通过只读工具');
if (runtime === 'yuxi') {
expect(f.prepareCloud.mock.calls[0][1].version).toBe(9);
expect(f.prepareModel).not.toHaveBeenCalled();
expect(f.prepareCloud).toHaveBeenCalledOnce();
} else {
expect(f.prepareModel).toHaveBeenCalledWith(expect.anything(), topic.definition, undefined, { finalOnly: false });
expect(f.prepareCloud).not.toHaveBeenCalled();
}
} else expect(f.prepareModel).toHaveBeenCalledWith(expect.anything(), topic.definition,
expect.objectContaining({ projectPath: f.created.project.path }), { finalOnly: false });
f.finish();
await vi.waitFor(async () => expect((await f.service.read(scope, topic.id)).requests[0].status).toBe('completed'));
});
@@ -1435,8 +1406,8 @@ describe('project consultations with selected cloud teachers', () => {
expect(result.topic?.id).toBe(topic.id);
expect(result.topic?.requests[0]).toMatchObject({ intent: 'check-in', sourceConversationId: f.scope.sourceId });
const messages = f.run.mock.calls[0][0] as Array<{ role: string; content: string }>;
expect(messages.some(message => message.role === 'system' && message.content.includes('不是学生提问'))).toBe(true);
expect(messages[0].content).toContain(TEACHER_BEHAVIOR_PROMPT);
expect(messages.some(message => message.role === 'system' && message.content.includes('不是用户提问'))).toBe(true);
expect(messages[0].content).not.toContain(TEACHER_BEHAVIOR_PROMPT);
expect(f.prepareCloud.mock.calls[0][3]).toMatchObject({ projectPath: f.created.project.path, source: context });
expect(f.prepareModel).not.toHaveBeenCalled();
f.finish();