233 lines
14 KiB
TypeScript
233 lines
14 KiB
TypeScript
// @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('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: '.' });
|
|
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(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;');
|
|
});
|
|
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(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 () => {
|
|
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(JSON.parse(result).text).toBe('中间的原文\n');
|
|
});
|
|
it('honors small caller budgets and rejects invalid page ranges without leaking paths', async () => {
|
|
const f = await fixture();
|
|
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();
|
|
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) + 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();
|
|
});
|
|
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();
|
|
});
|
|
});
|