fix(coding-teacher): restore context and read project files

This commit is contained in:
2026-09-22 19:12:59 +08:00
parent 7f5131e92f
commit 44e754a43e
14 changed files with 626 additions and 37 deletions

View File

@@ -18,7 +18,9 @@ import {
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 { 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';
@@ -54,7 +56,7 @@ 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() {
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(
@@ -106,8 +108,8 @@ async function fixture() {
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 }),
readSource: durableSource ? undefined : async () => structuredClone(sourceContext),
prepareModel: liveModel ? undefined : async () => ({ inputLimit: 8000, run }),
});
services.push(service);
return {
@@ -130,6 +132,54 @@ async function fixture() {
};
}
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(
@@ -327,6 +377,17 @@ describe('teacher context and wire contract', () => {
'超过上下文预算'
);
});
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(
@@ -413,6 +474,26 @@ it('reads only the durable active branch without starting a worker', async()=>{
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';