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

438 lines
18 KiB
TypeScript

// @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<void>((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<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()));}
});