fix(tools): keep truncation metadata when spilling read output

The artifact spill wrapper dropped the read truncation metadata, so a
spilled oversized read lost its next-offset pagination hint, and an
artifact:// read of an already-spilled result was spilled a second time.
Preserve the existing truncation metadata and skip re-spilling artifact
reads.
This commit is contained in:
Wolfgang Schoenberger
2026-07-30 16:25:48 -07:00
parent 9a6a1b40be
commit 5f9558d17a
2 changed files with 59 additions and 16 deletions
@@ -680,6 +680,17 @@ async function spillLargeResultToArtifact(
const existingMeta: OutputMeta | undefined = result.details?.meta;
if (existingMeta?.truncation?.artifactId) return result;
// Reading an artifact already addresses recoverable full output. Spilling that
// read would only create a redundant artifact containing another artifact's
// page (and can repeat indefinitely on subsequent reads).
if (
toolName === "read" &&
existingMeta?.source?.type === "internal" &&
existingMeta.source.value.startsWith("artifact://")
) {
return result;
}
// Measure total text content
const textParts: string[] = [];
for (const block of result.content) {
@@ -759,6 +770,7 @@ async function spillLargeResultToArtifact(
elidedLines,
elidedBytes,
artifactId,
nextOffset: existingMeta?.truncation?.nextOffset,
};
} else {
const shownStart = truncated.totalLines - outputLines + 1;
@@ -772,6 +784,7 @@ async function spillLargeResultToArtifact(
maxBytes: tailBytes,
shownRange: { start: shownStart, end: truncated.totalLines },
artifactId,
nextOffset: existingMeta?.truncation?.nextOffset,
};
}
+46 -16
View File
@@ -237,7 +237,7 @@ function createTestToolSession(
getArtifactsDir: () => sessionDir,
allocateOutputArtifact: async (toolType: string) => {
fs.mkdirSync(sessionDir, { recursive: true });
const id = `artifact-${++artifactCounter}`;
const id = String(++artifactCounter);
return { id, path: path.join(sessionDir, `${id}.${toolType}.log`) };
},
settings,
@@ -640,30 +640,60 @@ describe("Coding Agent Tools", () => {
it("should spill oversized read output to an artifact", async () => {
const testFile = path.join(testDir, "oversized-read.txt");
const line = "0123456789".repeat(20);
fs.writeFileSync(testFile, `${Array.from({ length: 600 }, () => line).join("\n")}\n`);
fs.writeFileSync(testFile, `${Array.from({ length: 3500 }, () => line).join("\n")}\n`);
const spillSettings = Settings.isolated({
"tools.artifactSpillThreshold": 20,
"tools.artifactTailBytes": 1,
"tools.artifactTailLines": 10,
"tools.artifactHeadBytes": 0,
});
const spillSession = createTestToolSession(testDir, spillSettings);
const defaultLimit = spillSettings.get("read.defaultLimit");
const spillManager = SessionManager.create(testDir, path.join(testDir, "spill-sessions"));
await spillManager.ensureOnDisk();
const spillSession = createTestToolSession(testDir, spillSettings, {
getSessionFile: () => spillManager.getSessionFile() ?? null,
getArtifactsDir: () => spillManager.getArtifactsDir(),
localProtocolOptions: {
getArtifactsDir: () => spillManager.getArtifactsDir(),
getSessionId: () => spillManager.getSessionId(),
},
});
const spillReadTool = wrapToolWithMetaNotice(new ReadTool(spillSession));
const context = { ...createTestToolContext(["read"]), settings: spillSettings };
const context = {
...createTestToolContext(["read"]),
settings: spillSettings,
sessionManager: spillManager,
};
const result = await spillReadTool.execute(
"test-call-read-spill",
{ path: testFile },
undefined,
undefined,
context,
);
const truncation = result.details?.meta?.truncation;
const output = getTextOutput(result);
try {
const result = await spillReadTool.execute(
"test-call-read-spill",
{ path: testFile },
undefined,
undefined,
context,
);
const truncation = result.details?.meta?.truncation;
const output = getTextOutput(result);
expect(truncation?.artifactId).toBeDefined();
expect(Buffer.byteLength(output, "utf-8")).toBeLessThan(20 * 1024);
expect(output).toContain("artifact://");
expect(truncation?.artifactId).toBeDefined();
expect(Buffer.byteLength(output, "utf-8")).toBeLessThan(20 * 1024);
expect(output).toContain("artifact://");
expect(truncation?.nextOffset).toBe(defaultLimit + 1);
const saveArtifact = vi.spyOn(spillManager, "saveArtifact");
const artifactResult = await spillReadTool.execute(
"test-call-read-spilled-artifact",
{ path: `artifact://${truncation?.artifactId}` },
undefined,
undefined,
context,
);
expect(getTextOutput(artifactResult)).toContain(line);
expect(saveArtifact).not.toHaveBeenCalled();
} finally {
await spillManager.close();
}
});
it("should render directories as a two-level tree without capping root entries", async () => {