Merge remote main preserving project chats and reply recovery
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user