// @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 } from '../../electron/coding-teacher/service'; import { TeacherTopicStore } from '../../electron/coding-teacher/store'; import { compileTeacherContext, sourceContext } from '../../electron/coding-teacher/context'; import { streamTeacherReply } from '../../electron/coding-teacher/model-runner'; import { TeacherError } from '../../electron/coding-teacher/config-client'; 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 { TeacherDefinition, TeacherSourceContext } from '../../shared/coding-teacher'; 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 }))); }); async function fixture() { 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; const run = vi.fn(async (_messages, signal: AbortSignal, onText: (text: string) => void) => { onText('计数器保存一个数字。'); await new Promise((resolve, reject) => { finish = resolve; signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true }); }); return { inputTokens: 20, outputTokens: 10 }; }); const account = { id: '11111111-1111-4111-8111-111111111111', binding: { accountKey: 'test', epoch: 1 }, }; const service = 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 }), version: async (_account, v) => ({ version: v, payload: { ...definition, name: '老师 v' + v }, }), preview: async (_account, revision) => { if (revision !== 2) throw new TeacherError(409, 'teacher_draft_changed', '草稿变化'); return { draft_revision: revision, payload: definition }; }, readSource: async () => structuredClone(context), prepareModel: async () => ({ inputLimit: 8000, run }), }); services.push(service); return { root, projects, created, scope, service, run, finish: () => finish(), disable: () => { enabled = false; }, nextVersion: () => { version++; }, switchAccount: () => { accountCurrent = false; }, }; } describe('cloud coding teacher', () => { 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('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 compiled = compileTeacherContext( definition, { ...context, messages: [{ id: 'old', role: 'user', text: 'old'.repeat(4000) }, ...context.messages], }, [], '为什么这样?', [{ kind: 'code', text: 'count += 1' }], 1200 ); const text = JSON.stringify(compiled.messages); expect(text).toContain('通过问题引导思考'); expect(text).toContain('使用具体的小例子'); expect(text).toContain('count += 1'); expect(compiled.omittedMessages).toBe(1); expect(() => compileTeacherContext(definition, context, [], 'x'.repeat(9000), [])).toThrow( '超过上下文预算' ); }); 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'); }); 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()));} });