// @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(); }); });