Files
makelore/tests/unit/coding-teacher.test.ts

1446 lines
78 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @vitest-environment node
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { CodingTeacherService, type TeacherScope } from '../../electron/coding-teacher/service';
import { TeacherTopicStore } from '../../electron/coding-teacher/store';
import { compileTeacherContext, estimateTeacherTokens, sourceContext } from '../../electron/coding-teacher/context';
const TEACHER_BEHAVIOR_PROMPT = '你是麦洛的创作老师';
import { streamTeacherReply } from '../../electron/coding-teacher/model-runner';
import { TeacherError } from '../../electron/coding-teacher/config-client';
import { teacherReplyInstructions } from '../../electron/coding-teacher/reply';
import {
createCodingProjectStore,
createMemoryCodingProjectStorage,
} from '../../electron/coding-projects/project-store';
import { CodingProjectService } from '../../electron/coding-projects/project-service';
import {
ensureDefaultCodingAgent,
createCodingProjectConfigV2,
} from '../../electron/coding-projects/project-config';
import { InMemoryConversationRuntime } from '../../electron/coding-runtime/in-memory-conversation-runtime';
import type { LegacyConsultationRole, TeacherDefinition, TeacherRequest, TeacherRequestIntent, TeacherSourceContext, TeacherTopic } from '../../shared/coding-teacher';
import { TEACHER_CHECK_IN_INTERVAL_MS, TEACHER_UNCHANGED_CHECK_IN_INTERVAL_MS } from '../../shared/coding-teacher';
import * as teacherCloud from '../../electron/coding-teacher/config-client';
import * as teacherTransport from '../../electron/utils/proxy-fetch';
import type { ConversationSnapshot } from '../../shared/coding-conversation-contracts';
import { parseNianCodeDeepLinkUrl } from '../../electron/main/app-deep-link';
const definition: TeacherDefinition = {
schema_version: 1,
teacher_id: 'coding-teacher',
name: '编程老师',
description: '',
avatar_id: 'avatar-01',
welcome_message: '一起学编程',
suggested_questions: ['为什么?'],
system_prompt: '通过问题引导思考。',
skills: [
{
id: 'explain',
name: '讲解',
description: '',
enabled: true,
instructions_markdown: '使用具体的小例子。',
},
],
model: { model_id: 'qwen', reasoning_choice: { mode: 'default' } },
limits: { max_input_tokens: 8000, max_output_tokens: 1500 },
};
const context: TeacherSourceContext = {
messages: [{ id: 'source-user', role: 'user', text: '创建计数器' }],
cursor: { workerGeneration: 1, seq: 3 },
capturedAt: '2026-09-22T00:00:00Z',
};
const roots: string[] = [];
const services: CodingTeacherService[] = [];
afterEach(async () => {
await Promise.all(services.splice(0).map((service) => service.dispose()));
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
vi.useRealTimers();
});
async function fixture({ durableSource = false, sourceContext = context, liveModel = false, cloudTeacher = false, mockCloud = false, modelInputLimit = definition.limits.max_input_tokens, modelSystemPrompt = definition.system_prompt } = {}) {
const root = await mkdtemp(path.join(tmpdir(), 'coding-teacher-'));
roots.push(root);
const projects = new CodingProjectService(
createCodingProjectStore(createMemoryCodingProjectStorage())
);
const created = await projects.createProject({
projectPath: path.join(root, 'project'),
identity: { kind: 'create' },
});
const source = await projects
.conversationStore(created.project.path)
.create({
agentId: created.config.defaultAgentId!,
title: '源码会话',
model: null,
modelResolution: 'required',
});
const scope = { projectId: created.project.id, sourceId: source.id };
let enabled = true,
version = 1,
accountCurrent = true;
let finish: () => void = () => undefined;
let reply = '计数器保存一个数字。';
const run = vi.fn(async (_messages, signal: AbortSignal, onText: (text: string) => void) => {
onText(reply);
await new Promise<void>((resolve, reject) => {
finish = resolve;
signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true });
});
return { inputTokens: 20, outputTokens: 10 };
});
let account = {
id: '11111111-1111-4111-8111-111111111111',
binding: { accountKey: 'test', epoch: 1 },
};
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(),
userDataDir: root,
account: async () => account,
assertAccount: () => {
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, system_prompt: modelSystemPrompt, 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: durableSource ? undefined : readSource,
prepareModel: liveModel ? undefined : prepareModel,
prepareCloud: mockCloud ? prepareCloud : undefined,
});
const service = createService();
services.push(service);
return {
root,
projects,
created,
scope,
service,
run,
readSource,
prepareModel,
prepareCloud,
restart: async () => {
await service.dispose();
const restarted = createService();
services.push(restarted);
return restarted;
},
finish: () => finish(),
replyWith: (text: string) => { reply = text; },
disable: () => {
enabled = false;
},
nextVersion: () => {
version++;
},
switchAccount: () => {
accountCurrent = false;
},
useOtherAccount: () => {
account = {
id: '99999999-9999-4999-8999-999999999999',
binding: { accountKey: 'another-account', epoch: 2 },
};
},
};
}
describe('cloud coding teacher', () => {
it('does not change the selected discussion when the companion polls an older topic', async () => {
const f = await fixture();
const old = await f.service.create(f.scope);
const selected = await f.service.create(f.scope);
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, sourceContext: {
...context, messages: [{ id: 'long-code', role: 'assistant', text: 'const title = "game";\n'.repeat(500) }],
} });
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.toolActivity).toEqual([{ id: '22222222-2222-4222-8222-222222222222:read-counter', name: 'read_project_file', status: 'completed' }]);
const restarted = await f.restart();
expect((await restarted.read(f.scope, topic.id)).requests[0].toolActivity).toEqual(saved.toolActivity);
expect(saved.cloudRequestId).toBe('cloud-question');
expect(JSON.stringify(requests[0].body)).toContain('const title');
expect(saved.truncatedMessages).toBeGreaterThan(0);
expect(Buffer.byteLength((requests[0].body as { query: string }).query)).toBeLessThanOrEqual(8000);
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(
path.join(f.created.project.path, '.makelore/conversations.json'),
'utf8'
);
const topic = await f.service.create(f.scope);
f.nextVersion();
const next = await f.service.create(f.scope);
expect(next.version).toBe(2);
expect(topic.version).toBe(1);
const input = { requestId: '22222222-2222-4222-8222-222222222222', text: '解释一下' };
await f.service.send(f.scope, topic.id, input);
await f.service.send(f.scope, topic.id, input);
expect(f.run).toHaveBeenCalledOnce();
await expect(
f.service.send(f.scope, topic.id, { ...input, text: '不同问题' })
).rejects.toMatchObject({ code: 'teacher_request_conflict' });
f.finish();
await vi.waitFor(async () =>
expect((await f.service.read(f.scope, topic.id)).requests[0].status).toBe('completed')
);
expect(
await readFile(path.join(f.created.project.path, '.makelore/conversations.json'), 'utf8')
).toBe(before);
expect((await f.service.list(f.scope)).items).toHaveLength(2);
});
it('honors disable for old topics while keeping readable history', async () => {
const f = await fixture(),
topic = await f.service.create(f.scope);
f.disable();
await expect(
f.service.send(f.scope, topic.id, {
requestId: '22222222-2222-4222-8222-222222222222',
text: '问题',
})
).rejects.toMatchObject({ code: 'teacher_disabled' });
expect((await f.service.read(f.scope, topic.id)).version).toBe(1);
expect(f.run).not.toHaveBeenCalled();
});
it('cancels partial replies and rejects another concurrent question', async () => {
const f = await fixture(),
topic = await f.service.create(f.scope),
id = '22222222-2222-4222-8222-222222222222';
await f.service.send(f.scope, topic.id, { requestId: id, text: '问题' });
await expect(
f.service.send(f.scope, topic.id, {
requestId: '33333333-3333-4333-8333-333333333333',
text: '另一个问题',
})
).rejects.toMatchObject({ code: 'teacher_topic_busy' });
await f.service.cancel(f.scope, topic.id, id);
await vi.waitFor(async () =>
expect((await f.service.read(f.scope, topic.id)).requests[0]).toMatchObject({
status: 'cancelled',
response: '', unparsedResponse: '计数器保存一个数字。',
})
);
});
it('retains interrupted requests on restart without submitting again', async () => {
const f = await fixture(),
topic = await f.service.create(f.scope),
id = '22222222-2222-4222-8222-222222222222';
await f.service.send(f.scope, topic.id, { requestId: id, text: '问题' });
const store = new TeacherTopicStore(
path.join(
f.created.project.path,
'.makelore/teacher-conversations',
topic.accountId,
f.scope.sourceId
)
);
expect((await store.read(topic.id)).requests[0].status).toBe('interrupted');
expect(f.run).toHaveBeenCalledOnce();
await f.service.cancel(f.scope, topic.id, id);
});
it('checks exact preview revision and never reads project context for preview', async () => {
const f = await fixture(),
scope = { projectId: 'preview', sourceId: 'preview' };
await expect(f.service.create(scope, 1, 'sample')).rejects.toMatchObject({
code: 'teacher_draft_changed',
});
const topic = await f.service.create(scope, 2, '示例项目的计数器');
await f.service.send(scope, topic.id, {
requestId: '22222222-2222-4222-8222-222222222222',
text: '怎么改进',
});
expect(JSON.stringify(f.run.mock.calls[0][0])).toContain('示例项目的计数器');
expect(JSON.stringify(f.run.mock.calls[0][0])).not.toContain('创建计数器');
f.finish();
});
it('rejects foreign message references before submitting a model request', async () => {
const f = await fixture(),
topic = await f.service.create(f.scope);
await expect(
f.service.send(f.scope, topic.id, {
requestId: '22222222-2222-4222-8222-222222222222',
text: '解释',
references: [{ kind: 'message', messageId: 'another-source', text: '别的项目' }],
})
).rejects.toMatchObject({ code: 'teacher_reference_invalid' });
expect(f.run).not.toHaveBeenCalled();
});
it('cascades source deletion after cancelling its reply, without recreating files', async () => {
const f = await fixture(),
topic = await f.service.create(f.scope);
await f.service.send(f.scope, topic.id, {
requestId: '22222222-2222-4222-8222-222222222222',
text: '问题',
});
await f.service.removeSource(f.scope.projectId, f.scope.sourceId);
await expect(f.service.read(f.scope, topic.id)).rejects.toMatchObject({
code: 'teacher_source_not_found',
});
await expect(
readFile(
path.join(
f.created.project.path,
'.makelore/teacher-conversations',
topic.accountId,
f.scope.sourceId,
topic.id + '.json'
)
)
).rejects.toMatchObject({ code: 'ENOENT' });
});
it('fails account changes before dispatch', async () => {
const f = await fixture(),
topic = await f.service.create(f.scope);
f.switchAccount();
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('failed')
);
expect(f.run).not.toHaveBeenCalled();
});
});
describe('project teacher and friend consultations', () => {
const requestId = '22222222-2222-4222-8222-222222222222';
const nextRequestId = '33333333-3333-4333-8333-333333333333';
const roles: Array<LegacyConsultationRole | undefined> = [undefined, 'teacher'];
it('preserves old synthetic friend history read-only and creates only published agents', async () => {
const f = await fixture();
const scope = { projectId: f.scope.projectId, sourceId: 'project' };
const legacyScope = { ...scope, role: 'friend' as const };
const current = await f.service.create(scope);
const legacy = { ...current, id: '44444444-4444-4444-8444-444444444444', role: 'friend' as const,
definition: { ...current.definition, name: '小麦', teacher_id: 'coding-friend', system_prompt: '旧版本地人设' } };
const store = new TeacherTopicStore(path.join(f.created.project.path, '.makelore/friend-conversations', current.accountId, 'project'));
await store.save(legacy);
expect((await f.service.list(scope)).items).toEqual(expect.arrayContaining([
expect.objectContaining({ id: legacy.id, legacyRole: 'friend' }), expect.objectContaining({ id: current.id }),
]));
expect(await f.service.read(legacyScope, legacy.id)).toMatchObject({ role: 'friend', definition: legacy.definition });
await expect(f.service.create(legacyScope)).rejects.toMatchObject({ code: 'consultation_legacy_read_only' });
await expect(f.service.send(legacyScope, legacy.id, { requestId, text: '继续' })).rejects.toMatchObject({ code: 'consultation_legacy_read_only' });
expect(current.role).toBeUndefined();
expect(current.definition.system_prompt).toBe(definition.system_prompt);
expect(current.definition.skills).toEqual(definition.skills);
expect(f.run).not.toHaveBeenCalled();
});
it('selects the current operation conversation per request and deduplicates retries by source', async () => {
const f = await fixture();
const scope = { projectId: f.scope.projectId, sourceId: 'project' };
const other = await f.projects.conversationStore(f.created.project.path).create({
agentId: f.created.config.defaultAgentId!, title: '另一个操作对话', model: null, modelResolution: 'required',
});
f.readSource.mockImplementation(async (selected) => ({
...context,
messages: [{ id: selected.sourceId, role: 'user', text: selected.sourceId === f.scope.sourceId ? '第一段操作' : '第二段操作' }],
}));
const topic = await f.service.create(scope);
const input = { requestId, text: '我这样理解对吗?', sourceConversationId: f.scope.sourceId };
await Promise.all([f.service.send(scope, topic.id, input), f.service.send(scope, topic.id, input)]);
expect(f.run).toHaveBeenCalledOnce();
expect(f.readSource).toHaveBeenCalledWith({ ...scope, sourceId: f.scope.sourceId });
await expect(f.service.send(scope, topic.id, { ...input, sourceConversationId: other.id }))
.rejects.toMatchObject({ code: 'teacher_request_conflict' });
let complete!: () => void;
const completed = new Promise<void>((resolve) => { complete = resolve; });
const unsubscribe = await f.service.subscribe(scope, topic.id, (snapshot) => {
if (snapshot.requests[0].status === 'completed') complete();
});
f.finish();
await completed;
unsubscribe();
await f.service.send(scope, topic.id, { ...input, requestId: nextRequestId, sourceConversationId: other.id });
const latest = await f.service.read(scope, topic.id);
expect(latest.requests.map((request) => request.sourceConversationId)).toEqual([f.scope.sourceId, other.id]);
expect(latest.requests[1].includedSourceMessageIds).toEqual([other.id]);
expect(JSON.stringify(f.run.mock.calls[1][0])).toContain('第二段操作');
expect(JSON.stringify(f.run.mock.calls[1][0])).not.toContain('第一段操作');
expect(latest.id).toBe(topic.id);
});
it.each(roles)('rejects cross-project sources and isolates %s topics by project and account', async (role) => {
const f = await fixture();
const scope = { projectId: f.scope.projectId, sourceId: 'project', role };
const topic = await f.service.create(scope);
const other = await f.projects.createProject({ projectPath: path.join(f.root, 'other-project'), identity: { kind: 'create' } });
const foreign = await f.projects.conversationStore(other.project.path).create({
agentId: other.config.defaultAgentId!, title: '外部对话', model: null, modelResolution: 'required',
});
await expect(f.service.send(scope, topic.id, { requestId, text: '帮我理解', sourceConversationId: foreign.id }))
.rejects.toMatchObject({ code: 'teacher_source_not_found' });
expect(f.readSource).not.toHaveBeenCalled();
expect(f.prepareModel).not.toHaveBeenCalled();
expect((await f.service.read(scope, topic.id)).requests).toEqual([]);
const otherScope = { ...scope, projectId: other.project.id };
expect((await f.service.list(otherScope)).items).toEqual([]);
await expect(f.service.read(otherScope, topic.id)).rejects.toMatchObject({ code: 'teacher_topic_not_found' });
f.useOtherAccount();
expect((await f.service.list(scope)).items).toEqual([]);
await expect(f.service.read(scope, topic.id)).rejects.toMatchObject({ code: 'teacher_topic_not_found' });
expect(f.run).not.toHaveBeenCalled();
});
it.each(roles)('cancels an active %s reply when its operation source is deleted, preserving project history', async (role) => {
const f = await fixture();
const scope = { projectId: f.scope.projectId, sourceId: 'project', role };
const topic = await f.service.create(scope);
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: '', 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' });
expect(f.run).toHaveBeenCalledOnce();
});
it.each(roles)('does not dispatch a preparing %s request after its source is deleted', async (role) => {
const f = await fixture();
const scope = { projectId: f.scope.projectId, sourceId: 'project', role };
const topic = await f.service.create(scope);
let releasePreparation!: () => void;
f.prepareModel.mockImplementationOnce(async () => {
await new Promise<void>((resolve) => { releasePreparation = resolve; });
return { inputLimit: 8000, run: f.run };
});
const sending = f.service.send(scope, topic.id, { requestId, text: '问题', sourceConversationId: f.scope.sourceId });
const rejected = expect(sending).rejects.toMatchObject({ code: 'teacher_source_not_found' });
await vi.waitFor(() => expect(f.prepareModel).toHaveBeenCalledOnce());
const deleting = f.service.removeSource(f.scope.projectId, f.scope.sourceId);
await vi.waitFor(async () => expect(f.service.list(f.scope)).rejects.toMatchObject({ code: 'teacher_source_not_found' }));
releasePreparation();
await rejected;
await deleting;
expect(f.run).not.toHaveBeenCalled();
expect((await f.service.read(scope, topic.id)).requests).toEqual([]);
});
it('settles a persisted project request as cancelled if deletion races its initial save', async () => {
const f = await fixture();
const scope = { projectId: f.scope.projectId, sourceId: 'project' };
const topic = await f.service.create(scope);
let saved!: () => void;
let releaseSave!: () => void;
const savedToDisk = new Promise<void>((resolve) => { saved = resolve; });
const allowSaveReturn = new Promise<void>((resolve) => { releaseSave = resolve; });
const originalSave = TeacherTopicStore.prototype.save;
const spy = vi.spyOn(TeacherTopicStore.prototype, 'save').mockImplementation(async function (value) {
await originalSave.call(this, value);
if (value.id === topic.id && value.requests[0]?.status === 'preparing') {
saved();
await allowSaveReturn;
}
});
try {
const sending = f.service.send(scope, topic.id, { requestId, text: '问题', sourceConversationId: f.scope.sourceId });
await savedToDisk;
const deleting = f.service.removeSource(f.scope.projectId, f.scope.sourceId);
await vi.waitFor(async () => expect(f.service.list(f.scope)).rejects.toMatchObject({ code: 'teacher_source_not_found' }));
releaseSave();
await sending;
await deleting;
expect(f.run).not.toHaveBeenCalled();
expect((await f.service.read(scope, topic.id)).requests[0].status).toBe('cancelled');
const stored = JSON.parse(await readFile(path.join(
f.created.project.path, '.makelore/teacher-conversations', topic.accountId, 'project', topic.id + '.json'
), 'utf8'));
expect(stored.requests[0].status).toBe('cancelled');
} finally {
releaseSave();
spy.mockRestore();
}
});
});
describe('teacher contextual discussion entry points', () => {
const requestId = '22222222-2222-4222-8222-222222222222';
const nextRequestId = '33333333-3333-4333-8333-333333333333';
const intro = '我们可以从你刚才想做的计数器聊起。';
const questions = ['我希望谁来使用这个计数器?', '我怎么知道数字有没有按照想法变化?'];
const response = JSON.stringify({ intro, questions });
const newScope = (f: Awaited<ReturnType<typeof fixture>>): TeacherScope => ({
projectId: f.scope.projectId, sourceId: 'project', role: 'teacher',
});
const finishRequest = async (f: Awaited<ReturnType<typeof fixture>>, scope: TeacherScope, id: string) => {
let complete!: (topic: TeacherTopic) => void;
const finished = new Promise<TeacherTopic>((resolve) => { complete = resolve; });
const unsubscribe = await f.service.subscribe(scope, id, (topic) => {
if (['completed', 'failed', 'cancelled'].includes(topic.requests.at(-1)?.status ?? '')) complete(topic);
});
f.finish();
const topic = await finished;
unsubscribe();
return topic;
};
it('validates a completed suggestion response, persists it, and includes it in the next question history', async () => {
const f = await fixture();
const scope = newScope(f);
const topic = await f.service.create(scope);
f.replyWith('```json\n' + response + '\n```');
const accepted = await f.service.send(scope, topic.id, {
requestId, intent: 'suggestions', text: '老师,帮我看看', sourceConversationId: f.scope.sourceId,
});
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(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,
usage: { inputTokens: 20, outputTokens: 10 },
});
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].suggestedQuestions).toEqual(questions);
expect(disk.requests[0].response).toBe(intro);
f.replyWith('你想让谁来使用?');
await f.service.send(scope, topic.id, { requestId: nextRequestId, text: questions[0] });
const nextMessages = f.run.mock.calls[1][0];
const history = nextMessages.filter((message: { role: string }) => message.role === 'assistant');
expect(history[0].content).toContain(intro);
for (const question of questions) expect(history[0].content).toContain(question);
expect(nextMessages.at(-1).content).not.toContain('只返回 JSON 对象');
expect((await f.service.read(scope, topic.id)).requests[1].intent).toBe('question');
expect(completed.definition.system_prompt).toBe(definition.system_prompt);
});
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(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: 'completed', response: reply, suggestedQuestions: quickReplies,
usage: { inputTokens: 20, outputTokens: 10 },
});
});
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);
const topic = await f.service.create(scope);
f.replyWith(response);
await f.service.send(scope, topic.id, { requestId, intent: 'suggestions', text: '老师,帮我看看' });
await f.service.cancel(scope, topic.id, requestId);
await vi.waitFor(async () => {
const request = (await f.service.read(scope, topic.id)).requests[0];
expect(request).toMatchObject({ status: 'cancelled', response: '' });
expect(request.suggestedQuestions).toBeUndefined();
});
});
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);
const topic = await f.service.create(scope);
f.run.mockImplementationOnce(async (_messages, _signal, onText) => {
onText(response);
throw new TeacherError(502, 'teacher_stream_interrupted', '回复中断,请再试一次。');
});
await f.service.send(scope, topic.id, { requestId, intent: 'suggestions', text: '老师,帮我看看' });
await vi.waitFor(async () => {
const request = (await f.service.read(scope, topic.id)).requests[0];
expect(request).toMatchObject({ status: 'failed', response: '' });
expect(request.suggestedQuestions).toBeUndefined();
});
const next = await f.service.create(scope);
f.replyWith(response);
await f.service.send(scope, next.id, { requestId: nextRequestId, intent: 'suggestions', text: '老师,帮我看看' });
f.switchAccount();
f.finish();
await vi.waitFor(async () => {
const request = (await f.service.read(scope, next.id)).requests[0];
expect(request).toMatchObject({ status: 'failed', response: '' });
expect(request.suggestedQuestions).toBeUndefined();
});
});
it('retains source-deletion cancellation for suggestions', async () => {
const f = await fixture();
const scope = newScope(f);
const topic = await f.service.create(scope);
f.replyWith(response);
await f.service.send(scope, topic.id, { requestId, intent: 'suggestions', 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].suggestedQuestions).toBeUndefined();
});
it('adds a one-question guided opening for this round without changing the teacher system instructions', async () => {
const f = await fixture();
const scope = newScope(f);
const topic = await f.service.create(scope);
f.replyWith('先想一个人:你希望谁来用你的计数器?');
await f.service.send(scope, topic.id, { requestId, intent: 'guided-help', text: '我也说不清,你带我看看' });
const completed = await finishRequest(f, scope, topic.id);
expect(completed.requests[0]).toMatchObject({
intent: 'guided-help', status: 'completed', response: '先想一个人:你希望谁来用你的计数器?',
});
expect(completed.requests[0].suggestedQuestions).toEqual([]);
const messages = f.run.mock.calls[0][0];
expect(messages.at(-1).content).toContain('推荐一个具体切入点');
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 () => {
const f = await fixture();
const scope = newScope(f);
const topic = await f.service.create(scope);
const input = { requestId, text: '老师,帮我看看' };
await f.service.send(scope, topic.id, input);
await f.service.send(scope, topic.id, { ...input, intent: 'question' });
expect(f.run).toHaveBeenCalledOnce();
await expect(f.service.send(scope, topic.id, { ...input, intent: 'suggestions' }))
.rejects.toMatchObject({ code: 'teacher_request_conflict' });
const second = await f.service.create(scope);
const suggestions = { requestId: nextRequestId, intent: 'suggestions' as const, text: '老师,帮我看看' };
await Promise.all([f.service.send(scope, second.id, suggestions), f.service.send(scope, second.id, suggestions)]);
expect(f.run).toHaveBeenCalledTimes(2);
});
it.each(['suggestions', 'guided-help'] as const)('rejects %s outside the project teacher before preparing a model', async (intent) => {
const f = await fixture();
const previewScope = { projectId: 'preview', sourceId: 'preview' };
const legacy = await f.service.create(f.scope);
const preview = await f.service.create(previewScope, 2, '示例项目');
for (const [scope, topic] of [[f.scope, legacy], [previewScope, preview]] as const) {
await expect(f.service.send(scope, topic.id, { requestId, text: '老师,帮我看看', intent }))
.rejects.toMatchObject({ code: 'teacher_intent_invalid' });
expect((await f.service.read(scope, topic.id)).requests).toEqual([]);
}
expect(f.prepareModel).not.toHaveBeenCalled();
expect(f.readSource).not.toHaveBeenCalled();
expect(f.run).not.toHaveBeenCalled();
});
it.each(['unknown', '', null, 1, {}])('rejects malformed request intent %j before any model preparation', async (intent) => {
const f = await fixture();
const scope = newScope(f);
const topic = await f.service.create(scope);
await expect(f.service.send(scope, topic.id, { requestId, text: '老师,帮我看看', intent: intent as TeacherRequestIntent }))
.rejects.toMatchObject({ code: 'teacher_intent_invalid' });
expect(f.prepareModel).not.toHaveBeenCalled();
expect(f.run).not.toHaveBeenCalled();
});
it('budgets the round-specific instructions and honestly starts from ideas with no source', () => {
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(() => 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) => {
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(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);
});
});
describe('project teacher check-ins', () => {
const requestId = '22222222-2222-4222-8222-222222222222';
const nextRequestId = '33333333-3333-4333-8333-333333333333';
const thirdRequestId = '44444444-4444-4444-8444-444444444444';
const projectScope = (f: Awaited<ReturnType<typeof fixture>>): TeacherScope => ({
projectId: f.scope.projectId, sourceId: 'project', role: 'teacher',
});
const input = (f: Awaited<ReturnType<typeof fixture>>, id = requestId) => ({
requestId: id, sourceConversationId: f.scope.sourceId,
});
const settle = async (f: Awaited<ReturnType<typeof fixture>>, topic: TeacherTopic) => {
f.finish();
await vi.waitFor(async () => expect((await f.service.read(projectScope(f), topic.id)).requests.at(-1)?.status).toBe('completed'));
return await f.service.read(projectScope(f), topic.id);
};
const startClock = () => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date('2026-09-22T12:00:00Z'));
};
it('continues the selected teacher history with a real configured-model call and no fabricated student turn', async () => {
const f = await fixture();
const scope = projectScope(f);
const selected = await f.service.create(scope);
await f.service.send(scope, selected.id, { requestId, text: '变量是什么意思?' });
await settle(f, selected);
await f.service.create(scope);
await f.service.read(scope, selected.id);
f.replyWith('你已经在试着保存数字了。想想这个数字要在什么时候改变?');
const result = await f.service.checkIn(scope, input(f, nextRequestId));
expect(result.topic?.id).toBe(selected.id);
const saved = await settle(f, result.topic!);
expect(saved.requests.at(-1)).toMatchObject({
intent: 'check-in', text: '', references: [], sourceConversationId: f.scope.sourceId, status: 'completed',
});
expect(saved.requests.at(-1)?.checkInSourceFingerprint).toMatch(/^[a-f0-9]{64}$/);
const messages = f.run.mock.calls[1][0];
expect(messages[0].content).toContain('通过问题引导思考');
expect(messages[0].content).toContain('使用具体的小例子');
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(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('创建计数器'), expect.stringContaining('变量是什么意思?'), '当前问题:\n继续说']);
expect(followup.messages.filter((message) => message.role === 'assistant').map((message) => message.content))
.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 () => {
const f = await fixture();
const scope = projectScope(f);
const [first, duplicate, competing] = await Promise.all([
f.service.checkIn(scope, input(f)), f.service.checkIn(scope, input(f)),
f.service.checkIn(scope, input(f, nextRequestId)),
]);
expect(first.topic?.id).toBe(duplicate.topic?.id);
expect(first.topic?.requests).toHaveLength(1);
expect(competing).toEqual({ topic: null, skipped: 'busy' });
expect(f.run).toHaveBeenCalledOnce();
expect((await f.service.list(scope)).items).toHaveLength(1);
expect((await f.service.list(scope)).items[0].title).toBe('和智能体聊聊');
await f.service.create(scope);
expect((await f.service.checkIn(scope, input(f))).topic?.id).toBe(first.topic?.id);
await expect(f.service.checkIn(scope, { ...input(f), sourceConversationId: thirdRequestId }))
.rejects.toMatchObject({ code: 'teacher_request_conflict' });
});
it('enforces project-wide cooldown and ignores cursor churn until completed text changes', async () => {
startClock();
const f = await fixture();
const scope = projectScope(f);
const first = await f.service.checkIn(scope, input(f));
await settle(f, first.topic!);
await f.service.create(scope);
expect(await f.service.checkIn(scope, input(f, nextRequestId))).toEqual({ topic: null, skipped: 'cooldown' });
vi.setSystemTime(Date.now() + TEACHER_CHECK_IN_INTERVAL_MS);
f.readSource.mockResolvedValue({ ...context, cursor: { workerGeneration: 90, seq: 500 } });
expect(await f.service.checkIn(scope, input(f, nextRequestId))).toEqual({ topic: null, skipped: 'unchanged' });
expect(f.run).toHaveBeenCalledOnce();
f.readSource.mockResolvedValue({ ...context, messages: [...context.messages, { id: 'new', role: 'assistant', text: '添加了重置按钮。' }] });
const next = await f.service.checkIn(scope, input(f, nextRequestId));
expect(next.topic?.id).not.toBe(first.topic?.id);
expect(f.run).toHaveBeenCalledTimes(2);
expect(JSON.stringify(f.run.mock.calls[1][0])).toContain('添加了重置按钮');
});
it('uses persisted history for request deduplication, cooldown, and unchanged-source checks after restart', async () => {
startClock();
const f = await fixture();
const scope = projectScope(f);
const first = await f.service.checkIn(scope, input(f));
await settle(f, first.topic!);
const restarted = await f.restart();
expect((await restarted.checkIn(scope, input(f))).topic?.id).toBe(first.topic?.id);
expect(await restarted.checkIn(scope, input(f, nextRequestId))).toEqual({ topic: null, skipped: 'cooldown' });
vi.setSystemTime(Date.now() + TEACHER_CHECK_IN_INTERVAL_MS);
expect(await restarted.checkIn(scope, input(f, nextRequestId))).toEqual({ topic: null, skipped: 'unchanged' });
expect(f.run).toHaveBeenCalledOnce();
});
it('follows up on unchanged work after fifteen minutes instead of suppressing that source forever', async () => {
startClock();
const f = await fixture();
const scope = projectScope(f);
const first = await f.service.checkIn(scope, input(f));
await settle(f, first.topic!);
const restarted = await f.restart();
vi.setSystemTime(Date.parse(first.topic!.requests[0].createdAt) + TEACHER_UNCHANGED_CHECK_IN_INTERVAL_MS - 1);
expect(await restarted.checkIn(scope, input(f, nextRequestId))).toEqual({ topic: null, skipped: 'unchanged' });
vi.setSystemTime(Date.now() + 1);
expect((await restarted.checkIn(scope, input(f, nextRequestId))).topic?.id).toBe(first.topic?.id);
expect(f.run).toHaveBeenCalledTimes(2);
});
it('counts a new teacher discussion as progress even when operation text has not changed', async () => {
startClock();
const f = await fixture();
const scope = projectScope(f);
const first = await f.service.checkIn(scope, input(f));
await settle(f, first.topic!);
await f.service.send(scope, first.topic!.id, { requestId: nextRequestId, sourceConversationId: f.scope.sourceId, text: '我想让宠物跳起来的时候有个小惊喜' });
await settle(f, first.topic!);
vi.setSystemTime(Date.now() + TEACHER_CHECK_IN_INTERVAL_MS);
expect((await f.service.checkIn(scope, input(f, thirdRequestId))).topic?.id).toBe(first.topic?.id);
expect(f.run).toHaveBeenCalledTimes(3);
expect(JSON.stringify(f.run.mock.calls[2][0])).toContain('小惊喜');
});
it('can follow an existing teacher discussion before the first completed operation message', async () => {
const f = await fixture();
const scope = projectScope(f);
const topic = await f.service.create(scope);
await f.service.send(scope, topic.id, { requestId, sourceConversationId: f.scope.sourceId, text: '我想做一个养宠物的游戏' });
await settle(f, topic);
f.readSource.mockResolvedValue({ ...context, messages: [] });
expect((await f.service.checkIn(scope, input(f, nextRequestId))).topic?.id).toBe(topic.id);
expect(f.run).toHaveBeenCalledTimes(2);
});
it('does not create a topic or call a model without completed source text', async () => {
const f = await fixture();
f.readSource.mockResolvedValue({ ...context, messages: [] });
expect(await f.service.checkIn(projectScope(f), input(f))).toEqual({ topic: null, skipped: 'no-context' });
expect((await f.service.list(projectScope(f))).items).toEqual([]);
expect(f.prepareModel).not.toHaveBeenCalled();
expect(f.run).not.toHaveBeenCalled();
});
it('skips archived or disabled sources and rejects sources outside the project before reading context', async () => {
const f = await fixture();
const scope = projectScope(f);
await expect(f.service.checkIn(scope, { ...input(f), sourceConversationId: thirdRequestId }))
.rejects.toMatchObject({ code: 'teacher_source_not_found' });
const conversations = f.projects.conversationStore(f.created.project.path);
await conversations.patchMetadata(f.scope.sourceId, { archivedAt: new Date().toISOString() });
expect(await f.service.checkIn(scope, input(f))).toEqual({ topic: null, skipped: 'archived' });
await conversations.patchMetadata(f.scope.sourceId, { archivedAt: null });
f.disable();
expect(await f.service.checkIn(scope, input(f))).toEqual({ topic: null, skipped: 'disabled' });
expect(f.readSource).not.toHaveBeenCalled();
expect(f.prepareModel).not.toHaveBeenCalled();
});
it('rejects friend, preview, legacy-source scopes and ordinary messages that try to bypass check-in guards', async () => {
const f = await fixture();
for (const scope of [{ projectId: 'preview', sourceId: 'preview' }, f.scope]) {
await expect(f.service.checkIn(scope, input(f))).rejects.toMatchObject({ code: 'teacher_intent_invalid' });
}
const scope = projectScope(f);
const topic = await f.service.create(scope);
await expect(f.service.send(scope, topic.id, { ...input(f), intent: 'check-in', text: '' }))
.rejects.toMatchObject({ code: 'teacher_intent_invalid' });
expect(f.readSource).not.toHaveBeenCalled();
expect(f.prepareModel).not.toHaveBeenCalled();
});
it('waits for manual request preparation and skips while any teacher topic is answering', async () => {
const f = await fixture();
const scope = projectScope(f);
const answering = await f.service.create(scope);
await f.service.create(scope);
let releaseSource!: (source: TeacherSourceContext) => void;
f.readSource.mockImplementationOnce(() => new Promise((resolve) => { releaseSource = resolve; }));
const manual = f.service.send(scope, answering.id, { requestId, text: '解释一下', sourceConversationId: f.scope.sourceId });
await vi.waitFor(() => expect(f.readSource).toHaveBeenCalledOnce());
const check = f.service.checkIn(scope, input(f, nextRequestId));
releaseSource(context);
await manual;
expect(await check).toEqual({ topic: null, skipped: 'busy' });
expect(f.run).toHaveBeenCalledOnce();
});
it('does not persist or run a check-in when the account changes during preparation', async () => {
const f = await fixture();
f.prepareModel.mockImplementationOnce(async () => {
f.switchAccount();
return { inputLimit: 8000, run: f.run };
});
await expect(f.service.checkIn(projectScope(f), input(f))).rejects.toMatchObject({ code: 'teacher_account_changed' });
expect(f.run).not.toHaveBeenCalled();
const list = await f.service.list(projectScope(f));
expect((await f.service.read(projectScope(f), list.items[0].id)).requests).toEqual([]);
});
it('cancels an accepted check-in when its source is deleted and never restarts it', async () => {
const f = await fixture();
const scope = projectScope(f);
const first = await f.service.checkIn(scope, input(f));
await f.service.removeSource(f.scope.projectId, f.scope.sourceId);
expect((await f.service.read(scope, first.topic!.id)).requests[0].status).toBe('cancelled');
expect((await f.service.checkIn(scope, input(f))).topic?.requests[0].status).toBe('cancelled');
expect(f.run).toHaveBeenCalledOnce();
});
});
it('accepts teacher check-ins through the project endpoint and returns skip results without a model call', async () => {
const f = await fixture();
const server = createServer((req, res) => {
void handleCodingTeacherRoutes(req, res, new URL(req.url!, 'http://localhost'), {
codingProducts: { teacher: f.service },
} as HostApiContext);
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
if (!address || typeof address === 'string') throw new Error('no address');
const url = `http://127.0.0.1:${address.port}/api/coding/projects/${f.scope.projectId}/teacher-check-in`;
const input = { requestId: '22222222-2222-4222-8222-222222222222', sourceConversationId: f.scope.sourceId };
const post = () => fetch(url, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input),
});
try {
expect((await fetch(url)).status).toBe(405);
f.readSource.mockResolvedValueOnce({ ...context, messages: [] });
const skipped = await post();
expect(skipped.status).toBe(200);
expect(await skipped.json()).toEqual({ topic: null, skipped: 'no-context' });
expect(f.run).not.toHaveBeenCalled();
const accepted = await post();
expect(accepted.status).toBe(200);
const result = await accepted.json();
expect(result.topic.requests[0]).toMatchObject({ intent: 'check-in', text: '', status: 'running' });
expect((await (await post()).json()).topic.id).toBe(result.topic.id);
expect(f.run).toHaveBeenCalledOnce();
} finally {
server.closeAllConnections();
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
}
});
describe('teacher context and wire contract', () => {
it('takes only complete user/assistant text and preserves the read cursor', () => {
const snapshot = {
nodes: [
{
kind: 'message',
id: 'u',
role: 'user',
status: 'complete',
blocks: [{ kind: 'text', status: 'complete', text: '用户问题' }],
},
{
kind: 'message',
id: 'a',
role: 'assistant',
status: 'complete',
blocks: [
{ kind: 'thinking', status: 'complete', text: 'private' },
{ kind: 'text', status: 'complete', text: '完整回答' },
],
},
{
kind: 'message',
id: 'live',
role: 'assistant',
status: 'streaming',
blocks: [{ kind: 'text', status: 'streaming', text: '未完成' }],
},
{ kind: 'tool', id: 'tool', args: { password: 'secret' } },
],
cursor: { workerGeneration: 2, seq: 10 },
} as ConversationSnapshot;
const selected = sourceContext(snapshot);
expect(selected.messages.map((message) => message.text)).toEqual(['用户问题', '完整回答']);
expect(selected.cursor).toEqual(snapshot.cursor);
});
it('trims old source messages but retains instructions, Skill, explicit quote and question', () => {
const references = [{ kind: 'code' as const, text: 'count += 1' }];
const budget = estimateTeacherTokens(compileTeacherContext(
definition, context, [], '为什么这样?', references
).messages);
const compiled = compileTeacherContext(
definition,
{
...context,
messages: [{ id: 'old', role: 'user', text: 'old'.repeat(4000) }, ...context.messages],
},
[],
'为什么这样?',
references,
budget
);
const text = JSON.stringify(compiled.messages);
expect(text).toContain('通过问题引导思考');
expect(text).toContain('使用具体的小例子');
expect(text).toContain('count += 1');
expect(compiled.messages[0].content).not.toContain(TEACHER_BEHAVIOR_PROMPT);
expect(compiled.includedSourceMessageIds).toEqual(['source-user']);
expect(compiled.omittedMessages).toBe(1);
expect(() => compileTeacherContext(definition, context, [], 'x'.repeat(18000), [])).toThrow(
'超过上下文预算'
);
});
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(
async () =>
new Response(
'data: {"choices":[{"delta":{"reasoning_content":"hidden","content":"答案"}}]}\n\ndata: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":3}}\n\ndata: [DONE]\n\n'
)
);
const usage = await streamTeacherReply(
{ base_url: 'https://gateway.test/v1', api_key: 'private' },
'model',
{},
1500,
[{ role: 'user', content: '问题' }],
new AbortController().signal,
(delta) => text.push(delta),
fake
);
expect(text).toEqual(['答案']);
expect(usage).toEqual({ inputTokens: 10, outputTokens: 3 });
const input = fake.mock.calls[0] as unknown as [string, RequestInit];
const body = JSON.parse(input[1].body as string);
expect(body).not.toHaveProperty('tools');
expect(input[0]).toBe('https://gateway.test/v1/chat/completions');
await expect(
streamTeacherReply(
{ base_url: 'https://gateway.test/v1', api_key: 'private' },
'model',
{},
1500,
[],
new AbortController().signal,
() => undefined,
async () => new Response('data: {"choices":[{"delta":{"content":"半句"}}]}\n\n')
)
).rejects.toMatchObject({ code: 'teacher_stream_interrupted' });
});
it('accepts only the exact credential-free preview link', () => {
expect(
parseNianCodeDeepLinkUrl('niancode://coding-teacher/preview?draft_revision=2')
).toMatchObject({ type: 'teacher-preview', draftRevision: 2 });
expect(
parseNianCodeDeepLinkUrl('niancode://coding-teacher/preview?draft_revision=2&token=x')
).toBeNull();
expect(
parseNianCodeDeepLinkUrl('niancode://coding-teacher/preview?draft_revision=0')
).toBeNull();
});
it('creates one default and preserves all legacy identities without reviving disabled Agents', () => {
const base = createCodingProjectConfigV2();
expect(base.agents).toHaveLength(1);
expect(ensureDefaultCodingAgent(base)).toBe(base);
const legacy = {
...base,
defaultAgentId: undefined,
agents: [
{ ...base.agents[0], id: 'old', enabled: false },
{ ...base.agents[0], id: 'existing', pinned: true },
],
};
const normalized = ensureDefaultCodingAgent(legacy);
expect(normalized.defaultAgentId).toBe('existing');
expect(normalized.agents).toEqual(legacy.agents);
const disabled = ensureDefaultCodingAgent({ ...legacy, agents: [legacy.agents[0]] });
expect(disabled.agents[0].enabled).toBe(false);
expect(disabled.agents).toHaveLength(2);
});
});
import {mkdir, writeFile} from 'node:fs/promises';
import {readCodingConversationHistory} from '../../electron/coding-projects/conversation-history';
import {getPiManagedPaths} from '../../electron/coding-runtime/pi/resource-loader';
it('reads only the durable active branch without starting a worker', async()=>{
const f=await fixture();
const metadata=(await f.projects.conversationStore(f.created.project.path).get(f.scope.sourceId))!;
const folder=path.join(getPiManagedPaths(f.root).sessionsDir,f.scope.projectId);await mkdir(folder,{recursive:true});
const entries=[
{type:'session',id:'session'},
{type:'message',id:'root',parentId:null,message:{role:'user',content:'开始学习'}},
{type:'message',id:'abandoned',parentId:'root',message:{role:'user',content:'旧分支内容'}},
{type:'message',id:'active',parentId:'root',message:{role:'user',content:'当前分支内容'}},
];
await writeFile(path.join(folder,'session-test.jsonl'),entries.map(entry=>JSON.stringify(entry)).join('\n'));
const snapshot=await readCodingConversationHistory(f.root,f.scope.projectId,{...metadata,sessionKey:'session-test'});
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';
it('serves topic acceptance and SSE snapshots without cancelling on stream close',async()=>{
const f=await fixture();
const server=createServer((req,res)=>{void handleCodingTeacherRoutes(req,res,new URL(req.url!,'http://localhost'),{codingProducts:{teacher:f.service}} as HostApiContext);});
await new Promise<void>(resolve=>server.listen(0,'127.0.0.1',resolve));
const address=server.address();if(!address||typeof address==='string')throw new Error('no address');
const origin='http://127.0.0.1:'+address.port;
const base=origin+'/api/coding/projects/'+f.scope.projectId+'/conversations/'+f.scope.sourceId+'/teacher-topics';
try {
const created=await fetch(base,{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});expect(created.status).toBe(201);
const topic=await created.json();const requestId='22222222-2222-4222-8222-222222222222';
const accepted=await fetch(base+'/'+topic.id+'/messages',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({requestId,text:'讲解一下'})});expect(accepted.status).toBe(202);
const stream=await fetch(base+'/'+topic.id+'/events');expect(stream.headers.get('content-type')).toContain('text/event-stream');
const reader=stream.body!.getReader();const chunk=await reader.read();expect(new TextDecoder().decode(chunk.value)).toContain('event: snapshot');await reader.cancel();
expect((await f.service.read(f.scope,topic.id)).requests[0].status).toBe('running');
const cancelled=await fetch(base+'/'+topic.id+'/requests/'+requestId+'/cancel',{method:'POST'});expect(cancelled.status).toBe(200);
await vi.waitFor(async()=>expect((await f.service.read(f.scope,topic.id)).requests[0].status).toBe('cancelled'));
expect((await fetch(origin+'/api/coding/teacher/config',{method:'POST'})).status).toBe(405);
} finally {server.closeAllConnections();await new Promise<void>((resolve,reject)=>server.close(error=>error?reject(error):resolve()));}
});
it.each(['agent', 'teacher'] as const)('routes project-level %s config and topic messages without a source-scoped URL', async (role) => {
const f = await fixture();
const server = createServer((req, res) => {
void handleCodingTeacherRoutes(req, res, new URL(req.url!, 'http://localhost'), {
codingProducts: { teacher: f.service },
} as HostApiContext);
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
if (!address || typeof address === 'string') throw new Error('no address');
const origin = 'http://127.0.0.1:' + address.port;
const base = `${origin}/api/coding/projects/${f.scope.projectId}/${role}-topics`;
const post = (url: string, body = {}) => fetch(url, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
try {
const config = await fetch(`${origin}/api/coding/teacher/config`);
expect(config.status).toBe(200);
expect((await config.json()).definition.teacher_id).toBe('coding-teacher');
expect((await post(`${origin}/api/coding/teacher/config`)).status).toBe(405);
const created = await post(base);
expect(created.status).toBe(201);
const topic = await created.json();
expect(topic).toMatchObject({ sourceConversationId: 'project', projectId: f.scope.projectId });
const listed = await fetch(base);
expect((await listed.json()).items.map((item: { id: string }) => item.id)).toEqual([topic.id]);
expect((await fetch(`${base}/${topic.id}`)).status).toBe(200);
expect(f.run).not.toHaveBeenCalled();
const requestId = '22222222-2222-4222-8222-222222222222';
const input = { requestId, text: '我想聊聊', sourceConversationId: f.scope.sourceId };
expect((await post(`${base}/${topic.id}/messages`, input)).status).toBe(202);
expect((await post(`${base}/${topic.id}/messages`, input)).status).toBe(202);
expect(f.run).toHaveBeenCalledOnce();
const accepted = await fetch(`${base}/${topic.id}`);
expect((await accepted.json()).requests[0].sourceConversationId).toBe(f.scope.sourceId);
expect((await post(`${base}/${topic.id}/requests/${requestId}/cancel`)).status).toBe(200);
await vi.waitFor(async () => expect((await f.service.read({ projectId: f.scope.projectId, sourceId: 'project' }, topic.id)).requests[0].status).toBe('cancelled'));
} finally {
server.closeAllConnections();
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
}
});
describe('structured teacher service integration', () => {
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),
sourceContext: { ...context, messages: [
{ id: 'current-question', role: 'user', text: '创建计数器' },
{ id: 'long-code', role: 'assistant', text: 'const title = "game";\n'.repeat(500) },
] },
});
const scope = { ...f.scope, sourceId: 'project', role: 'teacher' as const };
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 } : {}),
});
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).toBeUndefined();
expect(saved.requests[0].presentation).toBe('reply-v1');
}
expect(saved.requests[0].response).not.toContain(preamble);
} finally {
vi.restoreAllMocks();
}
});
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 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 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);
});
});
describe('project consultations with selected cloud teachers', () => {
it('persists malformed discussion output without putting it into future model context', async () => {
const f = await fixture();
const scope = { ...f.scope, sourceId: 'project' };
const selected = await f.service.create(scope);
const raw = '{"reply":"这段正文完整。","tool":{"privateDiagnostic":"only-in-original"';
f.replyWith(raw);
await f.service.send(scope, selected.id, {
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'));
const restarted = await f.restart();
const saved = await restarted.read(scope, selected.id);
expect(saved.requests[0]).toMatchObject({ response: '这段正文完整。', unparsedResponse: raw });
const compiled = compileTeacherContext(saved.definition, context, saved.requests, '继续聊', []);
expect(JSON.stringify(compiled.messages)).not.toContain('only-in-original');
expect(JSON.stringify(compiled.messages)).toContain('这段正文完整。');
});
it.each(['local', 'yuxi'])('keeps the published identity and project tools on the %s runtime', async (runtime) => {
const f = await fixture({ cloudTeacher: runtime === 'yuxi', mockCloud: runtime === 'yuxi' });
const scope = { ...f.scope, sourceId: 'project' };
const topic = await f.service.create(scope, undefined, undefined, runtime === 'yuxi' ? 9 : undefined);
expect(topic.role).toBeUndefined();
expect(topic.definition.teacher_id).toBe('coding-teacher');
expect((await f.service.list(scope)).items.find((item) => item.id === topic.id)?.teacherId)
.toBe(runtime === 'yuxi' ? 'cloud-teacher' : undefined);
await f.service.send(scope, topic.id, { requestId: crypto.randomUUID(), text: '你觉得怎么样?', sourceConversationId: f.scope.sourceId });
const messages = f.run.mock.calls[0][0];
expect(messages[0].content).not.toContain(TEACHER_BEHAVIOR_PROMPT);
expect(messages[0].content).toContain('你可以通过只读工具');
if (runtime === 'yuxi') {
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: true });
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).not.toContain(TEACHER_BEHAVIOR_PROMPT);
expect(f.prepareCloud.mock.calls[0][3]).toMatchObject({ projectPath: f.created.project.path, source: context });
expect(f.prepareModel).not.toHaveBeenCalled();
f.finish();
await vi.waitFor(async () => expect((await f.service.read(scope, topic.id)).requests[0].status).toBe('completed'));
});
});