Skip to content

Commit c5adc10

Browse files
wip - improve readFile
1 parent 0469fe0 commit c5adc10

11 files changed

Lines changed: 217 additions & 31 deletions

File tree

packages/web/src/features/chat/agent.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -95,15 +95,13 @@ export const createAgentStream = async ({
9595
}
9696

9797
if (toolName === toolNames.readFile) {
98-
output.forEach((file) => {
99-
onWriteSource({
100-
type: 'file',
101-
language: file.language,
102-
repo: file.repository,
103-
path: file.path,
104-
revision: file.revision,
105-
name: file.path.split('/').pop() ?? file.path,
106-
});
98+
onWriteSource({
99+
type: 'file',
100+
language: output.language,
101+
repo: output.repository,
102+
path: output.path,
103+
revision: output.revision,
104+
name: output.path.split('/').pop() ?? output.path,
107105
});
108106
} else if (toolName === toolNames.searchCode) {
109107
output.files.forEach((file) => {

packages/web/src/features/chat/components/chatThread/tools/readFileToolComponent.tsx

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,18 @@ import { ReadFileToolUIPart } from "@/features/chat/tools";
66
import { isServiceError } from "@/lib/utils";
77
import { EyeIcon } from "lucide-react";
88
import { useMemo, useState } from "react";
9+
import { CopyIconButton } from "@/app/[domain]/components/copyIconButton";
910
import { FileListItem, ToolHeader, TreeList } from "./shared";
1011

1112
export const ReadFileToolComponent = ({ part }: { part: ReadFileToolUIPart }) => {
1213
const [isExpanded, setIsExpanded] = useState(false);
1314

15+
const onCopy = () => {
16+
if (part.state !== 'output-available' || isServiceError(part.output)) return false;
17+
navigator.clipboard.writeText(part.output.source);
18+
return true;
19+
};
20+
1421
const label = useMemo(() => {
1522
switch (part.state) {
1623
case 'input-streaming':
@@ -29,14 +36,23 @@ export const ReadFileToolComponent = ({ part }: { part: ReadFileToolUIPart }) =>
2936

3037
return (
3138
<div className="my-4">
32-
<ToolHeader
33-
isLoading={part.state !== 'output-available' && part.state !== 'output-error'}
34-
isError={part.state === 'output-error' || (part.state === 'output-available' && isServiceError(part.output))}
35-
isExpanded={isExpanded}
36-
label={label}
37-
Icon={EyeIcon}
38-
onExpand={setIsExpanded}
39-
/>
39+
<div className="flex items-center gap-2 group/header">
40+
<ToolHeader
41+
isLoading={part.state !== 'output-available' && part.state !== 'output-error'}
42+
isError={part.state === 'output-error' || (part.state === 'output-available' && isServiceError(part.output))}
43+
isExpanded={isExpanded}
44+
label={label}
45+
Icon={EyeIcon}
46+
onExpand={setIsExpanded}
47+
className="flex-1"
48+
/>
49+
{part.state === 'output-available' && !isServiceError(part.output) && (
50+
<CopyIconButton
51+
onCopy={onCopy}
52+
className="opacity-0 group-hover/header:opacity-100 transition-opacity"
53+
/>
54+
)}
55+
</div>
4056
{part.state === 'output-available' && isExpanded && (
4157
<>
4258
<TreeList>

packages/web/src/features/chat/tools/findSymbolDefinitions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export const findSymbolDefinitionsTool = tool({
2727
});
2828

2929
if (isServiceError(response)) {
30-
return response;
30+
throw new Error(response.message);
3131
}
3232

3333
return response.files.map((file) => ({

packages/web/src/features/chat/tools/findSymbolReferences.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export const findSymbolReferencesTool = tool({
2727
});
2828

2929
if (isServiceError(response)) {
30-
return response;
30+
throw new Error(response.message);
3131
}
3232

3333
return response.files.map((file) => ({

packages/web/src/features/chat/tools/listCommits.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export const listCommitsTool = tool({
2828
});
2929

3030
if (isServiceError(response)) {
31-
return response;
31+
throw new Error(response.message);
3232
}
3333

3434
return {

packages/web/src/features/chat/tools/listRepos.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export const listReposTool = tool({
1515
const reposResponse = await listRepos(request);
1616

1717
if (isServiceError(reposResponse)) {
18-
return reposResponse;
18+
throw new Error(reposResponse.message);
1919
}
2020

2121
return reposResponse.data.map((repo) => repo.repoName);
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { describe, expect, test, vi } from 'vitest';
2+
import { readFileTool } from './readFile';
3+
4+
vi.mock('@/features/git', () => ({
5+
getFileSource: vi.fn(),
6+
}));
7+
8+
vi.mock('../logger', () => ({
9+
logger: { debug: vi.fn() },
10+
}));
11+
12+
vi.mock('./readFile.txt', () => ({ default: 'description' }));
13+
14+
import { getFileSource } from '@/features/git';
15+
16+
const mockGetFileSource = vi.mocked(getFileSource);
17+
18+
function makeSource(source: string) {
19+
mockGetFileSource.mockResolvedValue({
20+
source,
21+
path: 'test.ts',
22+
repo: 'github.com/org/repo',
23+
language: 'typescript',
24+
revision: 'HEAD',
25+
} as any);
26+
}
27+
28+
describe('readFileTool byte cap', () => {
29+
test('truncates output at 5KB and shows byte cap message', async () => {
30+
// Each line is ~100 bytes; 60 lines = ~6KB, over the 5KB cap
31+
const lines = Array.from({ length: 60 }, (_, i) => `line${i + 1}: ${'x'.repeat(90)}`).join('\n');
32+
makeSource(lines);
33+
34+
const result = await readFileTool.execute!({ path: 'test.ts', repository: 'github.com/org/repo' }, {} as any);
35+
expect('source' in result && result.source).toContain('Output capped at 5KB');
36+
expect('source' in result && result.source).toContain('Use offset=');
37+
expect('source' in result && result.source).toContain('Output capped at 5KB');
38+
});
39+
40+
test('does not cap output under 5KB', async () => {
41+
makeSource('short line\n'.repeat(10).trimEnd());
42+
43+
const result = await readFileTool.execute!({ path: 'test.ts', repository: 'github.com/org/repo' }, {} as any);
44+
expect('source' in result && result.source).not.toContain('Output capped at 5KB');
45+
});
46+
});
47+
48+
describe('readFileTool hasMoreLines message', () => {
49+
test('appends continuation message when file is truncated', async () => {
50+
const lines = Array.from({ length: 600 }, (_, i) => `line${i + 1}`).join('\n');
51+
makeSource(lines);
52+
53+
const result = await readFileTool.execute!({ path: 'test.ts', repository: 'github.com/org/repo' }, {} as any);
54+
expect('source' in result && result.source).toContain('Showing lines 1-500 of 600');
55+
expect('source' in result && result.source).toContain('offset=501');
56+
});
57+
58+
test('shows end of file message when all lines fit', async () => {
59+
makeSource('line1\nline2\nline3');
60+
61+
const result = await readFileTool.execute!({ path: 'test.ts', repository: 'github.com/org/repo' }, {} as any);
62+
expect('source' in result && result.source).not.toContain('Showing lines');
63+
expect('source' in result && result.source).toContain('End of file - 3 lines total');
64+
});
65+
66+
test('continuation message reflects offset parameter', async () => {
67+
const lines = Array.from({ length: 600 }, (_, i) => `line${i + 1}`).join('\n');
68+
makeSource(lines);
69+
70+
const result = await readFileTool.execute!({ path: 'test.ts', repository: 'github.com/org/repo', offset: 100 }, {} as any);
71+
expect('source' in result && result.source).toContain('Showing lines 100-599 of 600');
72+
expect('source' in result && result.source).toContain('offset=600');
73+
});
74+
});
75+
76+
describe('readFileTool line truncation', () => {
77+
test('does not truncate lines under the limit', async () => {
78+
const line = 'x'.repeat(100);
79+
makeSource(line);
80+
81+
const result = await readFileTool.execute!({ path: 'test.ts', repository: 'github.com/org/repo' }, {} as any);
82+
expect('source' in result && result.source).toContain(line);
83+
expect('source' in result && result.source).not.toContain('line truncated');
84+
});
85+
86+
test('truncates lines longer than 2000 chars', async () => {
87+
const line = 'x'.repeat(3000);
88+
makeSource(line);
89+
90+
const result = await readFileTool.execute!({ path: 'test.ts', repository: 'github.com/org/repo' }, {} as any);
91+
expect('source' in result && result.source).toContain('... (line truncated to 2000 chars)');
92+
expect('source' in result && result.source).not.toContain('x'.repeat(2001));
93+
});
94+
95+
test('truncates only the long lines, leaving normal lines intact', async () => {
96+
const longLine = 'a'.repeat(3000);
97+
const normalLine = 'normal line';
98+
makeSource(`${normalLine}\n${longLine}\n${normalLine}`);
99+
100+
const result = await readFileTool.execute!({ path: 'test.ts', repository: 'github.com/org/repo' }, {} as any);
101+
expect('source' in result && result.source).toContain(normalLine);
102+
expect('source' in result && result.source).toContain('... (line truncated to 2000 chars)');
103+
});
104+
105+
test('truncates a line at exactly 2001 chars', async () => {
106+
makeSource('b'.repeat(2001));
107+
108+
const result = await readFileTool.execute!({ path: 'test.ts', repository: 'github.com/org/repo' }, {} as any);
109+
expect('source' in result && result.source).toContain('... (line truncated to 2000 chars)');
110+
});
111+
112+
test('does not truncate a line at exactly 2000 chars', async () => {
113+
makeSource('c'.repeat(2000));
114+
115+
const result = await readFileTool.execute!({ path: 'test.ts', repository: 'github.com/org/repo' }, {} as any);
116+
expect('source' in result && result.source).not.toContain('line truncated');
117+
});
118+
});

packages/web/src/features/chat/tools/readFile.ts

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@ import { z } from "zod";
22
import { InferToolInput, InferToolOutput, InferUITool, tool, ToolUIPart } from "ai";
33
import { isServiceError } from "@/lib/utils";
44
import { getFileSource } from "@/features/git";
5-
import { addLineNumbers } from "../utils";
65
import { toolNames } from "../constants";
76
import { logger } from "../logger";
87
import description from './readFile.txt';
98

10-
// NOTE: if you change this value, update readFile.txt to match.
9+
// NOTE: if you change these values, update readFile.txt to match.
1110
const READ_FILES_MAX_LINES = 500;
11+
const MAX_LINE_LENGTH = 2000;
12+
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`;
13+
const MAX_BYTES = 5 * 1024;
14+
const MAX_BYTES_LABEL = `${MAX_BYTES / 1024}KB`;
1215

1316
export const readFileTool = tool({
1417
description,
@@ -34,24 +37,67 @@ export const readFileTool = tool({
3437
});
3538

3639
if (isServiceError(fileSource)) {
37-
return fileSource;
40+
throw new Error(fileSource.message);
3841
}
3942

4043
const lines = fileSource.source.split('\n');
4144
const start = (offset ?? 1) - 1;
4245
const end = start + Math.min(limit ?? READ_FILES_MAX_LINES, READ_FILES_MAX_LINES);
43-
const slicedLines = lines.slice(start, end);
44-
const truncated = end < lines.length;
46+
47+
let bytes = 0;
48+
let truncatedByBytes = false;
49+
const slicedLines: string[] = [];
50+
for (const raw of lines.slice(start, end)) {
51+
const line = raw.length > MAX_LINE_LENGTH ? raw.substring(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : raw;
52+
const size = Buffer.byteLength(line, 'utf-8') + (slicedLines.length > 0 ? 1 : 0);
53+
if (bytes + size > MAX_BYTES) {
54+
truncatedByBytes = true;
55+
break;
56+
}
57+
slicedLines.push(line);
58+
bytes += size;
59+
}
60+
61+
const truncatedByLines = end < lines.length;
62+
const startLine = (offset ?? 1);
63+
const lastReadLine = startLine + slicedLines.length - 1;
64+
const nextOffset = lastReadLine + 1;
65+
66+
let output = [
67+
`<repo>${fileSource.repo}</repo>`,
68+
`<path>${fileSource.path}</path>`,
69+
'<content>\n'
70+
].join('\n');
71+
72+
output += slicedLines.map((line, i) => `${startLine + i}: ${line}`).join('\n');
73+
74+
if (truncatedByBytes) {
75+
output += `\n\n(Output capped at ${MAX_BYTES_LABEL}. Showing lines ${startLine}-${lastReadLine} of ${lines.length}. Use offset=${nextOffset} to continue.)`;
76+
} else if (truncatedByLines) {
77+
output += `\n\n(Showing lines ${startLine}-${lastReadLine} of ${lines.length}. Use offset=${nextOffset} to continue.)`;
78+
} else {
79+
output += `\n\n(End of file - ${lines.length} lines total)`;
80+
}
81+
82+
output += `\n</content>`;
4583

4684
return {
4785
path: fileSource.path,
4886
repository: fileSource.repo,
4987
language: fileSource.language,
50-
source: addLineNumbers(slicedLines.join('\n'), offset ?? 1),
51-
truncated,
88+
source: output,
5289
totalLines: lines.length,
5390
revision,
5491
};
92+
},
93+
toModelOutput: ({ output }) => {
94+
return {
95+
type: 'content',
96+
value: [{
97+
type: 'text',
98+
text: output.source,
99+
}]
100+
}
55101
}
56102
});
57103

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,9 @@
1-
Reads the contents of a file. Use offset/limit to read a specific portion, which is strongly preferred for large files when only a specific section is needed. Maximum 500 lines per call. To read multiple files, call this tool in parallel.
1+
Read the contents of a file in a repository.
2+
3+
Usage:
4+
- Use offset/limit to read a specific portion of a file, which is strongly preferred for large files when only a specific section is needed.
5+
- Maximum 500 lines per call. Output is also capped at 5KB — if the cap is hit, call again with a larger offset to continue reading.
6+
- Any line longer than 2000 characters is truncated.
7+
- The response content includes the line range read and total line count. If the output was truncated, the next offset to continue reading is also included.
8+
- Call this tool in parallel when you need to read multiple files simultaneously.
9+
- Avoid tiny repeated slices. If you need more context, read a larger window.

packages/web/src/features/chat/tools/searchCode.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ export const createCodeSearchTool = (selectedRepos: string[]) => tool({
9696
});
9797

9898
if (isServiceError(response)) {
99-
return response;
99+
throw new Error(response.message);
100100
}
101101

102102
return {

0 commit comments

Comments
 (0)