mateclaw/mateclaw-server/src/test/java/vip/mate/tool/builtin/ReadFileToolLargeLineTest.java

195 lines
9.0 KiB
Java

package vip.mate.tool.builtin;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import vip.mate.i18n.I18nService;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
/**
* Regression tests for reading oversized content with {@link ReadFileTool}.
* <p>
* The original bug: a file whose only line exceeds the output budget returned
* empty content with readLines=0 and a continuation hint (startLine) that never
* advanced — an infinite retry loop. These tests assert that the tool always
* makes progress, clearly flags truncation, and lets a caller page through both
* a very long single line (via nextStartColumn) and subsequent normal lines
* (via nextStartLine).
*/
class ReadFileToolLargeLineTest {
private ReadFileTool tool;
@BeforeEach
void setUp() {
// Default answer echoes msg(key, ...) back as the key, so assertions stay
// locale-agnostic and the answer covers every varargs arity uniformly.
I18nService i18n = mock(I18nService.class, inv ->
"msg".equals(inv.getMethod().getName()) ? inv.getArgument(0) : null);
tool = new ReadFileTool(i18n);
// Ensure no workspace boundary is active so absolute temp paths validate.
ToolExecutionContext.clear();
}
@AfterEach
void tearDown() {
ToolExecutionContext.clear();
}
/** Build a single-line JSON array of {@code n} string elements. */
private static String oneLineJsonArray(int n) {
StringBuilder json = new StringBuilder("[");
for (int i = 0; i < n; i++) {
if (i > 0) json.append(',');
json.append("\"item-").append(i).append("\"");
}
return json.append(']').toString();
}
/** Strip the "%6d\t" line-number prefixes and the truncation marker from content. */
private static String stripDecorations(String content) {
StringBuilder out = new StringBuilder();
for (String l : content.split("\n", -1)) {
if (l.isEmpty()) continue;
int tab = l.indexOf('\t');
String body = tab >= 0 ? l.substring(tab + 1) : l;
int marker = body.indexOf("tool.read_file.line_truncated_marker");
if (marker >= 0) body = body.substring(0, marker);
out.append(body);
}
return out.toString();
}
@Test
@DisplayName("single line larger than the 30KB budget returns clipped content, not empty + infinite loop")
void singleOversizedLine_returnsClippedContent(@TempDir Path dir) throws Exception {
String json = oneLineJsonArray(4000); // ~40KB on one physical line
Path file = dir.resolve("big.json");
Files.writeString(file, json, StandardCharsets.UTF_8);
String raw = tool.read_file(file.toString(), null, null, null, null);
JSONObject res = JSONUtil.parseObj(raw);
assertFalse(res.getBool("error", false), "should not be an error result");
assertTrue(res.getBool("truncated"), "should be marked truncated");
assertTrue(res.getBool("lineTruncated", false), "should flag in-line truncation");
assertEquals(1, res.getInt("readLines"), "must count the clipped line as read");
assertTrue(res.getStr("content").length() > 1000, "content must carry the clipped line, not be empty");
// Continuation must advance into the same line, not loop on column 1.
assertEquals(1, res.getInt("nextStartLine"));
assertTrue(res.getInt("nextStartColumn") > 1, "nextStartColumn must advance past the head");
}
@Test
@DisplayName("a very long single line can be fully read by paging through nextStartColumn")
void oversizedLine_pagesToCompletionViaColumn(@TempDir Path dir) throws Exception {
String json = oneLineJsonArray(10000); // big enough to need several windows
Path file = dir.resolve("huge.json");
Files.writeString(file, json, StandardCharsets.UTF_8);
StringBuilder reassembled = new StringBuilder();
Integer startLine = null;
Integer startColumn = null;
int guard = 0;
while (true) {
String raw = tool.read_file(file.toString(), startLine, null, startColumn, null);
JSONObject res = JSONUtil.parseObj(raw);
assertFalse(res.getBool("error", false), "no error while paging");
reassembled.append(stripDecorations(res.getStr("content")));
if (!res.getBool("truncated")) {
break;
}
startLine = res.getInt("nextStartLine");
startColumn = res.containsKey("nextStartColumn") ? res.getInt("nextStartColumn") : 1;
assertTrue(++guard < 50, "must terminate, not loop forever");
}
assertEquals(json, reassembled.toString(), "paging through columns must reconstruct the whole line");
}
@Test
@DisplayName("multi-line file with a huge first line still lets the caller reach later normal lines")
void hugeFirstLine_thenNormalLines_offerNextLine(@TempDir Path dir) throws Exception {
String first = oneLineJsonArray(4000); // oversized line 1
Path file = dir.resolve("mixed.txt");
Files.writeString(file, first + "\nsecond-line\nthird-line\n", StandardCharsets.UTF_8);
// First read clips line 1 and must point both at the line's tail and the next line.
String raw = tool.read_file(file.toString(), null, null, null, null);
JSONObject res = JSONUtil.parseObj(raw);
assertTrue(res.getBool("truncated"));
assertTrue(res.getBool("lineTruncated", false));
assertEquals(3, res.getInt("totalLines"));
// The caller can skip the rest of the giant line and read the normal lines.
String raw2 = tool.read_file(file.toString(), 2, null, null, null);
JSONObject res2 = JSONUtil.parseObj(raw2);
assertFalse(res2.getBool("truncated"));
assertEquals(2, res2.getInt("readLines"));
assertTrue(res2.getStr("content").contains("second-line"));
assertTrue(res2.getStr("content").contains("third-line"));
}
@Test
@DisplayName("single-line spill-style JSON {\"stdout\":\"...\"} is windowed, not dropped")
void spillStyleStdoutJson_isWindowed(@TempDir Path dir) throws Exception {
String payload = "x".repeat(50 * 1024); // 50KB payload on one line
String line = "{\"stdout\":\"" + payload + "\"}";
Path file = dir.resolve("spill.json");
Files.writeString(file, line, StandardCharsets.UTF_8);
String raw = tool.read_file(file.toString(), null, null, null, null);
JSONObject res = JSONUtil.parseObj(raw);
assertTrue(res.getBool("truncated"));
assertTrue(res.getBool("lineTruncated", false));
assertTrue(res.getStr("content").contains("{\"stdout\":"), "head of the line must be present");
assertTrue(res.getInt("nextStartColumn") > 1);
}
@Test
@DisplayName("normal truncation at a line boundary advertises nextStartLine for continuation")
void manyNormalLines_truncateAtLineBoundary(@TempDir Path dir) throws Exception {
// 2000 lines of ~50 chars each well exceeds the 30KB budget but no single
// line is oversized, so truncation must happen at a clean line boundary.
StringBuilder sb = new StringBuilder();
for (int i = 1; i <= 2000; i++) {
sb.append("line-").append(i).append("-").append("y".repeat(40)).append('\n');
}
Path file = dir.resolve("many.txt");
Files.writeString(file, sb.toString(), StandardCharsets.UTF_8);
String raw = tool.read_file(file.toString(), null, null, null, null);
JSONObject res = JSONUtil.parseObj(raw);
assertTrue(res.getBool("truncated"));
assertFalse(res.getBool("lineTruncated", false), "no individual line is oversized");
int next = res.getInt("nextStartLine");
assertEquals(res.getInt("endLine") + 1, next, "continuation must resume right after the last read line");
assertFalse(res.containsKey("nextStartColumn"), "line-boundary truncation has no column");
}
@Test
@DisplayName("normal multi-line file reads fully without truncation")
void smallFile_readsFully(@TempDir Path dir) throws Exception {
Path file = dir.resolve("small.txt");
Files.writeString(file, "alpha\nbeta\ngamma\n", StandardCharsets.UTF_8);
String raw = tool.read_file(file.toString(), null, null, null, null);
JSONObject res = JSONUtil.parseObj(raw);
assertFalse(res.getBool("truncated"), "small file should not truncate");
assertEquals(3, res.getInt("readLines"));
assertTrue(res.getStr("content").contains("beta"));
}
}