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

@@ -527,7 +527,7 @@ async function installCodingFirstChatHost(
return respond(teacherTopic,201);
}
if (path.endsWith('/teacher-topics/teacher-topic/messages') && method === 'POST') {
teacherTopic={...teacherTopic,revision:2,requests:[{id:body!.requestId,text:body!.text,references:body!.references??[],createdAt:now,sourceCursor:{workerGeneration:1,seq:1},sourceCapturedAt:now,includedSourceMessageIds:[],omittedMessages:0,status:'completed',response:'先理解状态如何随点击变化,再修改代码。'}]};
teacherTopic={...teacherTopic,revision:2,requests:[{id:body!.requestId,text:body!.text,references:body!.references??[],createdAt:now,sourceCursor:{workerGeneration:1,seq:1},sourceCapturedAt:now,includedSourceMessageIds:[],omittedMessages:0,truncatedMessages:1,status:'completed',response:'先理解状态如何随点击变化,再修改代码。'}]};
return respond(teacherTopic,202);
}
if (path.endsWith('/teacher-topics/teacher-topic')) return respond(teacherTopic);
@@ -1722,9 +1722,11 @@ test('project teacher side chat returns advice to the coding draft without submi
await page.getByRole('button',{name:'问老师',exact:true}).click();
const teacher=page.getByTestId('teacher-chat-panel');
await expect(teacher.getByText('一起理解代码')).toBeVisible();
await expect(teacher.getByText('结合当前会话和项目文件答疑')).toBeVisible();
await teacher.getByRole('textbox',{name:'向老师提问'}).fill('帮我理解当前代码');
await teacher.getByRole('button',{name:'提问',exact:true}).click();
await expect(teacher.getByText('先理解状态如何随点击变化,再修改代码。')).toBeVisible();
await expect(teacher.getByText('较长的上下文已节选,老师可按需读取原文。')).toBeVisible();
await teacher.getByRole('button',{name:'带回主会话草稿'}).click();
await expect(composer).toHaveValue('保留我的草稿\n\n先理解状态如何随点击变化,再修改代码。');
const requests=(await readState(electronApp)).captured;

View File

@@ -4,6 +4,9 @@ import * as cloud from '../../electron/coding-teacher/config-client';
import * as transport from '../../electron/utils/proxy-fetch';
import { prepareTeacherModel } from '../../electron/coding-teacher/model-runner';
import type { TeacherDefinition } from '../../shared/coding-teacher';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
const account: cloud.TeacherAccount = {
id: '11111111-1111-4111-8111-111111111111',
@@ -48,6 +51,34 @@ function setup(canDisable = true) {
afterEach(() => vi.restoreAllMocks());
describe('teacher published reasoning wire contract', () => {
it('reads a current-project file requested by the model and returns its result for the answer', async () => {
const fetch = setup();
const root = await mkdtemp(path.join(tmpdir(), 'teacher-model-files-'));
try {
await writeFile(path.join(root, 'game.ts'), 'export const gravity = 0.6;');
fetch.mockResolvedValueOnce(new Response([
{ choices: [{ delta: { tool_calls: [{ index: 0, id: 'call-read', type: 'function', function: { name: 'read_project_file', arguments: '{"path":"game.' } }] } }] },
{ choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: 'ts"}' } }] }, finish_reason: 'tool_calls' }], usage: { prompt_tokens: 100, completion_tokens: 15 } },
].map(event => 'data: ' + JSON.stringify(event) + '\n\n').join('') + 'data: [DONE]\n\n'));
const prepared = await prepareTeacherModel(account, definition({ mode: 'disabled' }), {
projectPath: root,
source: { messages: [], cursor: { workerGeneration: 1, seq: 1 }, capturedAt: '2026-09-22' },
assertCurrent: () => undefined,
});
const onText = vi.fn();
await prepared.run([{ role: 'user', content: '查看 game.ts 的重力设置' }], new AbortController().signal, onText);
expect(fetch).toHaveBeenCalledTimes(2);
const first = JSON.parse(String(fetch.mock.calls[0][1]?.body));
expect(first.tools.map((tool: { function: { name: string } }) => tool.function.name)).toContain('read_project_file');
const second = JSON.parse(String(fetch.mock.calls[1][1]?.body));
expect(second.messages.at(-1)).toMatchObject({ role: 'tool', tool_call_id: 'call-read' });
expect(second.messages.at(-1).content).toContain('export const gravity = 0.6;');
expect(onText).toHaveBeenCalledWith('Explanation');
} finally {
await rm(root, { recursive: true, force: true });
}
});
it.each([
[{ mode: 'enabled', effort: null }, { thinking: { type: 'enabled' } }],
[{ mode: 'enabled' }, { thinking: { type: 'enabled' } }],
@@ -76,4 +107,4 @@ describe('teacher published reasoning wire contract', () => {
.rejects.toThrow('老师所用思考选项已不可用,请联系运营调整。');
expect(fetch).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,147 @@
// @vitest-environment node
import { afterEach, describe, expect, it, vi } from 'vitest';
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { tmpdir } from 'node:os';
import { createTeacherReadTools } from '../../electron/coding-teacher/read-tools';
import { streamTeacherReply } from '../../electron/coding-teacher/model-runner';
import { estimateTeacherTokens } from '../../electron/coding-teacher/context';
const roots: string[] = [];
afterEach(async () => {
for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true });
});
async function fixture() {
const root = await mkdtemp(path.join(tmpdir(), 'teacher-reads-'));
roots.push(root);
const project = path.join(root, 'project');
await mkdir(path.join(project, 'src'), { recursive: true });
await mkdir(path.join(project, '.makelore'));
await writeFile(path.join(project, 'src/game.ts'), 'const gravity = 0.6;\nconst score = 7;');
await writeFile(path.join(root, 'other-project.txt'), 'foreign project content');
await writeFile(path.join(project, '.makelore/conversations.json'), 'other account history');
await writeFile(path.join(project, '.makelore/project.json'), '{"projectId":"current-project"}');
const assertCurrent = vi.fn();
const tools = createTeacherReadTools({ projectPath: project, assertCurrent,
source: { messages: [{ id: 'active', role: 'assistant', text: '第一行\n第二行\n第三行' }],
capturedAt: 'now', cursor: { workerGeneration: 1, seq: 3 } } });
const controller = new AbortController();
return { root, project, tools, assertCurrent, controller,
read: (name: string, args: unknown) => tools.execute(name, JSON.stringify(args), controller.signal) };
}
describe('teacher read scope', () => {
it('browses nested source and reads numbered file lines without modifying files', async () => {
const f = await fixture();
const list = await f.read('list_project_files', { path: '.' });
expect(list).toContain('src/');
expect(list).not.toContain('.makelore');
expect(await f.read('list_project_files', { path: 'src' })).toContain('src/game.ts');
expect(await f.read('read_project_file', { path: '.makelore/project.json' })).toContain('current-project');
expect(await f.read('read_project_file', { path: 'src/game.ts', start_line: 2, line_count: 1 }))
.toBe('src/game.ts\nLines 2-2 of 2:\n2: const score = 7;');
expect(await f.read('write', { path: 'src/game.ts', content: 'overwrite' })).toContain('Read failed');
expect(await readFile(path.join(f.project, 'src/game.ts'), 'utf8')).toBe('const gravity = 0.6;\nconst score = 7;');
});
it.each(['../other-project.txt', '.makelore/conversations.json', ' .makelore/conversations.json ', 'src/../.makelore/conversations.json'])
('refuses out-of-scope path %s', async target => {
const f = await fixture();
expect(await f.read('read_project_file', { path: target })).toContain('Read failed');
});
it('refuses absolute paths and invalid text, while reporting missing files as tool results', async () => {
const f = await fixture();
await writeFile(path.join(f.project, 'binary.png'), Buffer.from([0, 1, 2]));
for (const target of [path.join(f.root, 'other-project.txt'), 'binary.png', 'missing.ts'])
expect(await f.read('read_project_file', { path: target })).toContain('Read failed');
expect(await f.tools.execute('read_project_file', '{', f.controller.signal)).toContain('valid JSON');
});
it('reads only the captured conversation and supports original-message line ranges', async () => {
const f = await fixture();
expect(await f.read('read_conversation', {})).toContain('active assistant');
expect(await f.read('read_conversation', { message_id: 'active', start_line: 2, line_count: 1 }))
.toBe('active assistant\nLines 2-2 of 3:\n2: 第二行');
expect(await f.read('read_conversation', { message_id: 'foreign' })).toContain('Read failed');
});
it('can recover the original middle of a previous teacher answer for a follow-up', async () => {
const f = await fixture();
const tools = createTeacherReadTools({ projectPath: f.project, assertCurrent: f.assertCurrent,
source: { messages: [], cursor: { workerGeneration: 1, seq: 3 }, capturedAt: 'now' },
history: [{ id: 'prior', text: '解释一下', response: '第一条建议\n中间的原文\n最后一条建议',
status: 'completed', references: [], createdAt: 'now', sourceCapturedAt: 'now',
sourceCursor: { workerGeneration: 1, seq: 3 }, includedSourceMessageIds: [], omittedMessages: 0 }],
});
const result = await tools.execute('read_conversation',
JSON.stringify({ message_id: 'teacher:prior:assistant', start_line: 2, line_count: 1 }), f.controller.signal);
expect(result).toContain('2: 中间的原文');
});
it('bounds read output and preserves explicit truncation markers', async () => {
const f = await fixture();
await writeFile(path.join(f.project, 'large.ts'), '项目分析内容'.repeat(30000));
const result = await f.read('read_project_file', { path: 'large.ts' });
expect(Buffer.byteLength(result)).toBeLessThanOrEqual(2400);
expect(result).toContain('中间内容已省略');
expect(result).toContain('256 KiB');
});
it('does not read after cancellation or an account change', async () => {
const f = await fixture();
f.assertCurrent.mockImplementationOnce(() => { throw new Error('account changed'); });
await expect(f.read('read_project_file', { path: 'src/game.ts' })).rejects.toThrow('account changed');
f.controller.abort();
await expect(f.read('read_project_file', { path: 'src/game.ts' })).rejects.toThrow();
});
});
function streamEvent(event: unknown) {
return 'data: ' + JSON.stringify(event) + '\n\n';
}
function toolResponse(content = '', reasoning = '') {
return new Response(streamEvent({ choices: [{ delta: { content, reasoning_content: reasoning,
tool_calls: [{ index: 0, id: 'read-1', function: { name: 'read_project_file', arguments: '{"path":"src/game.ts"}' } }] },
finish_reason: 'tool_calls' }], usage: { prompt_tokens: 20, completion_tokens: 10 } }) + 'data: [DONE]\n\n');
}
const config = { base_url: 'https://teacher.invalid/v1', api_key: 'synthetic' };
describe('teacher read rounds', () => {
it('preserves native thinking privately between reads and accumulates billed usage', async () => {
const f = await fixture();
const fetch = vi.fn().mockResolvedValueOnce(toolResponse('我先看看代码。', 'private reasoning'))
.mockResolvedValueOnce(new Response(streamEvent({ choices: [{ delta: { content: '重力为 0.6。' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 30, completion_tokens: 5 } })));
const onText = vi.fn();
const usage = await streamTeacherReply(config, 'test', {}, 1000, [{ role: 'user', content: '解释代码' }],
f.controller.signal, onText, fetch, { tools: f.tools, inputLimit: 8000, assertCurrent: f.assertCurrent });
expect(usage).toEqual({ inputTokens: 50, outputTokens: 15 });
expect(onText.mock.calls.flat().join('')).toBe('我先看看代码。重力为 0.6。');
expect(JSON.parse(fetch.mock.calls[1][1].body).messages.at(-2).reasoning_content).toBe('private reasoning');
});
it('stops after six reading rounds and keeps each request within its input budget', async () => {
const f = await fixture();
await writeFile(path.join(f.project, 'src/game.ts'), 'x'.repeat(10000));
const fetch = vi.fn(async (_url: string | URL, init?: RequestInit) => {
const body = JSON.parse(String(init?.body));
expect(estimateTeacherTokens(body.messages) + Buffer.byteLength(JSON.stringify(body.tools)) + 64).toBeLessThanOrEqual(8000);
if (body.tool_choice === 'none') return new Response(streamEvent({ choices: [{ delta: { content: '已完成阅读' }, finish_reason: 'stop' }] }));
return toolResponse();
});
await streamTeacherReply(config, 'test', {}, 1000, [{ role: 'user', content: '请解释项目' }],
f.controller.signal, () => undefined, fetch, { tools: f.tools, inputLimit: 8000, assertCurrent: f.assertCurrent });
expect(fetch).toHaveBeenCalledTimes(7);
});
it('never executes incomplete tool arguments', async () => {
const f = await fixture();
const execute = vi.spyOn(f.tools, 'execute');
const fetch = vi.fn(async () => new Response(streamEvent({ choices: [{ delta: {
tool_calls: [{ index: 0, id: 'incomplete', function: { name: 'read_project_file', arguments: '{"path":' } }] },
finish_reason: 'length' }] })));
await expect(streamTeacherReply(config, 'test', {}, 1000, [], f.controller.signal, () => undefined, fetch,
{ tools: f.tools, inputLimit: 8000, assertCurrent: f.assertCurrent })).rejects.toMatchObject({ code: 'teacher_stream_interrupted' });
expect(execute).not.toHaveBeenCalled();
});
it('does not dispatch another model request after cancellation during a read', async () => {
const f = await fixture();
vi.spyOn(f.tools, 'execute').mockImplementationOnce(async () => { f.controller.abort(); return 'read result'; });
const fetch = vi.fn(async () => toolResponse());
await expect(streamTeacherReply(config, 'test', {}, 1000, [], f.controller.signal, () => undefined, fetch,
{ tools: f.tools, inputLimit: 8000, assertCurrent: f.assertCurrent })).rejects.toThrow();
expect(fetch).toHaveBeenCalledOnce();
});
});

View File

@@ -93,6 +93,7 @@ describe('teacher side chat', () => {
sourceCapturedAt: 'now',
includedSourceMessageIds: [],
omittedMessages: 0,
truncatedMessages: 1,
status: 'completed',
response: '教学回答',
},
@@ -106,6 +107,8 @@ describe('teacher side chat', () => {
expect(screen.getByLabelText('向老师提问')).toHaveValue('解释代码');
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));
await screen.findByText('教学回答');
expect(screen.getByText('结合当前会话和项目文件答疑')).toBeVisible();
expect(screen.getByText('较长的上下文已节选,老师可按需读取原文。')).toBeVisible();
expect(api.send.mock.calls[0][2].requestId).toBe(api.send.mock.calls[1][2].requestId);
expect(bringBack).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '带回主会话草稿' }));

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';