fix(coding-teacher): restore context and read project files
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
147
tests/unit/coding-teacher-read-tools.test.ts
Normal file
147
tests/unit/coding-teacher-read-tools.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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: '带回主会话草稿' }));
|
||||
|
||||
@@ -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