import type { Kaos } from '@moonshot-ai/kaos'; import { describe, expect, it, vi } from 'vitest'; import { MAX_BYTES, MAX_LINE_LENGTH, MAX_LINES, type ReadInput, ReadInputSchema, ReadTool, } from '../../src/tools/builtin/file/read'; import { MEDIA_SNIFF_BYTES } from '../../src/tools/support/file-type'; import type { WorkspaceConfig } from '../../src/tools/support/workspace'; import { createFakeKaos, PERMISSIVE_WORKSPACE, toolContentString } from './fixtures/fake-kaos'; import { executeTool } from './fixtures/execute-tool'; const signal = new AbortController().signal; const REGULAR_FILE_STAT = { stMode: 0o100_644, stIno: 1, stDev: 1, stNlink: 1, stUid: 1000, stGid: 1000, stSize: 0, stAtime: 0, stMtime: 0, stCtime: 0, } satisfies Awaited>; const DIRECTORY_STAT = { ...REGULAR_FILE_STAT, stMode: 0o040_755, } satisfies Awaited>; function context(args: ReadInput) { return { turnId: '0', toolCallId: 'call_read', args, signal, }; } function linesFromContent(content: string): string[] { if (content === '') return []; const rawLines = content.split('\n'); return rawLines.flatMap((line, index) => { if (index < rawLines.length - 1) return [`${line}\n`]; return line === '' ? [] : [line]; }); } function readLinesFromContent(content: string): Kaos['readLines'] { return async function* readLines(): AsyncGenerator { for (const line of linesFromContent(content)) { yield line; } }; } function withReadStatus(output: string, status: string): string { const note = `${status}`; return output.length > 0 ? `${output}\n${note}` : note; } function toolWithContent(content: string, workspace: WorkspaceConfig = PERMISSIVE_WORKSPACE) { const bytes = Buffer.from(content, 'utf8'); return new ReadTool( createFakeKaos({ stat: vi.fn().mockResolvedValue(REGULAR_FILE_STAT), readBytes: vi.fn().mockImplementation(async (_path, n) => { return n === undefined ? bytes : bytes.subarray(0, n); }), readLines: vi.fn().mockImplementation(readLinesFromContent(content)), readText: vi.fn().mockResolvedValue(content), }), workspace, ); } describe('ReadTool', () => { it('exposes current metadata and schema', () => { const tool = toolWithContent(''); expect(tool.name).toBe('Read'); expect(tool.description).toContain('concrete file path'); expect(tool.description).toContain('Pure CRLF files are displayed with LF'); expect(tool.description).not.toContain('skip the verification re-read'); expect(tool.description).toContain('final external contract'); expect(tool.parameters).toMatchObject({ type: 'object', properties: { path: { type: 'string', description: expect.stringContaining('working directory'), }, line_offset: { description: expect.stringContaining('line number to start reading from'), }, n_lines: { description: expect.stringContaining('number of lines to read'), }, }, }); expect(ReadInputSchema.safeParse({ path: '/tmp/test.txt' }).success).toBe(true); expect( ReadInputSchema.safeParse({ path: '/tmp/test.txt', line_offset: 1, n_lines: 2 }).success, ).toBe(true); expect(ReadInputSchema.safeParse({ path: '/tmp/test.txt', line_offset: 0 }).success).toBe( false, ); expect( ReadInputSchema.safeParse({ path: '/tmp/test.txt', line_offset: -(MAX_LINES + 1) }).success, ).toBe(false); }); it('matches permission args with glob path semantics', () => { const tool = toolWithContent(''); const execution = tool.resolveExecution({ path: '/etc/passwd' }); if (execution.isError === true) throw new TypeError('expected runnable execution'); expect(execution.matchesRule?.('/etc/**')).toBe(true); expect(execution.matchesRule?.('/var/**')).toBe(false); }); it('reads text content with stable one-based line numbers', async () => { const tool = toolWithContent('alpha\nbeta\n'); const result = await executeTool(tool, context({ path: '/tmp/a.txt' })); expect(result).toEqual({ output: withReadStatus( '1\talpha\n2\tbeta', '2 lines read from file starting from line 1. Total lines in file: 2. End of file reached.', ), }); }); it('normalizes pure CRLF files to the LF model view', async () => { const tool = toolWithContent('alpha\r\nbeta\r\n'); const result = await executeTool(tool, context({ path: '/tmp/a.txt' })); expect(result.output).toBe( withReadStatus( ['1\talpha', '2\tbeta'].join('\n'), '2 lines read from file starting from line 1. Total lines in file: 2. End of file reached.', ), ); }); it('makes mixed carriage returns visible instead of normalizing them', async () => { const tool = toolWithContent('alpha\r\nbeta\ngamma\rdone'); const result = await executeTool(tool, context({ path: '/tmp/a.txt' })); expect(result.output).toBe( withReadStatus( ['1\talpha\\r', '2\tbeta', '3\tgamma\\rdone'].join('\n'), '3 lines read from file starting from line 1. Total lines in file: 3. End of file reached. Mixed or lone carriage-return line endings are shown as \\r. Use exact \\r\\n or \\r escapes in Edit.old_string for those lines.', ), ); }); it('respects one-based line_offset and positive n_lines', async () => { const tool = toolWithContent('a\nb\nc\nd\ne'); const result = await executeTool(tool, context({ path: '/tmp/a.txt', line_offset: 2, n_lines: 2 })); expect(result).toEqual({ output: withReadStatus( '2\tb\n3\tc', '2 lines read from file starting from line 2. Total lines in file: 5.', ), }); }); it('returns an empty successful output when line_offset is beyond EOF', async () => { const tool = toolWithContent('a\nb'); const result = await executeTool(tool, context({ path: '/tmp/a.txt', line_offset: 20 })); expect(result).toEqual({ output: withReadStatus( '', 'No lines read from file. Total lines in file: 2. End of file reached.', ), }); }); it('supports negative line_offset as tail mode with absolute line numbers', async () => { const tool = toolWithContent('a\nb\nc\nd\ne'); const result = await executeTool(tool, context({ path: '/tmp/a.txt', line_offset: -3 })); expect(result).toEqual({ output: withReadStatus( '3\tc\n4\td\n5\te', '3 lines read from file starting from line 3. Total lines in file: 5. End of file reached.', ), }); }); it('applies n_lines from the start of the negative line_offset tail window', async () => { const tool = toolWithContent('a\nb\nc\nd\ne'); const result = await executeTool(tool, context({ path: '/tmp/a.txt', line_offset: -5, n_lines: 2 })); expect(result.output).toBe( withReadStatus( '1\ta\n2\tb', '2 lines read from file starting from line 1. Total lines in file: 5.', ), ); }); it('rejects relative traversal before reading', async () => { const readText = vi.fn().mockResolvedValue('secret'); const tool = new ReadTool(createFakeKaos({ readText }), { workspaceDir: '/workspace/project', additionalDirs: [], }); const result = await executeTool(tool, context({ path: '../../outside.txt' })); expect(result).toMatchObject({ isError: true }); expect(result.output).toContain('absolute path'); expect(readText).not.toHaveBeenCalled(); }); it('allows explicit absolute paths outside the workspace', async () => { const content = 'external'; const bytes = Buffer.from(content, 'utf8'); const readBytes = vi.fn().mockImplementation(async (_path, n) => { return n === undefined ? bytes : bytes.subarray(0, n); }); const readLines = vi.fn().mockImplementation(readLinesFromContent(content)); const tool = new ReadTool( createFakeKaos({ stat: vi.fn().mockResolvedValue(REGULAR_FILE_STAT), readBytes, readLines, }), { workspaceDir: '/workspace', additionalDirs: [], }, ); const result = await executeTool(tool, context({ path: '/tmp/external.txt' })); expect(result.output).toBe( withReadStatus( '1\texternal', '1 line read from file starting from line 1. Total lines in file: 1. End of file reached.', ), ); expect(readBytes).toHaveBeenCalledWith('/tmp/external.txt', MEDIA_SNIFF_BYTES); expect(readLines).toHaveBeenCalledWith('/tmp/external.txt', { errors: 'strict' }); }); it('returns a friendly error for missing files before sniffing bytes', async () => { const statError = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); const readBytes = vi .fn() .mockRejectedValue(new Error('readBytes should not be called for missing files')); const readLines = vi.fn().mockImplementation(readLinesFromContent('')); const tool = new ReadTool( createFakeKaos({ stat: vi.fn().mockRejectedValue(statError), readBytes, readLines, }), { workspaceDir: '/workspace', additionalDirs: [], }, ); const result = await executeTool(tool, context({ path: '/workspace/missing.txt' })); expect(result).toEqual({ isError: true, output: '"/workspace/missing.txt" does not exist.', }); expect(readBytes).not.toHaveBeenCalled(); expect(readLines).not.toHaveBeenCalled(); }); it('returns a friendly error for directories before sniffing bytes', async () => { const readBytes = vi .fn() .mockRejectedValue(new Error('readBytes should not be called for directories')); const readLines = vi.fn().mockImplementation(readLinesFromContent('')); const tool = new ReadTool( createFakeKaos({ stat: vi.fn().mockResolvedValue(DIRECTORY_STAT), readBytes, readLines, }), { workspaceDir: '/workspace', additionalDirs: [], }, ); const result = await executeTool(tool, context({ path: '/workspace/src' })); expect(result).toEqual({ isError: true, output: '"/workspace/src" is not a file.', }); expect(readBytes).not.toHaveBeenCalled(); expect(readLines).not.toHaveBeenCalled(); }); it('expands leading tilde paths using the kaos home directory', async () => { const content = 'home note'; const bytes = Buffer.from(content, 'utf8'); const readBytes = vi.fn().mockImplementation(async (_path, n) => { return n === undefined ? bytes : bytes.subarray(0, n); }); const readLines = vi.fn().mockImplementation(readLinesFromContent(content)); const tool = new ReadTool( createFakeKaos({ gethome: () => '/home/test', stat: vi.fn().mockResolvedValue(REGULAR_FILE_STAT), readBytes, readLines, }), { workspaceDir: '/workspace', additionalDirs: [], }, ); const result = await executeTool(tool, context({ path: '~/notes/today.txt' })); expect(result.output).toBe( withReadStatus( '1\thome note', '1 line read from file starting from line 1. Total lines in file: 1. End of file reached.', ), ); expect(readBytes).toHaveBeenCalledWith('/home/test/notes/today.txt', MEDIA_SNIFF_BYTES); expect(readLines).toHaveBeenCalledWith('/home/test/notes/today.txt', { errors: 'strict' }); }); it('blocks sensitive files independently from workspace access', async () => { const readText = vi.fn().mockResolvedValue('SECRET=value'); const tool = new ReadTool(createFakeKaos({ readText }), { workspaceDir: '/workspace', additionalDirs: [], }); const result = await executeTool(tool, context({ path: '/workspace/.env' })); expect(result).toMatchObject({ isError: true }); expect(result.output).toContain('sensitive-file pattern'); expect(readText).not.toHaveBeenCalled(); }); it('rejects image files before text decoding and points to ReadMediaFile', async () => { const pngHeader = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); const readText = vi .fn() .mockRejectedValue(new Error('readText should not be called for images')); const tool = new ReadTool( createFakeKaos({ stat: vi.fn().mockResolvedValue(REGULAR_FILE_STAT), readBytes: vi.fn().mockResolvedValue(pngHeader), readText, }), PERMISSIVE_WORKSPACE, ); const result = await executeTool(tool, context({ path: '/tmp/sample.png' })); const output = toolContentString(result); expect(result.isError).toBe(true); expect(output).toMatch(/image file/i); expect(output).toMatch(/ReadMediaFile|media/i); expect(readText).not.toHaveBeenCalled(); }); it('rejects extensionless image files using magic-byte sniffing', async () => { const pngHeader = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); const readText = vi .fn() .mockRejectedValue(new Error('readText should not be called for extensionless images')); const tool = new ReadTool( createFakeKaos({ stat: vi.fn().mockResolvedValue(REGULAR_FILE_STAT), readBytes: vi.fn().mockResolvedValue(pngHeader), readText, }), PERMISSIVE_WORKSPACE, ); const result = await executeTool(tool, context({ path: '/tmp/sample' })); const output = toolContentString(result); expect(result.isError).toBe(true); expect(output).toMatch(/image file/i); expect(readText).not.toHaveBeenCalled(); }); it('rejects video files before text decoding', async () => { const mp4Header = Buffer.concat([ Buffer.from([0x00, 0x00, 0x00, 0x18]), Buffer.from('ftyp'), Buffer.from('mp42'), Buffer.from([0x00, 0x00, 0x00, 0x00]), Buffer.from('mp42isom'), ]); const readText = vi .fn() .mockRejectedValue(new Error('readText should not be called for videos')); const tool = new ReadTool( createFakeKaos({ stat: vi.fn().mockResolvedValue(REGULAR_FILE_STAT), readBytes: vi.fn().mockResolvedValue(mp4Header), readText, }), PERMISSIVE_WORKSPACE, ); const result = await executeTool(tool, context({ path: '/tmp/sample.mp4' })); const output = toolContentString(result); expect(result.isError).toBe(true); expect(output).toMatch(/video file/i); expect(output).toMatch(/ReadMediaFile|media/i); expect(readText).not.toHaveBeenCalled(); }); it('rejects NUL-containing binary files before text decoding', async () => { const header = Buffer.concat([Buffer.from('plain prefix'), Buffer.from([0x00, 0x01])]); const readText = vi .fn() .mockRejectedValue(new Error('readText should not be called for binary files')); const tool = new ReadTool( createFakeKaos({ stat: vi.fn().mockResolvedValue(REGULAR_FILE_STAT), readBytes: vi.fn().mockResolvedValue(header), readText, }), PERMISSIVE_WORKSPACE, ); const result = await executeTool(tool, context({ path: '/tmp/blob.bin' })); const output = toolContentString(result); expect(result.isError).toBe(true); expect(output).toBe( '"/tmp/blob.bin" is not readable as UTF-8 text. If it is an image or video, use ReadMediaFile. For other binary formats, use Bash or an MCP tool if available.', ); expect(output).not.toContain('Python tools'); expect(readText).not.toHaveBeenCalled(); }); it('rejects NUL bytes that appear after the preflight header', async () => { const header = Buffer.from('text prefix without nul', 'utf8'); const readLines = vi.fn().mockImplementation(async function* readLines() { yield 'safe text\n'; yield `binary${String.fromCodePoint(0)}tail\n`; }); const tool = new ReadTool( createFakeKaos({ stat: vi.fn().mockResolvedValue(REGULAR_FILE_STAT), readBytes: vi.fn().mockResolvedValue(header), readLines, }), PERMISSIVE_WORKSPACE, ); const result = await executeTool(tool, context({ path: '/tmp/blob-with-late-nul' })); const output = toolContentString(result); expect(result.isError).toBe(true); expect(output).toBe( '"/tmp/blob-with-late-nul" is not readable as UTF-8 text. If it is an image or video, use ReadMediaFile. For other binary formats, use Bash or an MCP tool if available.', ); expect(output).not.toContain('Python tools'); }); it('rejects invalid UTF-8 instead of returning replacement characters', async () => { const replacement = String.fromCodePoint(0xfffd); const readLines = vi.fn().mockImplementation(async function* readLines( _path, options, ) { if (options?.errors === 'strict') { throw new TypeError('The encoded data was not valid for encoding utf-8'); } yield `bad${replacement}text\n`; }); const tool = new ReadTool( createFakeKaos({ stat: vi.fn().mockResolvedValue(REGULAR_FILE_STAT), readBytes: vi.fn().mockResolvedValue(Buffer.from('text header')), readLines, }), PERMISSIVE_WORKSPACE, ); const result = await executeTool(tool, context({ path: '/tmp/not-utf8.txt' })); const output = toolContentString(result); expect(result.isError).toBe(true); expect(output).toBe( '"/tmp/not-utf8.txt" is not readable as UTF-8 text. If it is an image or video, use ReadMediaFile. For other binary formats, use Bash or an MCP tool if available.', ); expect(output).not.toContain('Python tools'); expect(output).not.toContain(replacement); expect(output).not.toContain('encoded data was not valid'); }); it('truncates long lines and surfaces the affected line numbers', async () => { const long = 'x'.repeat(MAX_LINE_LENGTH + 10); const tool = toolWithContent([long, 'short', long].join('\n')); const result = await executeTool(tool, context({ path: '/tmp/long.txt' })); expect(result.output).toContain('Lines [1, 3] were truncated.'); expect(result.output).toContain('...'); }); it('checks the byte cap before adding the next rendered line', async () => { const line = 'x'.repeat(MAX_LINE_LENGTH); const content = Array.from({ length: 80 }, () => line).join('\n'); const tool = toolWithContent(content); const result = await executeTool(tool, context({ path: '/tmp/bytes.txt' })); const output = toolContentString(result); const marker = '\n'; const markerIndex = output.indexOf(marker); expect(markerIndex).toBeGreaterThan(0); const body = output.slice(0, markerIndex); expect(Buffer.byteLength(body, 'utf8')).toBeLessThanOrEqual(MAX_BYTES); expect(output).toContain(`Max ${String(MAX_BYTES)} bytes reached.`); }); it('reads through bounded byte preflight and streams line iteration without full readText', async () => { const content = Array.from({ length: MAX_LINES + 5 }, (_, i) => `line ${String(i + 1)}`).join( '\n', ); const bytes = Buffer.from(content, 'utf8'); const readText = vi .fn() .mockRejectedValue(new Error('full readText should not be called')); let consumed = 0; const readLines: Kaos['readLines'] = async function* readLines(): AsyncGenerator { for (let i = 1; i <= MAX_LINES + 5; i += 1) { consumed = i; yield `line ${String(i)}\n`; } }; const readBytes = vi.fn().mockImplementation(async (_path, n) => { return n === undefined ? bytes : bytes.subarray(0, n); }); const tool = new ReadTool( createFakeKaos({ stat: vi.fn().mockResolvedValue(REGULAR_FILE_STAT), readBytes, readLines, readText, }), PERMISSIVE_WORKSPACE, ); const result = await executeTool(tool, context({ path: '/tmp/large.txt' })); const output = toolContentString(result); expect(result.isError).toBeFalsy(); expect(output).toContain('1\tline 1'); expect(output).toContain(`${String(MAX_LINES)}\tline ${String(MAX_LINES)}`); expect(output).toContain(`Total lines in file: ${String(MAX_LINES + 5)}.`); expect(output).toContain(`Max ${String(MAX_LINES)} lines reached.`); expect(consumed).toBe(MAX_LINES + 5); expect(readBytes).toHaveBeenCalledWith('/tmp/large.txt', MEDIA_SNIFF_BYTES); expect(readText).not.toHaveBeenCalled(); }); it('caps default reads at MAX_LINES', async () => { const content = Array.from({ length: MAX_LINES + 1 }, (_, i) => `line ${String(i + 1)}`).join( '\n', ); const tool = toolWithContent(content); const result = await executeTool(tool, context({ path: '/tmp/big.txt' })); expect(result.output).toContain(`Max ${String(MAX_LINES)} lines reached.`); expect(result.output).toContain(`${String(MAX_LINES)}\tline ${String(MAX_LINES)}`); expect(result.output).not.toContain(`${String(MAX_LINES + 1)}\tline ${String(MAX_LINES + 1)}`); }); it('tail byte truncation keeps the newest lines closest to EOF', async () => { const numLines = Math.floor(MAX_BYTES / 1001) + 20; const content = Array.from({ length: numLines }, (_, i) => { return `${String(i + 1).padStart(4, '0')}${'B'.repeat(996)}`; }).join('\n'); const tool = toolWithContent(content); const result = await executeTool(tool, context({ path: '/tmp/tail-bytes.txt', line_offset: -1000 })); const output = toolContentString(result); const outputLines = output .split('\n') .filter((line) => line.includes('\t') && !line.startsWith('')); expect(output).toContain(`Max ${String(MAX_BYTES)} bytes reached.`); expect(outputLines.at(-1)).toContain(String(numLines).padStart(4, '0')); expect(outputLines[0]).not.toContain('0001'); }); it('tail n_lines is applied before byte truncation', async () => { const numLines = 500; const content = Array.from({ length: numLines }, (_, i) => { return `${String(i + 1).padStart(4, '0')}${'X'.repeat(1996)}`; }).join('\n'); const tool = toolWithContent(content); const result = await executeTool(tool, context({ path: '/tmp/tail-small-window.txt', line_offset: -200, n_lines: 1 }), ); const output = toolContentString(result); expect(output).toMatch(/^301\t0301/); expect(output).not.toContain('Max'); }); it('description pins line/byte caps, tail mode, and the Grep-over-Read preference', () => { const tool = toolWithContent(''); // Numeric caps are part of the stable contract. expect(tool.description).toContain(String(MAX_LINES)); expect(tool.description).toContain(String(MAX_LINE_LENGTH)); // Tail mode (negative line_offset) is documented. expect(tool.description).toMatch(/negative line_offset|reads from the end/i); // Recommend Grep when searching for unknown content. expect(tool.description).toContain('Grep'); }); it('reads files inside additional_dirs via absolute path', async () => { const content = 'extra-dir note'; const bytes = Buffer.from(content, 'utf8'); const tool = new ReadTool( createFakeKaos({ stat: vi.fn().mockResolvedValue(REGULAR_FILE_STAT), readBytes: vi.fn().mockImplementation(async (_path, n) => { return n === undefined ? bytes : bytes.subarray(0, n); }), readLines: vi.fn().mockImplementation(readLinesFromContent(content)), }), { workspaceDir: '/workspace', additionalDirs: ['/extra'] }, ); const result = await executeTool(tool,context({ path: '/extra/notes.txt' })); expect(result.isError).toBeFalsy(); expect(result.output).toContain('1\textra-dir note'); }); it('reports nonexistent files with the expected does-not-exist phrasing', async () => { const statError = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); const tool = new ReadTool( createFakeKaos({ stat: vi.fn().mockRejectedValue(statError), }), { workspaceDir: '/workspace', additionalDirs: [] }, ); const result = await executeTool(tool,context({ path: '/workspace/ghost.txt' })); expect(result.isError).toBe(true); expect(result.output).toContain('does not exist'); // py also surfaces a separate `brief` channel with "File not found"; // TS only has the `output` string, so we check for the equivalent // human phrasing in the message itself. expect(result.output).toMatch(/not found|does not exist/i); }); it('returns empty output and Total lines: 0 for an empty file', async () => { const tool = toolWithContent(''); const result = await executeTool(tool,context({ path: '/tmp/empty.txt' })); expect(result.isError).toBeFalsy(); expect(result.output).toBe( withReadStatus('', 'No lines read from file. Total lines in file: 0. End of file reached.'), ); }); it('reads unicode (CJK + emoji + accented Latin) without loss', async () => { const tool = toolWithContent('Hello 世界 🌍\nUnicode test: café, naïve, résumé'); const result = await executeTool(tool,context({ path: '/tmp/unicode.txt' })); expect(result.isError).toBeFalsy(); expect(result.output).toContain('1\tHello 世界 🌍'); expect(result.output).toContain('2\tUnicode test: café, naïve, résumé'); }); it('schema validation rejects n_lines=0 and n_lines=-1 with an n_lines-keyed error', () => { const zero = ReadInputSchema.safeParse({ path: '/tmp/a.txt', n_lines: 0 }); expect(zero.success).toBe(false); if (!zero.success) { const message = JSON.stringify(zero.error.issues); expect(message).toContain('n_lines'); } const negative = ReadInputSchema.safeParse({ path: '/tmp/a.txt', n_lines: -1 }); expect(negative.success).toBe(false); if (!negative.success) { const message = JSON.stringify(negative.error.issues); expect(message).toContain('n_lines'); } }); it('schema validation accepts -1 and -MAX_LINES but rejects -(MAX_LINES + 1)', () => { expect(ReadInputSchema.safeParse({ path: '/tmp/a.txt', line_offset: -1 }).success).toBe(true); expect(ReadInputSchema.safeParse({ path: '/tmp/a.txt', line_offset: -MAX_LINES }).success).toBe( true, ); expect( ReadInputSchema.safeParse({ path: '/tmp/a.txt', line_offset: -(MAX_LINES + 1) }).success, ).toBe(false); }); it('reads non-sensitive dotfiles like .gitignore successfully', async () => { const tool = toolWithContent('node_modules/\n'); const result = await executeTool(tool,context({ path: '/workspace/.gitignore' })); expect(result.isError).toBeFalsy(); expect(result.output).toContain('node_modules/'); }); it('negative line_offset exceeding total lines returns the entire file', async () => { const tool = toolWithContent('a\nb\nc\nd\ne'); const result = await executeTool(tool,context({ path: '/tmp/short.txt', line_offset: -100 })); expect(result.isError).toBeFalsy(); expect(result.output).toContain('1\ta'); expect(result.output).toContain('5\te'); expect(result.output).toContain('Total lines in file: 5.'); }); it('tail mode on an empty file returns empty output without erroring', async () => { const tool = toolWithContent(''); const result = await executeTool(tool,context({ path: '/tmp/empty-tail.txt', line_offset: -10 })); expect(result.isError).toBeFalsy(); expect(result.output).toContain('Total lines in file: 0.'); }); it('line_offset=-1 returns only the last line with its absolute line number', async () => { const tool = toolWithContent('a\nb\nc\nd\ne'); const result = await executeTool(tool,context({ path: '/tmp/last.txt', line_offset: -1 })); expect(result.isError).toBeFalsy(); expect(result.output).toContain('5\te'); expect(result.output).toContain('1 line read from file starting from line 5.'); }); it('tail mode reports absolute line numbers when long lines are truncated', async () => { const shortLine = 'short'; const longLine = 'X'.repeat(MAX_LINE_LENGTH + 500); const content = [shortLine, longLine, shortLine, longLine, shortLine].join('\n'); const tool = toolWithContent(content); const result = await executeTool(tool,context({ path: '/tmp/tail-trunc.txt', line_offset: -3 })); expect(result.isError).toBeFalsy(); // Last 3 lines = 3, 4, 5; line 4 is the long one. expect(result.output).toContain('Total lines in file: 5.'); expect(result.output).toContain('Lines [4] were truncated.'); }); }); describe('ReadTool description and schema parity', () => { it('encourages reading multiple files in parallel', () => { const tool = toolWithContent(''); expect(tool.description).toMatch(/parallel/i); expect(tool.description).toMatch(/multiple `Read` calls in a single response/i); }); it('explains the trailing status block', () => { const tool = toolWithContent(''); expect(tool.description).toContain(''); // The TS implementation appends the status block after the content. expect(tool.description).toMatch(/after the file content/i); }); it('describes the path parameter with accurate working-directory semantics', () => { const tool = toolWithContent(''); const pathProperty = ( tool.parameters as { properties: { path: { description: string } } } ).properties.path; expect(pathProperty.description).toContain('working directory'); expect(pathProperty.description).not.toMatch(/^Absolute path/); }); it('documents the default for n_lines when omitted', () => { const tool = toolWithContent(''); const nLinesProperty = ( tool.parameters as { properties: { n_lines: { description: string } } } ).properties.n_lines; // Omitting n_lines reads up to MAX_LINES; the schema description must say so. expect(nLinesProperty.description).toMatch(/omit/i); expect(nLinesProperty.description).toContain(String(MAX_LINES)); }); it('warns that sensitive files are refused', () => { const tool = toolWithContent(''); expect(tool.description).toMatch(/refuse|reject|decline|block/i); expect(tool.description).toMatch(/sensitive|credential|secret|\.env|SSH key/i); }); it('explains that non-UTF-8 and binary files are refused', () => { const tool = toolWithContent(''); expect(tool.description).toMatch(/UTF-?8/i); expect(tool.description).toMatch(/binary/i); }); });