Files
oh-my-pi/packages/coding-agent/test/read-multi-range.test.ts
T
can1357 8fc5b78f30 feat(read): added comma-separated scatter gather line selector support
- Extended selector regex and parser to accept ranges like `:5-16,960-973`.
- Ranges are sorted and merged automatically before reading.
- Out-of-bounds ranges surface as inline notices instead of errors.
- Added `#readLocalFileMultiRange` and `#buildInMemoryMultiRangeResult` for file, archive, notebook, and internal URL targets.
2026-05-14 05:58:25 +02:00

160 lines
6.0 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import type { ClientBridge } from "@oh-my-pi/pi-coding-agent/session/client-bridge";
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import type { ReadToolDetails } from "@oh-my-pi/pi-coding-agent/tools/read";
import { ReadTool } from "@oh-my-pi/pi-coding-agent/tools/read";
function textOutput(result: AgentToolResult<ReadToolDetails>): string {
return result.content
.filter(c => c.type === "text")
.map(c => c.text)
.join("\n");
}
function createSession(cwd: string, bridge?: ClientBridge): ToolSession {
const settings = Settings.isolated();
// Disable structural summarization so multi-range tests assert raw line content
// regardless of language heuristics.
settings.set("read.summarize.enabled", false);
return {
cwd,
hasUI: false,
getSessionFile: () => path.join(cwd, "session.jsonl"),
getSessionSpawns: () => "*",
getArtifactsDir: () => path.join(cwd, "artifacts"),
allocateOutputArtifact: async () => ({ id: "artifact-1", path: path.join(cwd, "artifact-1.log") }),
settings,
getClientBridge: bridge ? () => bridge : undefined,
};
}
function makeNumberedContent(lines: number): string {
return Array.from({ length: lines }, (_, i) => `line ${i + 1}`).join("\n");
}
describe("read tool multi-range selector", () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "read-multi-range-test-"));
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
it("returns both ranges separated by an elision marker", async () => {
const filePath = path.join(tmpDir, "numbered.txt");
await fs.writeFile(filePath, makeNumberedContent(50));
const tool = new ReadTool(createSession(tmpDir));
const result = await tool.execute("call-multi", { path: `${filePath}:3-5,20-22` });
const text = textOutput(result);
expect(text).toContain("line 3");
expect(text).toContain("line 4");
expect(text).toContain("line 5");
expect(text).toContain("line 20");
expect(text).toContain("line 21");
expect(text).toContain("line 22");
// Lines between the ranges must be elided
expect(text).not.toContain("line 10");
expect(text).not.toContain("line 19");
// Separator marker is present between blocks
expect(text).toContain("…");
});
it("merges overlapping ranges into a single contiguous block", async () => {
const filePath = path.join(tmpDir, "numbered.txt");
await fs.writeFile(filePath, makeNumberedContent(20));
const tool = new ReadTool(createSession(tmpDir));
// 3-7 and 6-9 overlap → merged into 3-9 (collapses to a single-range read).
const result = await tool.execute("call-merge", { path: `${filePath}:3-7,6-9` });
const text = textOutput(result);
// All lines from the merged range present
for (const i of [3, 4, 5, 6, 7, 8, 9]) {
expect(text).toContain(`line ${i}\n`);
}
// No separator because ranges merged into one contiguous block
expect(text).not.toContain("…");
});
it("sorts ranges in ascending order regardless of user order", async () => {
const filePath = path.join(tmpDir, "numbered.txt");
await fs.writeFile(filePath, makeNumberedContent(50));
const tool = new ReadTool(createSession(tmpDir));
const result = await tool.execute("call-sort", { path: `${filePath}:30-32,5-7` });
const text = textOutput(result);
const indexEarly = text.indexOf("line 5");
const indexLate = text.indexOf("line 30");
expect(indexEarly).toBeGreaterThanOrEqual(0);
expect(indexLate).toBeGreaterThan(indexEarly);
});
it("surfaces an inline notice when a range is past EOF", async () => {
const filePath = path.join(tmpDir, "small.txt");
await fs.writeFile(filePath, makeNumberedContent(10));
const tool = new ReadTool(createSession(tmpDir));
const result = await tool.execute("call-oob", { path: `${filePath}:3-5,999-1000` });
const text = textOutput(result);
expect(text).toContain("line 3");
expect(text).toContain("line 5");
expect(text).toContain("Range 999-1000 is beyond end of file (10 lines total); skipped");
});
it("supports the +count syntax in multi-range", async () => {
const filePath = path.join(tmpDir, "numbered.txt");
await fs.writeFile(filePath, makeNumberedContent(30));
const tool = new ReadTool(createSession(tmpDir));
const result = await tool.execute("call-plus", { path: `${filePath}:2+2,20+2` });
const text = textOutput(result);
expect(text).toContain("line 2");
expect(text).toContain("line 3");
expect(text).toContain("line 20");
expect(text).toContain("line 21");
expect(text).not.toContain("line 4");
expect(text).not.toContain("line 19");
});
it("rejects multi-range selectors on directories", async () => {
const tool = new ReadTool(createSession(tmpDir));
await expect(tool.execute("call-dir", { path: `${tmpDir}:1-2,5-6` })).rejects.toThrow(
/Multi-range line selectors are not supported for directory listings/,
);
});
it("routes multi-range reads through the ACP bridge when available", async () => {
const filePath = path.join(tmpDir, "disk.txt");
await fs.writeFile(filePath, "disk one\ndisk two\ndisk three\ndisk four\ndisk five\n");
const bridgeText = "bridge one\nbridge two\nbridge three\nbridge four\nbridge five\n";
const bridge: ClientBridge = {
capabilities: { readTextFile: true },
readTextFile: async () => bridgeText,
};
const tool = new ReadTool(createSession(tmpDir, bridge));
const result = await tool.execute("call-bridge", { path: `${filePath}:1-2,4-5` });
const text = textOutput(result);
expect(text).toContain("bridge one");
expect(text).toContain("bridge two");
expect(text).toContain("bridge four");
expect(text).toContain("bridge five");
expect(text).not.toContain("bridge three");
expect(text).not.toContain("disk");
});
});