// @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'; import { TEACHER_BEHAVIOR_PROMPT } from '../../electron/coding-teacher/behavior-prompt'; import { streamTeacherReply } from '../../electron/coding-teacher/model-runner'; import { TeacherError } from '../../electron/coding-teacher/config-client'; import { consultationDefinition } from '../../electron/coding-teacher/consultation-role'; import { parseTeacherSuggestions } from '../../electron/coding-teacher/suggestions'; 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 { 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'; 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 } = {}) { 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((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, 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 }); 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( 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: '计数器保存一个数字。', }) ); }); 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: ConsultationRole[] = ['teacher', 'friend']; it('keeps teacher and friend histories and personas separate without running on open', async () => { const f = await fixture(); const teacherScope = { projectId: f.scope.projectId, sourceId: 'project', role: 'teacher' as const }; const friendScope = { ...teacherScope, role: 'friend' as const }; const teacher = await f.service.create(teacherScope); const friend = await f.service.create(friendScope); expect((await f.service.list(teacherScope)).items.map((item) => item.id)).toEqual([teacher.id]); expect((await f.service.list(friendScope)).items.map((item) => item.id)).toEqual([friend.id]); expect((await f.service.read(teacherScope, teacher.id)).role).toBe('teacher'); const onSnapshot = vi.fn(); const unsubscribe = await f.service.subscribe(friendScope, friend.id, onSnapshot); expect(onSnapshot).toHaveBeenCalledOnce(); unsubscribe(); expect(f.readSource).not.toHaveBeenCalled(); expect(f.prepareModel).not.toHaveBeenCalled(); expect(f.run).not.toHaveBeenCalled(); await expect(f.service.read(friendScope, teacher.id)).rejects.toMatchObject({ code: 'teacher_topic_not_found' }); await expect(f.service.read(teacherScope, friend.id)).rejects.toMatchObject({ code: 'teacher_topic_not_found' }); await f.service.send(teacherScope, teacher.id, { requestId, text: '我应该怎么想?' }); await f.service.send(friendScope, friend.id, { requestId, text: '你有什么感受?' }); const teacherPrompt = f.run.mock.calls[0][0][0].content; const friendPrompt = f.run.mock.calls[1][0][0].content; expect(teacherPrompt).toContain('引导思考'); expect(teacherPrompt).toContain('使用具体的小例子'); expect(friendPrompt).toContain('数字朋友'); expect(friendPrompt).toContain('不要假装运行、试玩'); expect(friendPrompt).not.toContain('使用具体的小例子'); expect(friendPrompt).not.toContain('你是编程老师'); expect(friend.definition.model).toEqual(teacher.definition.model); expect(friend.version).toBe(teacher.version); expect((await f.service.read(teacherScope, teacher.id)).requests[0].text).toBe('我应该怎么想?'); expect((await f.service.read(friendScope, friend.id)).requests[0].text).toBe('你有什么感受?'); }); 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((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: '计数器保存一个数字。' }); 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((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', role: 'friend' as const }; const topic = await f.service.create(scope); let saved!: () => void; let releaseSave!: () => void; const savedToDisk = new Promise((resolve) => { saved = resolve; }); const allowSaveReturn = new Promise((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/friend-conversations', topic.accountId, 'project', topic.id + '.json' ), 'utf8')); expect(stored.requests[0].status).toBe('cancelled'); } finally { releaseSave(); spy.mockRestore(); } }); it('derives friend configuration without mutating published teacher prompts or skills', () => { const original = structuredClone(definition); const friend = consultationDefinition(definition, 'friend'); expect(friend.teacher_id).toBe('coding-friend'); expect(friend.skills).toEqual([]); expect(definition).toEqual(original); expect(consultationDefinition(definition, 'teacher')).toBe(definition); }); }); 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>): TeacherScope => ({ projectId: f.scope.projectId, sourceId: 'project', role: 'teacher', }); const finishRequest = async (f: Awaited>, scope: TeacherScope, id: string) => { let complete!: (topic: TeacherTopic) => void; const finished = new Promise((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(JSON.stringify(f.run.mock.calls[0][0])).toContain('只返回 JSON 对象'); 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(['not JSON', JSON.stringify({ intro, questions: ['只有一个问题?'] })])('fails malformed suggestions without displaying raw output or losing usage: %s', async (invalid) => { const f = await fixture(); const scope = newScope(f); const topic = await f.service.create(scope); f.replyWith(invalid); await f.service.send(scope, topic.id, { requestId, intent: 'suggestions', text: '老师,帮我看看' }); const completed = await finishRequest(f, scope, topic.id); expect(completed.requests[0]).toMatchObject({ status: 'failed', response: '', usage: { inputTokens: 20, outputTokens: 10 } }); expect(completed.requests[0].suggestedQuestions).toBeUndefined(); expect(completed.requests[0].error).toContain('请再试一次'); const disk = JSON.parse(await readFile(path.join( f.created.project.path, '.makelore/teacher-conversations', topic.accountId, 'project', topic.id + '.json' ), 'utf8')); expect(disk.requests[0].response).toBe(''); expect(disk.requests[0].suggestedQuestions).toBeUndefined(); }); 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('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).toBeUndefined(); 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 friendScope = { ...newScope(f), role: 'friend' as const }; const previewScope = { projectId: 'preview', sourceId: 'preview' }; const friend = await f.service.create(friendScope); const legacy = await f.service.create(f.scope); const preview = await f.service.create(previewScope, 2, '示例项目'); for (const [scope, topic] of [[friendScope, friend], [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('trims and deduplicates valid suggestions, accepting plain JSON and standard JSON code fences', () => { const raw = JSON.stringify({ intro: ' ' + intro + ' ', questions: [' ' + questions[0], questions[1], questions[0] + ' '] }); expect(parseTeacherSuggestions(raw)).toEqual({ intro, questions }); expect(parseTeacherSuggestions('```json\n' + raw + '\n```')).toEqual({ intro, questions }); expect(parseTeacherSuggestions('```\n' + raw + '\n```')).toEqual({ intro, questions }); }); it.each([ null, [], { intro: '', questions }, { intro: ' '.repeat(3), questions }, { intro: '字'.repeat(401), questions }, { intro, questions: [questions[0]] }, { intro, questions: [...questions, '第三个?', '第四个?'] }, { intro, questions: [questions[0], questions[0]] }, { intro, questions: [questions[0], ''] }, { intro, questions: [questions[0], '字'.repeat(121)] }, { intro, questions: [questions[0], 1] }, ])('rejects suggestion responses that cannot become two or three short questions: %j', (value) => { expect(() => parseTeacherSuggestions(JSON.stringify(value))).toThrow('请再试一次'); }); }); describe('project teacher check-ins', () => { 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>): TeacherScope => ({ projectId: f.scope.projectId, sourceId: 'project', role: 'teacher', }); const input = (f: Awaited>, id = requestId) => ({ requestId: id, sourceConversationId: f.scope.sourceId, }); const settle = async (f: Awaited>, 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('约 120 字'); 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 [{ ...projectScope(f), role: 'friend' as const }, { 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((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((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).toContain(TEACHER_BEHAVIOR_PROMPT); expect(compiled.includedSourceMessageIds).toEqual(['source-user']); expect(compiled.omittedMessages).toBe(1); expect(() => compileTeacherContext(definition, context, [], 'x'.repeat(9000), [])).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(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((resolve,reject)=>server.close(error=>error?reject(error):resolve()));} }); it.each(['teacher', 'friend'] 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((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/${role}/config`); expect(config.status).toBe(200); expect((await config.json()).definition.teacher_id).toBe('coding-' + role); expect((await post(`${origin}/api/coding/${role}/config`)).status).toBe(405); const created = await post(base); expect(created.status).toBe(201); const topic = await created.json(); expect(topic).toMatchObject({ role, 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', role }, topic.id)).requests[0].status).toBe('cancelled')); } finally { server.closeAllConnections(); await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); } }); describe('structured teacher service integration', () => { it.each(['suggestions', 'discussion'] as const)('parses only final %s JSON after the real local runner reads a project file', async (format) => { const f = await fixture({ liveModel: true, modelInputLimit: 24000 }); 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 } : { 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); expect(pending.requests[0].response).toBe(''); expect(pending.discussion).toBeUndefined(); f.finish(); await vi.waitFor(async () => expect((await f.service.read(scope, initial.id)).requests[0].status).toBe('completed')); const offered = await f.service.read(scope, initial.id); expect(offered.discussion?.status).toBe('offered'); expect(offered.requests[0].response).toBe('我们可以先把想法放在一起。'); const active = await f.service.updateDiscussion(scope, initial.id, { toolId: offered.discussion!.id, revision: 1, action: 'enter' }); const context = { toolId: active.discussion!.id, revision: 2 }; f.replyWith(JSON.stringify({ reply: '把花园也放进来了。', tool: { ...tool, title: '小狗和花园' } })); const running = await send('还想种花', context); expect(running.discussion?.content.title).toBe('宠物游戏'); await expect(f.service.updateDiscussion(scope, initial.id, { ...context, action: 'finish' })).rejects.toMatchObject({ code: 'teacher_topic_busy' }); await f.service.cancel(scope, initial.id, running.requests.at(-1)!.id); await vi.waitFor(async () => expect((await f.service.read(scope, initial.id)).requests.at(-1)?.status).toBe('cancelled')); expect((await f.service.read(scope, initial.id)).discussion?.content.title).toBe('宠物游戏'); await send('再把花园加进来', context); f.finish(); await vi.waitFor(async () => expect((await f.service.read(scope, initial.id)).requests.at(-1)?.status).toBe('completed')); const completed = await f.service.read(scope, initial.id); expect(completed.discussion?.content.title).toBe('小狗和花园'); expect(completed.discussion?.revision).toBe(3); expect(completed.requests[0].discussionSnapshot?.title).toBe('宠物游戏'); await expect(send('旧版本', context)).rejects.toMatchObject({ code: 'teacher_discussion_changed' }); const restarted = await f.restart(); expect((await restarted.read(scope, initial.id)).discussion).toEqual(completed.discussion); }); }); 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')); }); });