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): 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 one"); }); });