438 lines
18 KiB
TypeScript
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()));}
|
|
});
|