fix(teacher): 区分词元预算与传输字节避免短问题被拦截
This commit is contained in:
@@ -85,7 +85,7 @@ async function submitCompiledContext(
|
||||
);
|
||||
const compiled = compileTeacherContext(
|
||||
f.topic.definition, f.access.source, [], intent === 'check-in' ? '' : '下一步怎么想?', [],
|
||||
model.inputLimit, intent, presentationInstructions, true
|
||||
model.inputLimit, intent, presentationInstructions, true, model.measureInput
|
||||
);
|
||||
await model.run(compiled.messages, new AbortController().signal, vi.fn());
|
||||
const body = vi.mocked(transport.json).mock.calls.find(([url]) => url === '/questions')?.[1] as {
|
||||
@@ -130,6 +130,32 @@ it('submits the active discussion protocol, current tool content and selected fo
|
||||
expect(protocol).toContain('比较两种重力');
|
||||
});
|
||||
|
||||
it('submits a short first question with the normal discussion protocol and published budget', async () => {
|
||||
const f = await fixture();
|
||||
const { body } = await submitCompiledContext(f, 'question', discussionInstructions(f.topic));
|
||||
expect(body.query).toContain('下一步怎么想?');
|
||||
});
|
||||
|
||||
it('excerpts long code context to fit the serialized cloud query', async () => {
|
||||
const f = await fixture();
|
||||
f.access.source.messages[0].text = 'const title = "game";\n'.repeat(500);
|
||||
const { body, compiled } = await submitCompiledContext(f);
|
||||
expect(compiled.truncatedMessages).toBeGreaterThan(0);
|
||||
expect(body.query).toContain('下一步怎么想?');
|
||||
});
|
||||
|
||||
it('fits code excerpts beside the unchanged discussion protocol in the original cloud budget', async () => {
|
||||
const f = await fixture();
|
||||
const original = 'const config = { "title": "小游戏" };\n'.repeat(500);
|
||||
f.access.source.messages[0].text = original;
|
||||
const protocol = discussionInstructions(f.topic);
|
||||
const { body, compiled } = await submitCompiledContext(f, 'question', protocol);
|
||||
expect(compiled.messages.at(-2)?.content).toBe(protocol);
|
||||
expect(compiled.truncatedMessages).toBeGreaterThan(0);
|
||||
expect(f.access.source.messages[0].text).toBe(original);
|
||||
expect(JSON.parse(body.query).messages.at(-1).content).toContain('下一步怎么想?');
|
||||
});
|
||||
|
||||
it('submits a nonempty proactive check-in without inventing a user message', async () => {
|
||||
const f = await fixture();
|
||||
f.access.source.messages = [];
|
||||
|
||||
@@ -7,6 +7,9 @@ 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';
|
||||
import { compileTeacherContext, estimateTeacherTokens } from '../../electron/coding-teacher/context';
|
||||
import { discussionInstructions } from '../../electron/coding-teacher/discussion';
|
||||
import type { TeacherTopic } from '../../shared/coding-teacher';
|
||||
|
||||
const account: cloud.TeacherAccount = {
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
@@ -51,6 +54,38 @@ function setup(canDisable = true) {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe('teacher published reasoning wire contract', () => {
|
||||
it('accepts a short discussion question with the existing 8000 budget and teaching prompt', async () => {
|
||||
const fetch = setup();
|
||||
const config = definition({ mode: 'disabled' });
|
||||
config.system_prompt = '帮助学生理解设计,结合实际进展给出建议。'.repeat(45);
|
||||
const source = { messages: [], cursor: { workerGeneration: 1, seq: 1 }, capturedAt: '2026-09-24' };
|
||||
const topic = { definition: config } as TeacherTopic;
|
||||
const prepared = await prepareTeacherModel(account, config, {
|
||||
projectPath: tmpdir(), source, assertCurrent: () => undefined,
|
||||
}, { finalOnly: true });
|
||||
const protocol = discussionInstructions(topic);
|
||||
const fixed = compileTeacherContext(config, source, [], '帮我看看我的项目有啥问题', [], Infinity, 'question', protocol, true);
|
||||
expect(estimateTeacherTokens(fixed.messages)).toBeLessThanOrEqual(prepared.inputLimit);
|
||||
const compiled = compileTeacherContext(config, source, [], '帮我看看我的项目有啥问题', [],
|
||||
prepared.inputLimit, 'question', protocol, true);
|
||||
await prepared.run(compiled.messages, new AbortController().signal, vi.fn());
|
||||
expect(fetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('reports oversized fixed instructions as a configuration problem without dispatching', async () => {
|
||||
const fetch = setup();
|
||||
const config = definition({ mode: 'disabled' });
|
||||
config.limits.max_input_tokens = 3000;
|
||||
const source = { messages: [], cursor: { workerGeneration: 1, seq: 1 }, capturedAt: 'now' };
|
||||
const prepared = await prepareTeacherModel(account, config, {
|
||||
projectPath: tmpdir(), source, assertCurrent: () => undefined,
|
||||
});
|
||||
expect(() => compileTeacherContext(config, source, [], '你好', [], prepared.inputLimit,
|
||||
'question', discussionInstructions({ definition: config } as TeacherTopic), true))
|
||||
.toThrow('老师配置或当前整理内容超过上下文预算');
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['我先看一下文件。', '{"reply":"读取前的草案","tool":null}'])('delivers only the final structured answer after a tool round containing %s', async (preamble) => {
|
||||
const fetch = setup();
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'teacher-final-model-'));
|
||||
|
||||
@@ -43,8 +43,7 @@ describe('teacher read scope', () => {
|
||||
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 => {
|
||||
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');
|
||||
});
|
||||
@@ -118,7 +117,7 @@ describe('teacher read rounds', () => {
|
||||
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);
|
||||
expect(estimateTeacherTokens(body.messages) + Math.ceil(Buffer.byteLength(JSON.stringify(body.tools)) / 2) + 64).toBeLessThanOrEqual(8000);
|
||||
if (body.tool_choice === 'none') return new Response(streamEvent({ choices: [{ delta: { content: '已完成阅读' }, finish_reason: 'stop' }] }));
|
||||
return toolResponse();
|
||||
});
|
||||
|
||||
@@ -61,7 +61,7 @@ afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
vi.useRealTimers();
|
||||
});
|
||||
async function fixture({ durableSource = false, sourceContext = context, liveModel = false, cloudTeacher = false, mockCloud = false, modelInputLimit = definition.limits.max_input_tokens } = {}) {
|
||||
async function fixture({ durableSource = false, sourceContext = context, liveModel = false, cloudTeacher = false, mockCloud = false, modelInputLimit = definition.limits.max_input_tokens, modelSystemPrompt = definition.system_prompt } = {}) {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'coding-teacher-'));
|
||||
roots.push(root);
|
||||
const projects = new CodingProjectService(
|
||||
@@ -114,7 +114,7 @@ async function fixture({ durableSource = false, sourceContext = context, liveMod
|
||||
yuxi: { agent_slug: 'teacher', agent_version: 2 } } }] }) : undefined,
|
||||
version: async (_account, v) => ({
|
||||
version: v,
|
||||
payload: { ...definition, name: '老师 v' + v, limits: { ...definition.limits, max_input_tokens: modelInputLimit } },
|
||||
payload: { ...definition, name: '老师 v' + v, system_prompt: modelSystemPrompt, limits: { ...definition.limits, max_input_tokens: modelInputLimit } },
|
||||
}),
|
||||
preview: async (_account, revision) => {
|
||||
if (revision !== 2) throw new TeacherError(409, 'teacher_draft_changed', '草稿变化');
|
||||
@@ -170,7 +170,9 @@ describe('cloud coding teacher', () => {
|
||||
expect((await f.service.list(f.scope)).lastSelectedTopicId).toBe(selected.id);
|
||||
});
|
||||
it('wires the Yuxi topic through scoped credentials and local tools without a local prompt or model loop', async () => {
|
||||
const f = await fixture({ cloudTeacher: true });
|
||||
const f = await fixture({ cloudTeacher: true, sourceContext: {
|
||||
...context, messages: [{ id: 'long-code', role: 'assistant', text: 'const title = "game";\n'.repeat(500) }],
|
||||
} });
|
||||
await writeFile(path.join(f.created.project.path, 'counter.ts'), 'let count = 42;');
|
||||
vi.spyOn(teacherCloud, 'assertTeacherAccount').mockReturnValue(undefined);
|
||||
vi.spyOn(teacherCloud, 'teacherCloudRequest').mockResolvedValue({
|
||||
@@ -198,7 +200,9 @@ describe('cloud coding teacher', () => {
|
||||
const saved = (await f.service.read(f.scope, topic.id)).requests[0];
|
||||
expect(saved.response).toBe('计数器从 42 开始。');
|
||||
expect(saved.cloudRequestId).toBe('cloud-question');
|
||||
expect(JSON.stringify(requests[0].body)).toContain('创建计数器');
|
||||
expect(JSON.stringify(requests[0].body)).toContain('const title');
|
||||
expect(saved.truncatedMessages).toBeGreaterThan(0);
|
||||
expect(Buffer.byteLength((requests[0].body as { query: string }).query)).toBeLessThanOrEqual(8000);
|
||||
expect(requests.find(item => item.path.endsWith('/tool-results'))?.body).toMatchObject({
|
||||
context_id: id, results: [{ tool_call_id: 'read-counter', status: 'success', content: expect.stringContaining('let count = 42;') }],
|
||||
});
|
||||
@@ -1102,7 +1106,7 @@ describe('teacher context and wire contract', () => {
|
||||
expect(compiled.messages[0].content).toContain(TEACHER_BEHAVIOR_PROMPT);
|
||||
expect(compiled.includedSourceMessageIds).toEqual(['source-user']);
|
||||
expect(compiled.omittedMessages).toBe(1);
|
||||
expect(() => compileTeacherContext(definition, context, [], 'x'.repeat(9000), [])).toThrow(
|
||||
expect(() => compileTeacherContext(definition, context, [], 'x'.repeat(18000), [])).toThrow(
|
||||
'超过上下文预算'
|
||||
);
|
||||
});
|
||||
@@ -1297,7 +1301,14 @@ it.each(['teacher', 'friend'] as const)('routes project-level %s config and topi
|
||||
|
||||
describe('structured teacher service integration', () => {
|
||||
it.each(['suggestions', 'discussion'] as const)('parses only final %s JSON after the real local runner reads a project file', async (format) => {
|
||||
const f = await fixture({ liveModel: true, modelInputLimit: 24000 });
|
||||
const f = await fixture({
|
||||
liveModel: true, modelInputLimit: 8000,
|
||||
modelSystemPrompt: '帮助学生理解设计,结合实际进展给出建议。'.repeat(45),
|
||||
sourceContext: { ...context, messages: [
|
||||
{ id: 'current-question', role: 'user', text: '创建计数器' },
|
||||
{ id: 'long-code', role: 'assistant', text: 'const title = "game";\n'.repeat(500) },
|
||||
] },
|
||||
});
|
||||
const scope = { ...f.scope, sourceId: 'project', role: 'teacher' as const };
|
||||
await writeFile(path.join(f.created.project.path, 'counter.ts'), 'let count = 42;');
|
||||
vi.spyOn(teacherCloud, 'assertTeacherAccount').mockReturnValue(undefined);
|
||||
|
||||
Reference in New Issue
Block a user