Merge remote main preserving project chats and reply recovery
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled

This commit is contained in:
2026-09-28 20:51:33 +08:00
54 changed files with 3189 additions and 1713 deletions

View File

@@ -9,7 +9,8 @@ import { compileTeacherContext, estimateTeacherTokens, sourceContext } from '../
const TEACHER_BEHAVIOR_PROMPT = '你是麦洛的创作老师';
import { streamTeacherReply } from '../../electron/coding-teacher/model-runner';
import { TeacherError } from '../../electron/coding-teacher/config-client';
import { parseTeacherSuggestions } from '../../electron/coding-teacher/suggestions';
import { teacherReplyInstructions } from '../../electron/coding-teacher/reply';
import { createTeacherReadTools, type TeacherReadAccess } from '../../electron/coding-teacher/read-tools';
import {
createCodingProjectStore,
createMemoryCodingProjectStorage,
@@ -81,6 +82,7 @@ async function fixture({ durableSource = false, sourceContext = context, liveMod
const scope = { projectId: created.project.id, sourceId: source.id };
let enabled = true,
version = 1,
cloudVersion = 9,
accountCurrent = true;
let finish: () => void = () => undefined;
let reply = '计数器保存一个数字。';
@@ -108,7 +110,7 @@ async function fixture({ durableSource = false, sourceContext = context, liveMod
if (!accountCurrent) throw new TeacherError(401, 'teacher_account_changed', '账号变化');
},
availability: async () => ({ enabled, published_version: version, revision: version }),
catalog: cloudTeacher ? async () => ({ items: [{ teacher_id: 'cloud-teacher', version: 9, is_default: true,
catalog: cloudTeacher ? async () => ({ items: [{ teacher_id: 'cloud-teacher', version: cloudVersion, is_default: true,
definition: { ...definition, config_id: 'cloud-teacher', runtime: 'yuxi' as const, system_prompt: '', skills: [],
yuxi: { agent_slug: 'teacher', agent_version: 2 } } }] }) : undefined,
version: async (_account, v) => ({
@@ -149,6 +151,7 @@ async function fixture({ durableSource = false, sourceContext = context, liveMod
nextVersion: () => {
version++;
},
publishCloudVersion: (next: number) => { cloudVersion = next; },
switchAccount: () => {
accountCurrent = false;
},
@@ -314,7 +317,7 @@ describe('cloud coding teacher', () => {
await vi.waitFor(async () =>
expect((await f.service.read(f.scope, topic.id)).requests[0]).toMatchObject({
status: 'cancelled',
response: '计数器保存一个数字。',
response: '', unparsedResponse: '计数器保存一个数字。',
})
);
});
@@ -488,7 +491,7 @@ describe('project teacher and friend consultations', () => {
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(saved.requests[0]).toMatchObject({ status: 'cancelled', response: '', unparsedResponse: '计数器保存一个数字。' });
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' });
@@ -587,7 +590,7 @@ describe('teacher contextual discussion entry points', () => {
expect(accepted.requests[0]).toMatchObject({ intent: 'suggestions', status: 'running' });
expect(accepted.requests[0].suggestedQuestions).toBeUndefined();
expect(JSON.stringify(f.run.mock.calls[0][0])).toContain('创建计数器');
expect(JSON.stringify(f.run.mock.calls[0][0])).toContain('只返回 JSON 对象');
expect(f.run.mock.calls[0][0]).toContainEqual({ role: 'system', content: teacherReplyInstructions() });
const completed = await finishRequest(f, scope, topic.id);
expect(completed.requests[0]).toMatchObject({
intent: 'suggestions', text: '老师,帮我看看', status: 'completed', response: intro, suggestedQuestions: questions,
@@ -609,21 +612,42 @@ describe('teacher contextual discussion entry points', () => {
expect(completed.definition.system_prompt).toBe(definition.system_prompt);
});
it.each(['not JSON', JSON.stringify({ intro, questions: ['只有一个问题?'] })])('fails malformed suggestions without displaying raw output or losing usage: %s', async (invalid) => {
it.each([
['只有正文也可以。', '只有正文也可以。', []],
[JSON.stringify({ intro, questions: ['【我想聊】先说说数字怎么变。'] }), intro, ['【我想聊】先说说数字怎么变。']],
])('accepts configured prose or one quick reply without old count rules: %s', async (raw, reply, quickReplies) => {
const f = await fixture();
const scope = newScope(f);
const topic = await f.service.create(scope);
f.replyWith(invalid);
f.replyWith(raw as string);
await f.service.send(scope, topic.id, { requestId, intent: 'suggestions', text: '老师,帮我看看' });
const completed = await finishRequest(f, scope, topic.id);
expect(completed.requests[0]).toMatchObject({ status: 'failed', response: '', usage: { inputTokens: 20, outputTokens: 10 } });
expect(completed.requests[0].suggestedQuestions).toBeUndefined();
expect(completed.requests[0].error).toContain('请再试一次');
expect(completed.requests[0]).toMatchObject({
status: 'completed', response: reply, suggestedQuestions: quickReplies,
usage: { inputTokens: 20, outputTokens: 10 },
});
});
it.each([false, true])('persists prose and exact raw diagnostics instead of excess new shortcuts (cloud=%s)', async (cloudTeacher) => {
const f = await fixture(cloudTeacher ? { cloudTeacher: true, mockCloud: true } : {});
const scope = newScope(f);
const topic = await f.service.create(scope);
const reply = '我们先看看规则会怎样影响结局。';
const quickReplies = ['看懂规则|现在是什么规则?', '想想影响|摸头会改变什么?', '试试结果|怎样验证?', '换个话题|先聊别的。'];
const raw = JSON.stringify({ reply, quickReplies }, null, 2);
f.replyWith(raw);
await f.service.send(scope, topic.id, { requestId, intent: 'suggestions', text: '帮我看看' });
const completed = await finishRequest(f, scope, topic.id);
expect(completed.requests[0]).toMatchObject({
status: 'completed', response: reply, suggestedQuestions: [],
replyParseError: expect.stringContaining('最多 3 条'), unparsedResponse: raw,
});
const disk = JSON.parse(await readFile(path.join(
f.created.project.path, '.makelore/teacher-conversations', topic.accountId, 'project', topic.id + '.json'
), 'utf8'));
expect(disk.requests[0].response).toBe('');
expect(disk.requests[0].suggestedQuestions).toBeUndefined();
expect(disk.requests[0]).toMatchObject({
response: reply, suggestedQuestions: [], replyParseError: completed.requests[0].replyParseError, unparsedResponse: raw,
});
});
it('does not treat a cancelled response as valid suggestions even when its JSON is complete', async () => {
@@ -640,6 +664,24 @@ describe('teacher contextual discussion entry points', () => {
});
});
it('retains received bytes when a cancelled runner resolves instead of throwing', async () => {
const f = await fixture();
const scope = newScope(f);
const topic = await f.service.create(scope);
f.run.mockImplementationOnce(async (_messages, signal, onText) => {
onText(response);
await new Promise<void>(resolve => signal.addEventListener('abort', () => resolve(), { once: true }));
return { inputTokens: 20, outputTokens: 10 };
});
await f.service.send(scope, topic.id, { requestId, intent: 'suggestions', text: '帮我看看' });
await f.service.cancel(scope, topic.id, requestId);
await vi.waitFor(async () => expect((await f.service.read(scope, topic.id)).requests[0]).toMatchObject({
status: 'cancelled', response: '', unparsedResponse: response,
usage: { inputTokens: 20, outputTokens: 10 },
}));
expect((await f.service.read(scope, topic.id)).requests[0].suggestedQuestions).toBeUndefined();
});
it('does not parse suggestions after a failed stream or account change', async () => {
const f = await fixture();
const scope = newScope(f);
@@ -678,7 +720,7 @@ describe('teacher contextual discussion entry points', () => {
expect(saved.requests[0].suggestedQuestions).toBeUndefined();
});
it('adds a one-question guided opening for this round without changing the teacher system instructions', async () => {
it('marks the guided-help entry without choosing a teaching strategy or changing the system instructions', async () => {
const f = await fixture();
const scope = newScope(f);
const topic = await f.service.create(scope);
@@ -688,9 +730,9 @@ describe('teacher contextual discussion entry points', () => {
expect(completed.requests[0]).toMatchObject({
intent: 'guided-help', status: 'completed', response: '先想一个人:你希望谁来用你的计数器?',
});
expect(completed.requests[0].suggestedQuestions).toBeUndefined();
expect(completed.requests[0].suggestedQuestions).toEqual([]);
const messages = f.run.mock.calls[0][0];
expect(messages.at(-1).content).toContain('只发起一个具体、容易回答的交流起点');
expect(messages.at(-1).content).toContain('学生主动求助,表示暂时说不清想问什么');
expect(messages[0]).toEqual(compileTeacherContext(definition, context, [], '问题', [], undefined, 'question', undefined, true).messages[0]);
});
@@ -735,39 +777,30 @@ describe('teacher contextual discussion entry points', () => {
expect(f.run).not.toHaveBeenCalled();
});
it('budgets the round-specific instructions and honestly starts from ideas with no source', () => {
it('budgets the entry facts even when no source conversation is available', () => {
const empty = { ...context, messages: [] };
const ordinary = compileTeacherContext(definition, empty, [], '老师,帮我看看', []);
const suggestions = compileTeacherContext(definition, empty, [], '老师,帮我看看', [], 8000, 'suggestions');
expect(suggestions.messages[0]).toEqual(ordinary.messages[0]);
expect(suggestions.messages.at(-1)?.content).toContain('没有可用上下文时');
expect(suggestions.messages.at(-1)?.content).toContain('不要编造');
expect(suggestions.messages.at(-1)?.content).toContain('学生通过“帮我看看”主动请求帮助');
expect(suggestions.messages.some(message => message.content.startsWith('来源编程会话'))).toBe(false);
expect(() => compileTeacherContext(definition, empty, [], '老师,帮我看看', [], estimateTeacherTokens(ordinary.messages), 'suggestions'))
.toThrow('超过上下文预算');
});
it('trims and deduplicates valid suggestions, accepting plain JSON and standard JSON code fences', () => {
const raw = JSON.stringify({ intro: ' ' + intro + ' ', questions: [' ' + questions[0], questions[1], questions[0] + ' '] });
expect(parseTeacherSuggestions(raw)).toEqual({ intro, questions });
expect(parseTeacherSuggestions('```json\n' + raw + '\n```')).toEqual({ intro, questions });
expect(parseTeacherSuggestions('```\n' + raw + '\n```')).toEqual({ intro, questions });
it.each(['suggestions', 'guided-help'] as const)('passes %s entry facts without local teaching or expression policy', (intent) => {
const selected = { ...definition, system_prompt: '云端配置:正文长度随内容,选项保留【我想聊】前缀。' };
const compiled = compileTeacherContext(selected, context, [], '帮我看看', [], 8000, intent, teacherReplyInstructions());
const current = compiled.messages.at(-1)!.content;
expect(current).toContain(intent === 'suggestions'
? '学生通过“帮我看看”主动请求帮助' : '学生主动求助,表示暂时说不清想问什么');
expect(compiled.messages[0].content).toContain(selected.system_prompt);
const local = compiled.messages.slice(1).map(message => message.content).join('\n');
for (const removed of ['400 字', '120 字', '2–3', '简短中文', '学生口吻', 'Alice', '组件', '先这些',
'推荐一个具体切入点', '说明为何', '快捷回复围绕', '从构思切入', '整理想法', '理解关系', '承接已确认的共识'])
expect(local).not.toContain(removed);
});
it.each([
null,
[],
{ intro: '', questions },
{ intro: ' '.repeat(3), questions },
{ intro: '字'.repeat(401), questions },
{ intro, questions: [questions[0]] },
{ intro, questions: [...questions, '第三个?', '第四个?'] },
{ intro, questions: [questions[0], questions[0]] },
{ intro, questions: [questions[0], ''] },
{ intro, questions: [questions[0], '字'.repeat(121)] },
{ intro, questions: [questions[0], 1] },
])('rejects suggestion responses that cannot become two or three short questions: %j', (value) => {
expect(() => parseTeacherSuggestions(JSON.stringify(value))).toThrow('请再试一次');
});
});
describe('project teacher check-ins', () => {
@@ -812,9 +845,8 @@ 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('保持简短');
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 });
@@ -1279,7 +1311,172 @@ it.each(['agent', 'teacher'] as const)('routes project-level %s config and topic
describe('structured teacher service integration', () => {
it.each(['suggestions', 'discussion'] as const)('parses only final %s JSON after the real local runner reads a project file', async (format) => {
it.each(['discussion-v1', undefined] as const)('rotates a legacy %s cloud checkpoint within the same visible conversation', async (presentation) => {
const f = await fixture({ cloudTeacher: true, mockCloud: true });
const agentId = 'cloud-teacher';
const input = { projectId: f.scope.projectId, sourceConversationId: f.scope.sourceId,
requestId: crypto.randomUUID(), text: '看看数字如何改变' };
const reply = '已确认的旧正文:数字会随着玩家操作改变。';
const quickReplies = ['看看改变的时机'];
f.replyWith(JSON.stringify({ reply, quickReplies }));
await f.service.sendConversation(agentId, input);
f.finish();
await vi.waitFor(async () => expect((await f.service.conversation(f.scope.projectId, agentId)).topic?.requests[0].status).toBe('completed'));
const original = (await f.service.conversation(f.scope.projectId, agentId)).topic!;
await f.service.dispose();
const turnFile = path.join(f.root, 'agent-conversations', original.accountId, agentId, 'projects', f.scope.projectId, 'turns', input.requestId + '.json');
const archived = { ...original.requests[0], presentation,
discussionContext: { focusId: 'RETIRED_CHECKPOINT_CONTEXT' },
discussionSnapshot: { kind: 'ideas', title: 'RETIRED_CHECKPOINT_COMPONENT' } };
await writeFile(turnFile, JSON.stringify(archived));
const service = await f.restart();
await service.sendConversation(agentId, { ...input, requestId: crypto.randomUUID(), text: '继续看看' });
const migrated = (await service.conversation(f.scope.projectId, agentId)).topic!;
expect(migrated.id).toBe(original.id);
expect(migrated.conversation?.segmentTurns).toBe(1);
expect(migrated.requests[1]).toMatchObject({ projectId: input.projectId,
sourceConversationId: input.sourceConversationId, teacherVersion: 9, presentation: 'reply-v1' });
expect(migrated.requests[1].runtimeThreadId).toBeTruthy();
expect(migrated.requests[1].runtimeThreadId).not.toBe(original.requests[0].runtimeThreadId);
const bootstrap = JSON.stringify(f.run.mock.calls[1][0]);
expect(bootstrap).toContain(reply);
expect(bootstrap).toContain(quickReplies[0]);
expect(bootstrap).not.toContain('RETIRED_CHECKPOINT_');
expect(f.run.mock.calls[1][0]).toContainEqual({ role: 'system', content: teacherReplyInstructions() });
f.finish();
await vi.waitFor(async () => expect((await service.conversation(f.scope.projectId, agentId)).topic?.requests[1].status).toBe('completed'));
await service.sendConversation(agentId, { ...input, requestId: crypto.randomUUID(), text: '再往下聊' });
const continued = (await service.conversation(f.scope.projectId, agentId)).topic!;
expect(continued.id).toBe(original.id);
expect(continued.conversation?.segmentTurns).toBe(2);
expect(continued.requests[2].runtimeThreadId).toBe(migrated.requests[1].runtimeThreadId);
expect(JSON.stringify(f.run.mock.calls[2][0])).not.toContain(reply);
expect(continued.requests[0]).toEqual(archived);
expect(JSON.parse(await readFile(turnFile, 'utf8'))).toEqual(JSON.parse(JSON.stringify(archived)));
f.finish();
await vi.waitFor(async () => expect((await service.conversation(f.scope.projectId, agentId)).topic?.requests[2].status).toBe('completed'));
expect((await service.conversation(f.scope.projectId, agentId)).topic?.requests[2].usage).toEqual({ inputTokens: 20, outputTokens: 10 });
});
it('keeps continuous chat scopes, releases and usage while archived components remain inert', async () => {
const f = await fixture({ cloudTeacher: true, mockCloud: true });
const agentId = 'cloud-teacher';
const firstInput = { projectId: f.scope.projectId, sourceConversationId: f.scope.sourceId,
requestId: crypto.randomUUID(), text: '接着看看数字改变的时机' };
const reply = '先看数字改变前后,玩家能收到什么反馈。';
const quickReplies = ['【想试试】先观察数字变化', '【还不确定】说说我期待的反馈'];
f.replyWith(JSON.stringify({ reply, quickReplies, tool: { kind: 'flow', title: 'DISCARDED_COMPONENT_OUTPUT' } }));
await f.service.sendConversation(agentId, firstInput);
f.finish();
await vi.waitFor(async () => expect((await f.service.conversation(f.scope.projectId, agentId)).topic?.requests[0].status).toBe('completed'));
const first = (await f.service.conversation(f.scope.projectId, agentId)).topic!;
expect(first.requests[0]).toMatchObject({ id: firstInput.requestId, text: firstInput.text,
projectId: firstInput.projectId, sourceConversationId: firstInput.sourceConversationId,
projectName: f.created.project.name, teacherVersion: 9, presentation: 'reply-v1', response: reply,
suggestedQuestions: quickReplies, usage: { inputTokens: 20, outputTokens: 10 } });
expect(first.requests[0].runtimeThreadId).toBeTruthy();
expect(first.requests[0]).not.toHaveProperty('discussionSnapshot');
await f.service.dispose();
// Simulate a continuous conversation saved by the pre-cleanup release. Its
// top-level and per-project component archives must not overwrite each other.
const directory = path.join(f.root, 'agent-conversations', first.accountId, agentId, 'projects', f.scope.projectId);
const manifestFile = path.join(directory, 'conversation.json');
const manifest = JSON.parse(await readFile(manifestFile, 'utf8'));
const legacy = { id: 'old-component', revision: -1, status: 'active', content: { kind: 'ideas', title: 'ARCHIVED_COMPONENT_TOPIC' } };
const projectsArchive = { [f.scope.projectId]: { content: 'ARCHIVED_COMPONENT_PROJECT', previousIdeas: ['unchanged'] } };
manifest.topic.discussion = legacy;
manifest.topic.conversation.discussions = projectsArchive;
await writeFile(manifestFile, JSON.stringify(manifest));
const turnFile = path.join(directory, 'turns', firstInput.requestId + '.json');
const oldTurn = { ...first.requests[0], discussionContext: { focusId: 'ARCHIVED_COMPONENT_FOCUS' },
discussionSnapshot: { kind: 'unknown', data: 'ARCHIVED_COMPONENT_SNAPSHOT' },
discussionError: 'ARCHIVED_COMPONENT_ERROR', unparsedResponse: 'ARCHIVED_COMPONENT_RAW' };
await writeFile(turnFile, JSON.stringify(oldTurn));
const service = await f.restart();
const secondInput = { ...firstInput, requestId: crypto.randomUUID(), text: quickReplies[0] };
await service.sendConversation(agentId, secondInput);
const sameSegmentMessages = f.run.mock.calls[1][0];
expect(sameSegmentMessages).toContainEqual({ role: 'system', content: teacherReplyInstructions() });
expect(JSON.stringify(sameSegmentMessages)).not.toContain(reply);
const access = f.prepareCloud.mock.calls[1][3] as TeacherReadAccess;
const tools = createTeacherReadTools(access);
const historicalText = await tools.execute('read_conversation',
JSON.stringify({ message_id: 'teacher:' + firstInput.requestId + ':assistant' }), new AbortController().signal);
expect(historicalText).toContain(reply);
expect(historicalText).toContain(quickReplies[0]);
expect(historicalText).not.toContain('ARCHIVED_COMPONENT_');
f.finish();
await vi.waitFor(async () => expect((await service.conversation(f.scope.projectId, agentId)).topic?.requests.at(-1)?.status).toBe('completed'));
const second = (await service.conversation(f.scope.projectId, agentId)).topic!;
expect(second.requests[1].runtimeThreadId).toBe(first.requests[0].runtimeThreadId);
const other = await f.projects.createProject({ projectPath: path.join(f.root, 'another-project'), identity: { kind: 'create' } });
const otherSource = await f.projects.conversationStore(other.project.path).create({
agentId: other.config.defaultAgentId!, title: '另一个来源', model: null, modelResolution: 'required',
});
f.publishCloudVersion(10);
const nextInput = { projectId: other.project.id, sourceConversationId: otherSource.id,
requestId: crypto.randomUUID(), text: '在新项目里继续聊' };
await service.sendConversation(agentId, nextInput);
await service.sendConversation(agentId, { ...nextInput,
presentation: 'discussion-v1', discussion: { focusId: 'IGNORED_CLIENT_COMPONENT' } } as typeof nextInput);
expect(f.run).toHaveBeenCalledTimes(3);
const carried = JSON.stringify(f.run.mock.calls[2][0]);
expect(carried).not.toContain(reply);
expect(carried).not.toContain(f.created.project.name);
expect(carried).not.toMatch(/ARCHIVED_COMPONENT_|DISCARDED_COMPONENT_OUTPUT|IGNORED_CLIENT_COMPONENT/);
expect(f.prepareCloud.mock.calls[2][3]).toMatchObject({ projectPath: other.project.path });
f.finish();
await vi.waitFor(async () => expect((await service.conversation(other.project.id, agentId)).topic?.requests.at(-1)?.status).toBe('completed'));
const completed = (await service.conversation(other.project.id, agentId)).topic!;
expect(completed.id).not.toBe(first.id);
expect(completed.requests.map(turn => turn.teacherVersion)).toEqual([10]);
expect(completed.requests[0]).toMatchObject({ projectId: other.project.id, projectName: other.project.name,
sourceConversationId: otherSource.id, presentation: 'reply-v1', usage: { inputTokens: 20, outputTokens: 10 } });
expect(completed.requests[0].runtimeThreadId).not.toBe(first.requests[0].runtimeThreadId);
expect(completed).not.toHaveProperty('discussion');
expect(completed.conversation).not.toHaveProperty('discussions');
const retained = (await service.conversation(f.scope.projectId, agentId)).topic!;
expect(retained.requests[0]).toEqual(oldTurn);
expect(retained.discussion).toEqual(legacy);
expect(retained.conversation?.discussions).toEqual(projectsArchive);
expect(JSON.parse(await readFile(manifestFile, 'utf8')).topic).toMatchObject({
discussion: legacy, conversation: { discussions: projectsArchive },
});
expect(JSON.parse(await readFile(turnFile, 'utf8'))).toEqual(oldTurn);
await expect(service.sendConversation(agentId, { ...nextInput, text: '改变原问题' }))
.rejects.toMatchObject({ code: 'teacher_request_conflict' });
expect(f.run).toHaveBeenCalledTimes(3);
});
it('retains scoped continuous-chat diagnostics when cancellation resolves with usage', async () => {
const f = await fixture({ cloudTeacher: true, mockCloud: true });
const raw = '{"reply":"收到的未完成正文","tool":{"kind":"ideas"';
f.run.mockImplementationOnce(async (_messages, signal, onText) => {
onText(raw);
await new Promise<void>(resolve => signal.addEventListener('abort', () => resolve(), { once: true }));
return { inputTokens: 33, outputTokens: 11 };
});
const input = { projectId: f.scope.projectId, sourceConversationId: f.scope.sourceId,
requestId: crypto.randomUUID(), text: '聊聊这个数字' };
const topic = await f.service.sendConversation('cloud-teacher', input);
const scope = { agentId: 'cloud-teacher', projectId: f.scope.projectId, sourceId: 'project' };
await f.service.cancel(scope, topic.id, input.requestId);
await vi.waitFor(async () => expect((await f.service.conversation(f.scope.projectId, 'cloud-teacher')).topic?.requests[0].status).toBe('cancelled'));
const cancelled = (await f.service.conversation(f.scope.projectId, 'cloud-teacher')).topic!.requests[0];
expect(cancelled).toMatchObject({ id: input.requestId, projectId: input.projectId,
sourceConversationId: input.sourceConversationId, teacherVersion: 9, presentation: 'reply-v1',
status: 'cancelled', response: '', unparsedResponse: raw, usage: { inputTokens: 33, outputTokens: 11 } });
await f.service.sendConversation('cloud-teacher', input);
expect(f.run).toHaveBeenCalledOnce();
const restarted = await f.restart();
expect((await restarted.conversation(f.scope.projectId, 'cloud-teacher')).topic!.requests[0]).toEqual(cancelled);
});
it.each(['suggestions', 'reply'] as const)('parses only final %s JSON after the real local runner reads a project file', async (format) => {
const f = await fixture({
liveModel: true, modelInputLimit: 8000,
modelSystemPrompt: '帮助学生理解设计,结合实际进展给出建议。'.repeat(45),
@@ -1313,7 +1510,7 @@ describe('structured teacher service integration', () => {
const topic = await f.service.create(scope);
await f.service.send(scope, topic.id, {
requestId: crypto.randomUUID(), text: '一起看看计数器。', sourceConversationId: f.scope.sourceId,
...(format === 'suggestions' ? { intent: 'suggestions' as const } : { presentation: 'discussion-v1' as const }),
...(format === 'suggestions' ? { intent: 'suggestions' as const } : {}),
});
await vi.waitFor(async () => expect((await f.service.read(scope, topic.id)).requests[0].status).toBe('completed'));
expect(fetch).toHaveBeenCalledTimes(2);
@@ -1326,7 +1523,8 @@ describe('structured teacher service integration', () => {
} else {
expect(saved.requests[0].response).toBe(final.reply);
expect(saved.requests[0].discussionError).toBeUndefined();
expect(saved.discussion).toMatchObject({ status: 'offered', content: tool });
expect(saved.discussion).toBeUndefined();
expect(saved.requests[0].presentation).toBe('reply-v1');
}
expect(saved.requests[0].response).not.toContain(preamble);
} finally {
@@ -1334,48 +1532,45 @@ describe('structured teacher service integration', () => {
}
});
it.each(['local', 'yuxi'])('%s buffers model JSON, offers once, locks updates, persists state, and never applies a cancelled update', async (runtime) => {
it.each(['local', 'yuxi'])('%s keeps legacy data passive while new replies, retries and cancellation work', async (runtime) => {
const f = await fixture({ cloudTeacher: runtime === 'yuxi', mockCloud: runtime === 'yuxi' });
const scope = { ...f.scope, sourceId: 'project', role: 'teacher' as const };
const initial = await f.service.create(scope, undefined, undefined, runtime === 'yuxi' ? 9 : undefined);
const tool = { kind: 'ideas', title: '宠物游戏', items: [{ id: 'dog', text: '养只小狗', state: 'kept' }] };
f.replyWith(JSON.stringify({ reply: '我们可以先把想法放在一起。', quickReplies: [], tool }));
const send = (text: string, discussion?: { toolId: string; revision: number }) => f.service.send(scope, initial.id, {
requestId: crypto.randomUUID(), text, presentation: 'discussion-v1', sourceConversationId: f.scope.sourceId, discussion,
});
const pending = await send('想养只小狗');
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).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();
const legacy = { id: 'old', revision: 4, status: 'active', content: { kind: 'ideas', title: 'legacy-state-sentinel' } };
const directory = path.join(f.created.project.path, '.makelore/teacher-conversations', initial.accountId, 'project');
const archived = { ...initial, discussion: legacy };
await new TeacherTopicStore(directory).save(archived);
const service = await f.restart();
const input = { requestId: crypto.randomUUID(), text: '接着聊数字怎么变', sourceConversationId: f.scope.sourceId };
const quickReplies = ['【想试试】先增加一个数字', '【还不确定】数字什么时候改变'];
f.replyWith(JSON.stringify({ reply: '我们看看数字改变的时机。', quickReplies, tool: { kind: 'flow', title: 'discarded-output-sentinel' } }));
const pending = await service.send(scope, initial.id, input);
expect(pending.requests[0]).toMatchObject({ response: '', presentation: 'reply-v1' });
expect(pending.discussion).toEqual(legacy);
// Old cached send fields are ignored and cannot cause a duplicate request or change a component.
const staleInput = { ...input, presentation: 'discussion-v1', discussion: { toolId: 'old', revision: -1 } };
await service.send(scope, initial.id, staleInput);
expect(f.run).toHaveBeenCalledOnce();
const messages = f.run.mock.calls[0][0];
expect(messages).toContainEqual({ role: 'system', content: teacherReplyInstructions() });
expect(JSON.stringify(messages)).not.toContain('legacy-state-sentinel');
expect(messages[0].content).not.toContain(TEACHER_BEHAVIOR_PROMPT);
f.finish();
await vi.waitFor(async () => expect((await f.service.read(scope, initial.id)).requests[0].status).toBe('completed'));
const offered = await f.service.read(scope, initial.id);
expect(offered.discussion?.status).toBe('offered');
expect(offered.requests[0].response).toBe('我们可以先把想法放在一起。');
const active = await f.service.updateDiscussion(scope, initial.id, { toolId: offered.discussion!.id, revision: 1, action: 'enter' });
const context = { toolId: active.discussion!.id, revision: 2 };
f.replyWith(JSON.stringify({ reply: '把花园也放进来了。', tool: { ...tool, title: '小狗和花园' } }));
const running = await send('还想种花', context);
expect(running.discussion?.content.title).toBe('宠物游戏');
await expect(f.service.updateDiscussion(scope, initial.id, { ...context, action: 'finish' })).rejects.toMatchObject({ code: 'teacher_topic_busy' });
await f.service.cancel(scope, initial.id, running.requests.at(-1)!.id);
await vi.waitFor(async () => expect((await f.service.read(scope, initial.id)).requests.at(-1)?.status).toBe('cancelled'));
expect((await f.service.read(scope, initial.id)).discussion?.content.title).toBe('宠物游戏');
await send('再把花园加进来', context);
f.finish();
await vi.waitFor(async () => expect((await f.service.read(scope, initial.id)).requests.at(-1)?.status).toBe('completed'));
const completed = await f.service.read(scope, initial.id);
expect(completed.discussion?.content.title).toBe('小狗和花园');
expect(completed.discussion?.revision).toBe(3);
expect(completed.requests[0].discussionSnapshot?.title).toBe('宠物游戏');
await expect(send('旧版本', context)).rejects.toMatchObject({ code: 'teacher_discussion_changed' });
const restarted = await f.restart();
expect((await restarted.read(scope, initial.id)).discussion).toEqual(completed.discussion);
await vi.waitFor(async () => expect((await service.read(scope, initial.id)).requests[0].status).toBe('completed'));
const completed = await service.read(scope, initial.id);
expect(completed.discussion).toEqual(legacy);
expect(completed.requests[0]).toMatchObject({ response: '我们看看数字改变的时机。', suggestedQuestions: quickReplies });
expect(completed.requests[0]).not.toHaveProperty('discussionSnapshot');
const next = { ...input, requestId: crypto.randomUUID(), text: quickReplies[0] };
await service.send(scope, initial.id, next);
await service.cancel(scope, initial.id, next.requestId);
await vi.waitFor(async () => expect((await service.read(scope, initial.id)).requests[1].status).toBe('cancelled'));
expect((await service.read(scope, initial.id)).discussion).toEqual(legacy);
const disk = await new TeacherTopicStore(directory).read(initial.id);
expect(disk.discussion).toEqual(legacy);
expect(disk.requests[0].suggestedQuestions).toEqual(quickReplies);
});
});
@@ -1387,7 +1582,7 @@ describe('project consultations with selected cloud teachers', () => {
const raw = '{"reply":"这段正文完整。","tool":{"privateDiagnostic":"only-in-original"';
f.replyWith(raw);
await f.service.send(scope, selected.id, {
requestId: crypto.randomUUID(), text: '看看项目', presentation: 'discussion-v1', sourceConversationId: f.scope.sourceId,
requestId: crypto.randomUUID(), text: '看看项目', sourceConversationId: f.scope.sourceId,
});
f.finish();
await vi.waitFor(async () => expect((await f.service.read(scope, selected.id)).requests[0].status).toBe('completed'));
@@ -1415,7 +1610,7 @@ describe('project consultations with selected cloud teachers', () => {
expect(f.prepareCloud.mock.calls[0][1].version).toBe(9);
expect(f.prepareModel).not.toHaveBeenCalled();
} else expect(f.prepareModel).toHaveBeenCalledWith(expect.anything(), topic.definition,
expect.objectContaining({ projectPath: f.created.project.path }), { finalOnly: false });
expect.objectContaining({ projectPath: f.created.project.path }), { finalOnly: true });
f.finish();
await vi.waitFor(async () => expect((await f.service.read(scope, topic.id)).requests[0].status).toBe('completed'));
});
@@ -1430,7 +1625,7 @@ 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.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();