fix(coding-teacher): restore context and read project files
This commit is contained in:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user