fix(teacher): 改善只读工具分页与读取预算
This commit is contained in:
@@ -127,6 +127,37 @@ it('waits for the interrupted parent cleanup and resubmits the same three read r
|
||||
expect(paths.some(item => item.endsWith('/cancel'))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns the complete batch within the byte budget and lets the cloud finish with available evidence', async () => {
|
||||
const f = await fixture();
|
||||
await writeFile(path.join(f.access.projectPath, 'src/game.ts'), '源码🙂'.repeat(20000));
|
||||
const results: { status: string; content: string }[] = [];
|
||||
const transport: TeacherCloudTransport = {
|
||||
events: vi.fn(),
|
||||
json: vi.fn(async (url, body) => {
|
||||
if (url === '/questions') return { request_id: 'question', run_id: 'one' };
|
||||
if (url.endsWith('/tool-results')) {
|
||||
results.push(...(body as { results: typeof results }).results);
|
||||
return { run_id: 'done' };
|
||||
}
|
||||
if (url === '/runs/done') return { status: 'completed', output: '已检查的范围和待补充资料。' };
|
||||
return { status: 'interrupted', interrupt: {
|
||||
source: 'client_read_tools', context_id: requestId,
|
||||
calls: Array.from({ length: 10 }, (_, i) => ({ tool_call_id: 'file-' + i, name: 'read_project_file',
|
||||
arguments: { path: 'src/game.ts', start_column: 1 + i * 3000 } })),
|
||||
} };
|
||||
}),
|
||||
};
|
||||
const text = vi.fn();
|
||||
await prepareCloudTeacher(f.account, f.topic, requestId, f.access, f.progress, f.saveRequest, transport)
|
||||
.run([{ role: 'user', content: '检查' }], new AbortController().signal, text);
|
||||
expect(results).toHaveLength(10);
|
||||
const bytes = results.filter(item => item.status === 'success').reduce((sum, item) => sum + Buffer.byteLength(item.content), 0);
|
||||
expect(bytes).toBeLessThanOrEqual(65536);
|
||||
expect(bytes).toBeGreaterThan(65024);
|
||||
expect(results.at(-1)).toMatchObject({ status: 'error', content: expect.stringContaining('budget exhausted') });
|
||||
expect(text).toHaveBeenCalledWith('已检查的范围和待补充资料。');
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ ...cleanupBusy, active_run_id: 'other-run' },
|
||||
{ ...cleanupBusy, active_run_status: 'running' },
|
||||
@@ -190,7 +221,7 @@ async function submitCompiledContext(
|
||||
await model.run(compiled.messages, new AbortController().signal, vi.fn());
|
||||
const body = vi.mocked(transport.json).mock.calls.find(([url]) => url === '/questions')?.[1] as {
|
||||
query: string;
|
||||
local_context: { id: string; scope: { project_id: string; source_session_id: string }; tools: string[] };
|
||||
local_context: { id: string; read_protocol: number; scope: { project_id: string; source_session_id: string }; tools: string[] };
|
||||
};
|
||||
expect(JSON.parse(body.query)).toEqual({ messages: compiled.messages });
|
||||
expect(estimateTeacherTokens(compiled.messages)).toBeLessThanOrEqual(model.inputLimit);
|
||||
@@ -211,6 +242,7 @@ it('submits only the published persona, source evidence and current question in
|
||||
expect(compiled.messages[0].content).toContain('你可以通过只读工具');
|
||||
expect(compiled.messages[1].content).toContain(f.access.source.messages[0].text);
|
||||
expect(compiled.messages[2].content).toContain('当前问题:\n下一步怎么想?');
|
||||
expect(body.local_context.read_protocol).toBe(2);
|
||||
expect(body.local_context.tools).toEqual(['list_project_files', 'read_project_file', 'read_conversation']);
|
||||
});
|
||||
|
||||
@@ -512,7 +544,7 @@ it.each(['方法老师', '朋友', '代码顾问'])('returns all three local rea
|
||||
expect.objectContaining({
|
||||
tool_call_id: 'chat',
|
||||
status: 'success',
|
||||
content: expect.stringContaining('2: 调整重力'),
|
||||
content: expect.stringContaining('调整重力'),
|
||||
}),
|
||||
],
|
||||
},
|
||||
@@ -522,7 +554,7 @@ it.each(['方法老师', '朋友', '代码顾问'])('returns all three local rea
|
||||
expect(f.progress).toHaveBeenCalledWith('正在读取项目与会话…');
|
||||
});
|
||||
|
||||
it.each([false, true])('finishes six batches and rejects a seventh as a read limit (extra batch: %s)', async extraBatch => {
|
||||
it.each([false, true])('finishes twelve batches and rejects a thirteenth as a read limit (extra batch: %s)', async extraBatch => {
|
||||
const f = await fixture();
|
||||
const returned: unknown[] = [];
|
||||
const transport: TeacherCloudTransport = {
|
||||
@@ -534,7 +566,7 @@ it.each([false, true])('finishes six batches and rejects a seventh as a read lim
|
||||
returned.push(body);
|
||||
return { run_id: `run-${returned.length}` };
|
||||
}
|
||||
if (!extraBatch && returned.length === 6) return { status: 'completed', output: '已根据六批资料完成检查。' };
|
||||
if (!extraBatch && returned.length === 12) return { status: 'completed', output: '已根据十二批资料完成检查。' };
|
||||
return { status: 'interrupted', interrupt: {
|
||||
source: 'client_read_tools', context_id: requestId,
|
||||
calls: [{ tool_call_id: `read-${returned.length}`, name: 'read_project_file', arguments: { path: 'src/game.ts' } }],
|
||||
@@ -549,10 +581,10 @@ it.each([false, true])('finishes six batches and rejects a seventh as a read lim
|
||||
expect(transport.json).toHaveBeenCalledWith('/questions/' + requestId + '/cancel', {}, expect.anything());
|
||||
} else {
|
||||
await result;
|
||||
expect(text).toHaveBeenCalledWith('已根据六批资料完成检查。');
|
||||
expect(text).toHaveBeenCalledWith('已根据十二批资料完成检查。');
|
||||
expect(transport.json).not.toHaveBeenCalledWith('/questions/' + requestId + '/cancel', expect.anything(), expect.anything());
|
||||
}
|
||||
expect(returned).toHaveLength(6);
|
||||
expect(returned).toHaveLength(12);
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -31,6 +31,90 @@ async function fixture() {
|
||||
}
|
||||
|
||||
describe('teacher read scope', () => {
|
||||
it('discovers nested sources in one bounded tree and can drill into deeper directories', async () => {
|
||||
const f = await fixture();
|
||||
await mkdir(path.join(f.project, 'app/src/main/java/sample'), { recursive: true });
|
||||
await writeFile(path.join(f.project, 'app/src/main/AndroidManifest.xml'), '<manifest/>');
|
||||
await writeFile(path.join(f.project, 'app/src/main/java/sample/MainActivity.kt'), 'class MainActivity');
|
||||
const rawTree = await f.read('list_project_files', { path: '.' });
|
||||
expect(rawTree).toContain('app/src/main/AndroidManifest.xml');
|
||||
const tree = JSON.parse(rawTree);
|
||||
expect(tree.text).toContain('app/src/main/AndroidManifest.xml');
|
||||
expect(tree.text).toContain('app/src/main/java/');
|
||||
expect(tree.text).not.toContain('MainActivity.kt');
|
||||
const subtree = JSON.parse(await f.read('list_project_files', { path: 'app/src/main/java' }));
|
||||
expect(subtree.text).toContain('sample/MainActivity.kt');
|
||||
});
|
||||
it('can page beyond 2000 directory entries instead of silently losing later files', async () => {
|
||||
const f = await fixture();
|
||||
await mkdir(path.join(f.project, 'assets'));
|
||||
for (let i = 0; i < 2001; i++) await writeFile(path.join(f.project, 'assets', `item-${String(i).padStart(4, '0')}.txt`), '');
|
||||
const result = JSON.parse(await f.read('list_project_files', { path: 'assets', start_line: 1995, line_count: 10 }));
|
||||
expect(result.text).toContain('assets/item-2000.txt');
|
||||
expect(result.next).toBeNull();
|
||||
});
|
||||
it('keeps Android source discoverable beside a large Gradle cache', async () => {
|
||||
const f = await fixture();
|
||||
await mkdir(path.join(f.project, '.gradle'));
|
||||
await mkdir(path.join(f.project, 'app/src'), { recursive: true });
|
||||
await writeFile(path.join(f.project, 'app/src/Main.kt'), 'class Main');
|
||||
for (let i = 0; i < 2000; i++) await writeFile(path.join(f.project, '.gradle', `cache-${i}.bin`), '');
|
||||
const result = JSON.parse(await f.read('list_project_files', { path: '.' }));
|
||||
expect(result.text).toContain('app/');
|
||||
expect(result.text).toContain('app/src/Main.kt');
|
||||
expect(result.text).not.toContain('.gradle');
|
||||
});
|
||||
it('pages large UTF-8 originals without losing the middle or stopping at the preview limit', async () => {
|
||||
const f = await fixture();
|
||||
const original = '开始\n' + '中文🙂\\"'.repeat(24000) + '\n末行';
|
||||
await writeFile(path.join(f.project, 'large.ts'), original);
|
||||
let cursor: { start_line: number; start_column: number } | null = { start_line: 1, start_column: 1 };
|
||||
let restored = '';
|
||||
let pages = 0;
|
||||
while (cursor && pages++ < 150) {
|
||||
const result = await f.tools.executeResult('read_project_file', JSON.stringify({ path: 'large.ts', ...cursor }), f.controller.signal);
|
||||
expect(result.status).toBe('success');
|
||||
expect(result.content).not.toContain('中间内容已省略');
|
||||
expect(Buffer.byteLength(result.content)).toBeLessThanOrEqual(8192);
|
||||
const page = JSON.parse(result.content);
|
||||
expect(page.text.length).toBeGreaterThan(0);
|
||||
restored += page.text;
|
||||
cursor = page.next;
|
||||
expect(result.truncated).toBe(cursor !== null);
|
||||
}
|
||||
expect(cursor).toBeNull();
|
||||
expect(pages).toBeGreaterThan(1);
|
||||
expect(restored).toBe(original);
|
||||
});
|
||||
it('stops consuming a multi-megabyte single line after the first bounded page', async () => {
|
||||
const f = await fixture();
|
||||
await writeFile(path.join(f.project, 'minified.js'), '源码🙂'.repeat(600000));
|
||||
const decode = TextDecoder.prototype.decode;
|
||||
let decodedBytes = 0;
|
||||
const spy = vi.spyOn(TextDecoder.prototype, 'decode').mockImplementation(function (input, options) {
|
||||
decodedBytes += input?.byteLength ?? 0;
|
||||
return decode.call(this, input, options);
|
||||
});
|
||||
try {
|
||||
const result = await f.tools.executeResult('read_project_file', '{"path":"minified.js"}', f.controller.signal);
|
||||
expect(result.status).toBe('success');
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(Buffer.byteLength(result.content)).toBeLessThanOrEqual(8192);
|
||||
expect(decodedBytes).toBeLessThanOrEqual(128 * 1024);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
it('keeps line positions when Windows newlines cross filesystem chunks', async () => {
|
||||
const f = await fixture();
|
||||
await writeFile(path.join(f.project, 'windows.ts'), 'x'.repeat(65535) + '\r\n中文🙂\r\n最后');
|
||||
const page = JSON.parse(await f.read('read_project_file', { path: 'windows.ts', start_line: 2, line_count: 1 }));
|
||||
expect(page.text).toBe('中文🙂\n');
|
||||
expect(page.next).toEqual({ start_line: 3, start_column: 1 });
|
||||
const last = JSON.parse(await f.read('read_project_file', { path: 'windows.ts', ...page.next }));
|
||||
expect(last.text).toBe('最后');
|
||||
expect(last.next).toBeNull();
|
||||
});
|
||||
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: '.' });
|
||||
@@ -38,8 +122,8 @@ describe('teacher read scope', () => {
|
||||
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(JSON.parse(await f.read('read_project_file', { path: 'src/game.ts', start_line: 2, line_count: 1 })))
|
||||
.toEqual({ source: 'src/game.ts', start_line: 2, start_column: 1, text: 'const score = 7;', next: null });
|
||||
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;');
|
||||
});
|
||||
@@ -57,8 +141,8 @@ describe('teacher read scope', () => {
|
||||
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(JSON.parse(await f.read('read_conversation', { message_id: 'active', start_line: 2, line_count: 1 })))
|
||||
.toEqual({ source: 'active assistant', start_line: 2, start_column: 1, text: '第二行\n', next: { start_line: 3, start_column: 1 } });
|
||||
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 () => {
|
||||
@@ -71,15 +155,17 @@ describe('teacher read scope', () => {
|
||||
});
|
||||
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: 中间的原文');
|
||||
expect(JSON.parse(result).text).toBe('中间的原文\n');
|
||||
});
|
||||
it('bounds read output and preserves explicit truncation markers', async () => {
|
||||
it('honors small caller budgets and rejects invalid page ranges without leaking paths', 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');
|
||||
const result = await f.tools.executeResult('read_project_file', JSON.stringify({ path: 'src/game.ts' }), f.controller.signal, 128);
|
||||
expect(Buffer.byteLength(result.content)).toBeLessThanOrEqual(128);
|
||||
for (const args of [{ start_line: 9 }, { start_column: 900 }, { line_count: 401 }, { start_line: 0 }]) {
|
||||
const failure = await f.tools.executeResult('read_project_file', JSON.stringify({ path: 'src/game.ts', ...args }), f.controller.signal);
|
||||
expect(failure.status).toBe('error');
|
||||
expect(failure.content).not.toContain(f.project);
|
||||
}
|
||||
});
|
||||
it('does not read after cancellation or an account change', async () => {
|
||||
const f = await fixture();
|
||||
|
||||
Reference in New Issue
Block a user