feat(consultation): unify server-distributed agents without fixed roles
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user