Merge Yuxi teacher support with classroom discussions
This commit is contained in:
@@ -21,8 +21,10 @@ import {
|
||||
createCodingProjectConfigV2,
|
||||
} from '../../electron/coding-projects/project-config';
|
||||
import { InMemoryConversationRuntime } from '../../electron/coding-runtime/in-memory-conversation-runtime';
|
||||
import type { ConsultationRole, TeacherDefinition, TeacherRequestIntent, TeacherSourceContext, TeacherTopic } from '../../shared/coding-teacher';
|
||||
import type { ConsultationRole, 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';
|
||||
import type { ConversationSnapshot } from '../../shared/coding-conversation-contracts';
|
||||
import { parseNianCodeDeepLinkUrl } from '../../electron/main/app-deep-link';
|
||||
|
||||
@@ -59,7 +61,7 @@ afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
vi.useRealTimers();
|
||||
});
|
||||
async function fixture() {
|
||||
async function fixture({ durableSource = false, sourceContext = context, liveModel = false, cloudTeacher = false, mockCloud = false, modelInputLimit = definition.limits.max_input_tokens } = {}) {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'coding-teacher-'));
|
||||
roots.push(root);
|
||||
const projects = new CodingProjectService(
|
||||
@@ -95,8 +97,9 @@ async function fixture() {
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
binding: { accountKey: 'test', epoch: 1 },
|
||||
};
|
||||
const readSource = vi.fn(async (_scope: TeacherScope) => structuredClone(context));
|
||||
const readSource = vi.fn(async (_scope: TeacherScope) => structuredClone(sourceContext));
|
||||
const prepareModel = vi.fn(async () => ({ inputLimit: 8000, run }));
|
||||
const prepareCloud = vi.fn((..._args: unknown[]) => ({ inputLimit: 8000, run }));
|
||||
const createService = () => new CodingTeacherService({
|
||||
projects,
|
||||
runtime: new InMemoryConversationRuntime(),
|
||||
@@ -106,16 +109,20 @@ async function fixture() {
|
||||
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,
|
||||
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) => ({
|
||||
version: v,
|
||||
payload: { ...definition, name: '老师 v' + v },
|
||||
payload: { ...definition, name: '老师 v' + v, limits: { ...definition.limits, max_input_tokens: modelInputLimit } },
|
||||
}),
|
||||
preview: async (_account, revision) => {
|
||||
if (revision !== 2) throw new TeacherError(409, 'teacher_draft_changed', '草稿变化');
|
||||
return { draft_revision: revision, payload: definition };
|
||||
},
|
||||
readSource,
|
||||
prepareModel,
|
||||
readSource: durableSource ? undefined : readSource,
|
||||
prepareModel: liveModel ? undefined : prepareModel,
|
||||
prepareCloud: mockCloud ? prepareCloud : undefined,
|
||||
});
|
||||
const service = createService();
|
||||
services.push(service);
|
||||
@@ -128,6 +135,7 @@ async function fixture() {
|
||||
run,
|
||||
readSource,
|
||||
prepareModel,
|
||||
prepareCloud,
|
||||
restart: async () => {
|
||||
await service.dispose();
|
||||
const restarted = createService();
|
||||
@@ -161,6 +169,90 @@ describe('cloud coding teacher', () => {
|
||||
expect((await f.service.read(f.scope, old.id, false)).id).toBe(old.id);
|
||||
expect((await f.service.list(f.scope)).lastSelectedTopicId).toBe(selected.id);
|
||||
});
|
||||
it('wires the Yuxi topic through scoped credentials and local tools without a local prompt or model loop', async () => {
|
||||
const f = await fixture({ cloudTeacher: true });
|
||||
await writeFile(path.join(f.created.project.path, 'counter.ts'), 'let count = 42;');
|
||||
vi.spyOn(teacherCloud, 'assertTeacherAccount').mockReturnValue(undefined);
|
||||
vi.spyOn(teacherCloud, 'teacherCloudRequest').mockResolvedValue({
|
||||
scope: 'makelore-teachers', access_token: 'teacher-test', api_base_url: 'https://teacher.invalid',
|
||||
expires_at: Math.floor(Date.now() / 1000) + 120,
|
||||
});
|
||||
const id = '22222222-2222-4222-8222-222222222222';
|
||||
const requests: Array<{ path: string; body: unknown }> = [];
|
||||
vi.spyOn(teacherTransport, 'proxyAwareFetch').mockImplementation(async (url, init) => {
|
||||
const pathname = new URL(String(url)).pathname.replace('/api/makelore/teachers', '');
|
||||
const body = init?.body ? JSON.parse(String(init.body)) : undefined;
|
||||
requests.push({ path: pathname, body });
|
||||
if (pathname === '/questions') return Response.json({ request_id: 'cloud-question', run_id: 'parent' });
|
||||
if (pathname === '/runs/parent') return Response.json({ status: 'interrupted', interrupt: {
|
||||
source: 'client_read_tools', context_id: id, calls: [{ tool_call_id: 'read-counter', name: 'read_project_file', arguments: { path: 'counter.ts' } }],
|
||||
} });
|
||||
if (pathname.endsWith('/tool-results')) return Response.json({ run_id: 'continued' });
|
||||
if (pathname === '/runs/continued') return Response.json({ status: 'completed', output: '计数器从 42 开始。' });
|
||||
throw new Error('unexpected cloud call: ' + pathname);
|
||||
});
|
||||
try {
|
||||
const topic = await f.service.create(f.scope, undefined, undefined, 9);
|
||||
await f.service.send(f.scope, topic.id, { requestId: id, text: '解释项目里的计数器' });
|
||||
await vi.waitFor(async () => expect((await f.service.read(f.scope, topic.id)).requests[0].status).toBe('completed'));
|
||||
const saved = (await f.service.read(f.scope, topic.id)).requests[0];
|
||||
expect(saved.response).toBe('计数器从 42 开始。');
|
||||
expect(saved.cloudRequestId).toBe('cloud-question');
|
||||
expect(JSON.stringify(requests[0].body)).toContain('创建计数器');
|
||||
expect(requests.find(item => item.path.endsWith('/tool-results'))?.body).toMatchObject({
|
||||
context_id: id, results: [{ tool_call_id: 'read-counter', status: 'success', content: expect.stringContaining('let count = 42;') }],
|
||||
});
|
||||
expect(f.run).not.toHaveBeenCalled();
|
||||
} finally { vi.restoreAllMocks(); }
|
||||
});
|
||||
it('connects the scoped service, source context, file tools and model continuation', async () => {
|
||||
const f = await fixture({ liveModel: true });
|
||||
await writeFile(path.join(f.created.project.path, 'counter.ts'), 'let count = 42;');
|
||||
vi.spyOn(teacherCloud, 'assertTeacherAccount').mockReturnValue(undefined);
|
||||
vi.spyOn(teacherCloud, 'teacherCloudRequest').mockResolvedValue({
|
||||
api_key: 'synthetic', base_url: 'https://teacher.invalid/v1', models: ['qwen'],
|
||||
model_capabilities_v2: { schema_version: 2, models: {
|
||||
qwen: { input_modalities: ['text'], output_modalities: ['text'], reasoning: { supported: false } },
|
||||
} },
|
||||
});
|
||||
const fetch = vi.spyOn(teacherTransport, 'proxyAwareFetch')
|
||||
.mockResolvedValueOnce(new Response('data: ' + JSON.stringify({ choices: [{ delta: {
|
||||
tool_calls: [{ index: 0, id: 'read-counter', function: { name: 'read_project_file', arguments: '{"path":"counter.ts"}' } }],
|
||||
}, finish_reason: 'tool_calls' }] }) + '\n\n'))
|
||||
.mockResolvedValueOnce(new Response('data: {"choices":[{"delta":{"content":"计数器从 42 开始。"},"finish_reason":"stop"}]}\n\n'));
|
||||
try {
|
||||
const topic = await f.service.create(f.scope);
|
||||
await f.service.send(f.scope, topic.id, { requestId: '22222222-2222-4222-8222-222222222222', text: '解释项目里的计数器' });
|
||||
await vi.waitFor(async () => expect((await f.service.read(f.scope, topic.id)).requests[0].status).toBe('completed'));
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
const sent = JSON.parse(String(fetch.mock.calls[1][1]?.body));
|
||||
expect(JSON.stringify(sent.messages)).toContain('创建计数器');
|
||||
expect(sent.messages.at(-1).content).toContain('let count = 42;');
|
||||
expect((await f.service.read(f.scope, topic.id)).requests[0].response).toBe('计数器从 42 开始。');
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
it('retains excerpts of the current question and long project review in the model request', async () => {
|
||||
const f = await fixture({ sourceContext: {
|
||||
...context,
|
||||
messages: [
|
||||
{ id: 'current-question', role: 'user', text: '请分析小鸟游戏的设计' },
|
||||
{ id: 'current-review', role: 'assistant', text: '资源路径问题。'.repeat(1800) + '最后建议使用时间步长。' },
|
||||
],
|
||||
} });
|
||||
const topic = await f.service.create(f.scope);
|
||||
const result = await f.service.send(f.scope, topic.id, {
|
||||
requestId: '22222222-2222-4222-8222-222222222222', text: '这个设计有什么问题?',
|
||||
});
|
||||
const sent = JSON.stringify(f.run.mock.calls[0][0]);
|
||||
expect(sent).toContain('请分析小鸟游戏的设计');
|
||||
expect(sent).toContain('资源路径问题');
|
||||
expect(sent).toContain('最后建议使用时间步长');
|
||||
expect(result.requests[0].includedSourceMessageIds).toEqual(['current-question', 'current-review']);
|
||||
f.finish();
|
||||
});
|
||||
|
||||
it('persists a fixed version, deduplicates requests, and does not change coding metadata', async () => {
|
||||
const f = await fixture();
|
||||
const before = await readFile(
|
||||
@@ -614,7 +706,7 @@ describe('teacher contextual discussion entry points', () => {
|
||||
expect(completed.requests[0].suggestedQuestions).toBeUndefined();
|
||||
const messages = f.run.mock.calls[0][0];
|
||||
expect(messages.at(-1).content).toContain('只发起一个具体、容易回答的交流起点');
|
||||
expect(messages[0]).toEqual(compileTeacherContext(definition, context, [], '问题', []).messages[0]);
|
||||
expect(messages[0]).toEqual(compileTeacherContext(definition, context, [], '问题', [], undefined, 'question', undefined, true).messages[0]);
|
||||
});
|
||||
|
||||
it('deduplicates normalized question intent and rejects reuse with another intent', async () => {
|
||||
@@ -740,12 +832,14 @@ describe('project teacher check-ins', () => {
|
||||
expect(messages.at(-1).content).toContain('约 120 字');
|
||||
expect(messages.at(-1).content).toContain('最多问一个问题');
|
||||
expect(messages.at(-1).content).toContain('不能假装');
|
||||
expect(f.prepareModel).toHaveBeenLastCalledWith(expect.anything(), selected.definition);
|
||||
expect(f.prepareModel).toHaveBeenLastCalledWith(expect.anything(), selected.definition, expect.objectContaining({
|
||||
projectPath: f.created.project.path, source: context, assertCurrent: expect.any(Function),
|
||||
}), { finalOnly: false });
|
||||
const followup = compileTeacherContext(saved.definition, context, saved.requests, '继续说', []);
|
||||
expect(followup.messages.filter((message) => message.role === 'user').map((message) => message.content))
|
||||
.toEqual([expect.stringContaining('创建计数器'), '变量是什么意思?', '当前问题:\n继续说']);
|
||||
.toEqual([expect.stringContaining('创建计数器'), expect.stringContaining('变量是什么意思?'), '当前问题:\n继续说']);
|
||||
expect(followup.messages.filter((message) => message.role === 'assistant').map((message) => message.content))
|
||||
.toContain(saved.requests.at(-1)?.response);
|
||||
.toContainEqual(expect.stringContaining(saved.requests.at(-1)!.response));
|
||||
});
|
||||
|
||||
it('serializes first-topic creation, returns the same accepted request, and skips a concurrent distinct check-in', async () => {
|
||||
@@ -1012,6 +1106,22 @@ describe('teacher context and wire contract', () => {
|
||||
'超过上下文预算'
|
||||
);
|
||||
});
|
||||
it('keeps a long previous teacher answer available for a follow-up within the read budget', () => {
|
||||
const history: TeacherRequest[] = [{
|
||||
id: 'previous', text: '帮我分析', response: '重力的问题。'.repeat(1800) + '建议使用时间步长。',
|
||||
references: [], createdAt: 'now', sourceCursor: context.cursor, sourceCapturedAt: 'now',
|
||||
includedSourceMessageIds: [], omittedMessages: 0, status: 'completed',
|
||||
}];
|
||||
const question = '你刚才的建议是什么意思?';
|
||||
const fixed = compileTeacherContext(definition, context, [], question, [], undefined, 'question', undefined, true);
|
||||
const budget = estimateTeacherTokens(fixed.messages) + 1500;
|
||||
const compiled = compileTeacherContext(definition, context, history, question, [], budget, 'question', undefined, true);
|
||||
expect(compiled.truncatedMessages).toBeGreaterThan(0);
|
||||
expect(estimateTeacherTokens(compiled.messages)).toBeLessThanOrEqual(budget);
|
||||
expect(JSON.stringify(compiled.messages)).toContain('创建计数器');
|
||||
expect(JSON.stringify(compiled.messages)).toContain('重力的问题');
|
||||
expect(JSON.stringify(compiled.messages)).toContain('建议使用时间步长');
|
||||
});
|
||||
it('sends no tools, ignores reasoning deltas, and requires a terminal stream', async () => {
|
||||
const text: string[] = [];
|
||||
const fake = vi.fn(
|
||||
@@ -1098,6 +1208,26 @@ it('reads only the durable active branch without starting a worker', async()=>{
|
||||
expect(sourceContext(snapshot).messages.map(message=>message.text)).toEqual(['开始学习','当前分支内容']);
|
||||
expect(snapshot.worker.status).toBe('stopped');
|
||||
});
|
||||
it('sends durable source history through the real teacher service without a running worker', async () => {
|
||||
const f = await fixture({ durableSource: true });
|
||||
await f.projects.conversationStore(f.created.project.path).ensureSessionBinding(f.scope.sourceId,
|
||||
async () => ({ sessionKey: 'teacher-source', piSessionId: 'teacher-source' }));
|
||||
const folder = path.join(getPiManagedPaths(f.root).sessionsDir, f.scope.projectId);
|
||||
await mkdir(folder, { recursive: true });
|
||||
await writeFile(path.join(folder, 'teacher-source.jsonl'), [
|
||||
{ type: 'session', id: 'teacher-source' },
|
||||
{ type: 'message', id: 'root', parentId: null, message: { role: 'user', content: '分析小鸟游戏' } },
|
||||
{ type: 'message', id: 'other', parentId: 'root', message: { role: 'user', content: '废弃的分支' } },
|
||||
{ type: 'message', id: 'active', parentId: 'root', message: { role: 'assistant', content: [{ type: 'text', text: '检查碰撞检测' }] } },
|
||||
].map(entry => JSON.stringify(entry)).join('\n'));
|
||||
const topic = await f.service.create(f.scope);
|
||||
await f.service.send(f.scope, topic.id, { requestId: '22222222-2222-4222-8222-222222222222', text: '老师怎么看?' });
|
||||
const sent = JSON.stringify(f.run.mock.calls[0][0]);
|
||||
expect(sent).toContain('分析小鸟游戏');
|
||||
expect(sent).toContain('检查碰撞检测');
|
||||
expect(sent).not.toContain('废弃的分支');
|
||||
f.finish();
|
||||
});
|
||||
import {createServer} from 'node:http';
|
||||
import {handleCodingTeacherRoutes} from '../../electron/api/routes/coding-teacher';
|
||||
import type {HostApiContext} from '../../electron/api/context';
|
||||
@@ -1166,16 +1296,66 @@ it.each(['teacher', 'friend'] as const)('routes project-level %s config and topi
|
||||
|
||||
|
||||
describe('structured teacher service integration', () => {
|
||||
it('buffers model JSON, offers once, locks updates, persists state, and never applies a cancelled update', async () => {
|
||||
const f = await fixture();
|
||||
it.each(['suggestions', 'discussion'] 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: 24000 });
|
||||
const scope = { ...f.scope, sourceId: 'project', role: 'teacher' as const };
|
||||
const initial = await f.service.create(scope);
|
||||
await writeFile(path.join(f.created.project.path, 'counter.ts'), 'let count = 42;');
|
||||
vi.spyOn(teacherCloud, 'assertTeacherAccount').mockReturnValue(undefined);
|
||||
vi.spyOn(teacherCloud, 'teacherCloudRequest').mockResolvedValue({
|
||||
api_key: 'synthetic', base_url: 'https://teacher.invalid/v1', models: ['qwen'],
|
||||
model_capabilities_v2: { schema_version: 2, models: {
|
||||
qwen: { input_modalities: ['text'], output_modalities: ['text'], reasoning: { supported: false } },
|
||||
} },
|
||||
});
|
||||
const tool = { kind: 'ideas', title: '计数器', items: [{ id: 'count', text: '从 42 开始计数', state: 'kept' }] };
|
||||
const final = format === 'suggestions'
|
||||
? { intro: '计数器从 42 开始,我们可以聊聊它怎么变。', questions: ['为什么从 42 开始?', '什么时候改变数字?'] }
|
||||
: { reply: '把现在的计数方式放进来了。', quickReplies: [], tool };
|
||||
const preamble = format === 'suggestions' ? '我先读取计数器文件。'
|
||||
: JSON.stringify({ reply: '这是读取前的想法。', quickReplies: [], tool: { ...tool, title: '尚未核对的草案' } });
|
||||
const fetch = vi.spyOn(teacherTransport, 'proxyAwareFetch')
|
||||
.mockResolvedValueOnce(new Response('data: ' + JSON.stringify({ choices: [{ delta: {
|
||||
content: preamble,
|
||||
tool_calls: [{ index: 0, id: 'read-counter', function: { name: 'read_project_file', arguments: '{"path":"counter.ts"}' } }],
|
||||
}, finish_reason: 'tool_calls' }] }) + '\n\n'))
|
||||
.mockResolvedValueOnce(new Response('data: ' + JSON.stringify({ choices: [{ delta: { content: JSON.stringify(final) }, finish_reason: 'stop' }] }) + '\n\n'));
|
||||
try {
|
||||
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 }),
|
||||
});
|
||||
await vi.waitFor(async () => expect((await f.service.read(scope, topic.id)).requests[0].status).toBe('completed'));
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
const continuation = JSON.parse(String(fetch.mock.calls[1][1]?.body));
|
||||
expect(continuation.messages.at(-1).content).toContain('let count = 42;');
|
||||
const saved = await f.service.read(scope, topic.id);
|
||||
if (format === 'suggestions') {
|
||||
expect(saved.requests[0].response).toBe(final.intro);
|
||||
expect(saved.requests[0].suggestedQuestions).toEqual(final.questions);
|
||||
} 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.requests[0].response).not.toContain(preamble);
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['local', 'yuxi'])('%s buffers model JSON, offers once, locks updates, persists state, and never applies a cancelled update', 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).toContain(TEACHER_BEHAVIOR_PROMPT);
|
||||
expect(modelMessages.some(message => message.role === 'system' && message.content.includes('本轮界面协议'))).toBe(true);
|
||||
@@ -1207,3 +1387,48 @@ describe('structured teacher service integration', () => {
|
||||
expect((await restarted.read(scope, initial.id)).discussion).toEqual(completed.discussion);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
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) => {
|
||||
const f = await fixture({ cloudTeacher: runtime === 'yuxi', mockCloud: runtime === 'yuxi' });
|
||||
const scope = { ...f.scope, sourceId: 'project', role: 'friend' as const };
|
||||
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(messages[0].content).not.toContain(TEACHER_BEHAVIOR_PROMPT);
|
||||
expect(messages[0].content).toContain('本轮没有项目读取工具');
|
||||
if (runtime === 'yuxi') {
|
||||
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();
|
||||
}
|
||||
f.finish();
|
||||
await vi.waitFor(async () => expect((await f.service.read(scope, topic.id)).requests[0].status).toBe('completed'));
|
||||
});
|
||||
|
||||
it('uses the selected Yuxi topic and read scope for an automatic check-in', async () => {
|
||||
const f = await fixture({ cloudTeacher: true, mockCloud: true });
|
||||
const scope = { ...f.scope, sourceId: 'project', role: 'teacher' as const };
|
||||
const topic = await f.service.create(scope, undefined, undefined, 9);
|
||||
const result = await f.service.checkIn(scope, {
|
||||
requestId: crypto.randomUUID(), sourceConversationId: f.scope.sourceId,
|
||||
});
|
||||
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(f.prepareCloud.mock.calls[0][3]).toMatchObject({ projectPath: f.created.project.path, source: context });
|
||||
expect(f.prepareModel).not.toHaveBeenCalled();
|
||||
f.finish();
|
||||
await vi.waitFor(async () => expect((await f.service.read(scope, topic.id)).requests[0].status).toBe('completed'));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user