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

519 lines
24 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, TeacherRequest, TeacherSourceContext } 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 })));
});
async function fixture({ durableSource = false, sourceContext = context, liveModel = false } = {}) {
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: durableSource ? undefined : async () => structuredClone(sourceContext),
prepareModel: liveModel ? undefined : 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('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('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('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 compiled = compileTeacherContext(definition, context, history, '你刚才的建议是什么意思?', [], 2600, true);
expect(JSON.stringify(compiled.messages)).toContain('创建计数器');
expect(JSON.stringify(compiled.messages)).toContain('重力的问题');
expect(JSON.stringify(compiled.messages)).toContain('建议使用时间步长');
});
it('sends no tools, ignores reasoning deltas, and requires a terminal stream', async () => {
const text: string[] = [];
const fake = vi.fn(
async () =>
new Response(
'data: {"choices":[{"delta":{"reasoning_content":"hidden","content":"答案"}}]}\n\ndata: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":3}}\n\ndata: [DONE]\n\n'
)
);
const usage = await streamTeacherReply(
{ base_url: 'https://gateway.test/v1', api_key: 'private' },
'model',
{},
1500,
[{ role: 'user', content: '问题' }],
new AbortController().signal,
(delta) => text.push(delta),
fake
);
expect(text).toEqual(['答案']);
expect(usage).toEqual({ inputTokens: 10, outputTokens: 3 });
const input = fake.mock.calls[0] as unknown as [string, RequestInit];
const body = JSON.parse(input[1].body as string);
expect(body).not.toHaveProperty('tools');
expect(input[0]).toBe('https://gateway.test/v1/chat/completions');
await expect(
streamTeacherReply(
{ base_url: 'https://gateway.test/v1', api_key: 'private' },
'model',
{},
1500,
[],
new AbortController().signal,
() => undefined,
async () => new Response('data: {"choices":[{"delta":{"content":"半句"}}]}\n\n')
)
).rejects.toMatchObject({ code: 'teacher_stream_interrupted' });
});
it('accepts only the exact credential-free preview link', () => {
expect(
parseNianCodeDeepLinkUrl('niancode://coding-teacher/preview?draft_revision=2')
).toMatchObject({ type: 'teacher-preview', draftRevision: 2 });
expect(
parseNianCodeDeepLinkUrl('niancode://coding-teacher/preview?draft_revision=2&token=x')
).toBeNull();
expect(
parseNianCodeDeepLinkUrl('niancode://coding-teacher/preview?draft_revision=0')
).toBeNull();
});
it('creates one default and preserves all legacy identities without reviving disabled Agents', () => {
const base = createCodingProjectConfigV2();
expect(base.agents).toHaveLength(1);
expect(ensureDefaultCodingAgent(base)).toBe(base);
const legacy = {
...base,
defaultAgentId: undefined,
agents: [
{ ...base.agents[0], id: 'old', enabled: false },
{ ...base.agents[0], id: 'existing', pinned: true },
],
};
const normalized = ensureDefaultCodingAgent(legacy);
expect(normalized.defaultAgentId).toBe('existing');
expect(normalized.agents).toEqual(legacy.agents);
const disabled = ensureDefaultCodingAgent({ ...legacy, agents: [legacy.agents[0]] });
expect(disabled.agents[0].enabled).toBe(false);
expect(disabled.agents).toHaveLength(2);
});
});
import {mkdir, writeFile} from 'node:fs/promises';
import {readCodingConversationHistory} from '../../electron/coding-projects/conversation-history';
import {getPiManagedPaths} from '../../electron/coding-runtime/pi/resource-loader';
it('reads only the durable active branch without starting a worker', async()=>{
const f=await fixture();
const metadata=(await f.projects.conversationStore(f.created.project.path).get(f.scope.sourceId))!;
const folder=path.join(getPiManagedPaths(f.root).sessionsDir,f.scope.projectId);await mkdir(folder,{recursive:true});
const entries=[
{type:'session',id:'session'},
{type:'message',id:'root',parentId:null,message:{role:'user',content:'开始学习'}},
{type:'message',id:'abandoned',parentId:'root',message:{role:'user',content:'旧分支内容'}},
{type:'message',id:'active',parentId:'root',message:{role:'user',content:'当前分支内容'}},
];
await writeFile(path.join(folder,'session-test.jsonl'),entries.map(entry=>JSON.stringify(entry)).join('\n'));
const snapshot=await readCodingConversationHistory(f.root,f.scope.projectId,{...metadata,sessionKey:'session-test'});
expect(sourceContext(snapshot).messages.map(message=>message.text)).toEqual(['开始学习','当前分支内容']);
expect(snapshot.worker.status).toBe('stopped');
});
it('sends durable source history through the real teacher service without a running worker', async () => {
const f = await fixture({ durableSource: true });
await f.projects.conversationStore(f.created.project.path).ensureSessionBinding(f.scope.sourceId,
async () => ({ sessionKey: 'teacher-source', piSessionId: 'teacher-source' }));
const folder = path.join(getPiManagedPaths(f.root).sessionsDir, f.scope.projectId);
await mkdir(folder, { recursive: true });
await writeFile(path.join(folder, 'teacher-source.jsonl'), [
{ type: 'session', id: 'teacher-source' },
{ type: 'message', id: 'root', parentId: null, message: { role: 'user', content: '分析小鸟游戏' } },
{ type: 'message', id: 'other', parentId: 'root', message: { role: 'user', content: '废弃的分支' } },
{ type: 'message', id: 'active', parentId: 'root', message: { role: 'assistant', content: [{ type: 'text', text: '检查碰撞检测' }] } },
].map(entry => JSON.stringify(entry)).join('\n'));
const topic = await f.service.create(f.scope);
await f.service.send(f.scope, topic.id, { requestId: '22222222-2222-4222-8222-222222222222', text: '老师怎么看?' });
const sent = JSON.stringify(f.run.mock.calls[0][0]);
expect(sent).toContain('分析小鸟游戏');
expect(sent).toContain('检查碰撞检测');
expect(sent).not.toContain('废弃的分支');
f.finish();
});
import {createServer} from 'node:http';
import {handleCodingTeacherRoutes} from '../../electron/api/routes/coding-teacher';
import type {HostApiContext} from '../../electron/api/context';
it('serves topic acceptance and SSE snapshots without cancelling on stream close',async()=>{
const f=await fixture();
const server=createServer((req,res)=>{void handleCodingTeacherRoutes(req,res,new URL(req.url!,'http://localhost'),{codingProducts:{teacher:f.service}} as HostApiContext);});
await new Promise<void>(resolve=>server.listen(0,'127.0.0.1',resolve));
const address=server.address();if(!address||typeof address==='string')throw new Error('no address');
const origin='http://127.0.0.1:'+address.port;
const base=origin+'/api/coding/projects/'+f.scope.projectId+'/conversations/'+f.scope.sourceId+'/teacher-topics';
try {
const created=await fetch(base,{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});expect(created.status).toBe(201);
const topic=await created.json();const requestId='22222222-2222-4222-8222-222222222222';
const accepted=await fetch(base+'/'+topic.id+'/messages',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({requestId,text:'讲解一下'})});expect(accepted.status).toBe(202);
const stream=await fetch(base+'/'+topic.id+'/events');expect(stream.headers.get('content-type')).toContain('text/event-stream');
const reader=stream.body!.getReader();const chunk=await reader.read();expect(new TextDecoder().decode(chunk.value)).toContain('event: snapshot');await reader.cancel();
expect((await f.service.read(f.scope,topic.id)).requests[0].status).toBe('running');
const cancelled=await fetch(base+'/'+topic.id+'/requests/'+requestId+'/cancel',{method:'POST'});expect(cancelled.status).toBe(200);
await vi.waitFor(async()=>expect((await f.service.read(f.scope,topic.id)).requests[0].status).toBe('cancelled'));
expect((await fetch(origin+'/api/coding/teacher/config',{method:'POST'})).status).toBe(405);
} finally {server.closeAllConnections();await new Promise<void>((resolve,reject)=>server.close(error=>error?reject(error):resolve()));}
});