feat: organize project conversations and add coding teacher side chat
This commit is contained in:
437
tests/unit/coding-teacher.test.ts
Normal file
437
tests/unit/coding-teacher.test.ts
Normal file
@@ -0,0 +1,437 @@
|
||||
// @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()));}
|
||||
});
|
||||
Reference in New Issue
Block a user