Merge latest main and preserve teacher upgrade safeguards
This commit is contained in:
@@ -628,6 +628,28 @@ describe('teacher contextual discussion entry points', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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]).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 () => {
|
||||
const f = await fixture();
|
||||
const scope = newScope(f);
|
||||
@@ -698,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);
|
||||
@@ -710,7 +732,7 @@ describe('teacher contextual discussion entry points', () => {
|
||||
});
|
||||
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]);
|
||||
});
|
||||
|
||||
@@ -755,26 +777,27 @@ 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.each(['suggestions', 'guided-help'] as const)('recommends one concrete starting point for %s without local expression policy', (intent) => {
|
||||
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('推荐一个具体切入点');
|
||||
expect(current).toContain('快捷回复围绕这个切入点');
|
||||
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', '组件', '先这些'])
|
||||
for (const removed of ['400 字', '120 字', '2–3', '简短中文', '学生口吻', 'Alice', '组件', '先这些',
|
||||
'推荐一个具体切入点', '说明为何', '快捷回复围绕', '从构思切入', '整理想法', '理解关系', '承接已确认的共识'])
|
||||
expect(local).not.toContain(removed);
|
||||
});
|
||||
|
||||
@@ -822,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 });
|
||||
@@ -1289,6 +1311,55 @@ it.each(['agent', 'teacher'] as const)('routes project-level %s config and topic
|
||||
|
||||
|
||||
describe('structured teacher service integration', () => {
|
||||
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(agentId)).topic?.requests[0].status).toBe('completed'));
|
||||
const original = (await f.service.conversation(agentId)).topic!;
|
||||
await f.service.dispose();
|
||||
|
||||
const turnFile = path.join(f.root, 'agent-conversations', original.accountId, agentId, '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(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(agentId)).topic?.requests[1].status).toBe('completed'));
|
||||
|
||||
await service.sendConversation(agentId, { ...input, requestId: crypto.randomUUID(), text: '再往下聊' });
|
||||
const continued = (await service.conversation(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(agentId)).topic?.requests[2].status).toBe('completed'));
|
||||
expect((await service.conversation(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';
|
||||
@@ -1551,7 +1622,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