diff --git a/src/agent/tools/git/blame.ts b/src/agent/tools/git/blame.ts index 8cfbc6b3ba46d2876f3fd5c7978133fca485049b..b12605a81cefed72c544386a16df2c9ce53712f9 100644 --- a/src/agent/tools/git/blame.ts +++ b/src/agent/tools/git/blame.ts @@ -2,6 +2,7 @@ import { Type } from "@sinclair/typebox"; import type { AgentTool } from "@mariozechner/pi-ai"; import simpleGit from "simple-git"; import { ToolInputError } from "../../../util/errors.js"; +import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead } from "../../../util/truncate.js"; const BlameSchema = Type.Object({ path: Type.String({ description: "File path relative to repo root" }), @@ -17,11 +18,17 @@ export const createGitBlameTool = (workspacePath: string): AgentTool => ({ throw new ToolInputError("path must be a non-empty string"); } const git = simpleGit(workspacePath); - const text = await git.raw(["blame", "--", params.path]); + const raw = await git.raw(["blame", "--", params.path]); + const truncation = truncateHead(raw); + + let text = truncation.content; + if (truncation.truncated) { + text += `\n\n[truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)})]`; + } return { content: [{ type: "text", text }], - details: { path: params.path }, + details: { path: params.path, ...(truncation.truncated ? { truncation } : {}) }, }; }, }); diff --git a/src/agent/tools/git/diff.ts b/src/agent/tools/git/diff.ts index 56773c2fa75c861a08eeff9ddc1647253c2e61a1..08454d90ee9599ed308465aa11d8f578aa318771 100644 --- a/src/agent/tools/git/diff.ts +++ b/src/agent/tools/git/diff.ts @@ -2,6 +2,7 @@ import { Type } from "@sinclair/typebox"; import type { AgentTool } from "@mariozechner/pi-ai"; import simpleGit from "simple-git"; import { ToolInputError } from "../../../util/errors.js"; +import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead } from "../../../util/truncate.js"; const DiffSchema = Type.Object({ ref: Type.Optional(Type.String({ description: "Base ref (optional)" })), @@ -31,10 +32,17 @@ export const createGitDiffTool = (workspacePath: string): AgentTool => ({ if (params.ref2) args.push(params.ref2); if (params.path) args.push("--", params.path); - const text = await git.diff(args); + const raw = await git.diff(args); + const truncation = truncateHead(raw); + + let text = truncation.content; + if (truncation.truncated) { + text += `\n\n[truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)})]`; + } + return { content: [{ type: "text", text }], - details: { path: params.path ?? null }, + details: { path: params.path ?? null, ...(truncation.truncated ? { truncation } : {}) }, }; }, }); diff --git a/src/agent/tools/git/log.ts b/src/agent/tools/git/log.ts index 56fe195dbcf134901d4c302c27184cdbe0e46443..c23f00d15d7215b492b5998749e6d10a67aac12f 100644 --- a/src/agent/tools/git/log.ts +++ b/src/agent/tools/git/log.ts @@ -3,6 +3,8 @@ import type { AgentTool } from "@mariozechner/pi-ai"; import simpleGit from "simple-git"; import { ToolInputError } from "../../../util/errors.js"; +const DEFAULT_LOG_LIMIT = 20; + const LogSchema = Type.Object({ path: Type.Optional(Type.String({ description: "Filter to commits touching this path" })), author: Type.Optional(Type.String({ description: "Filter by author name/email" })), @@ -21,12 +23,11 @@ export const createGitLogTool = (workspacePath: string): AgentTool => ({ const git = simpleGit(workspacePath); const options: string[] = []; - if (params.n !== undefined) { - if (typeof params.n !== "number" || Number.isNaN(params.n) || params.n <= 0) { - throw new ToolInputError("n must be a positive number"); - } - options.push("-n", String(Math.floor(params.n))); + const limit = params.n !== undefined ? params.n : DEFAULT_LOG_LIMIT; + if (typeof limit !== "number" || Number.isNaN(limit) || limit <= 0) { + throw new ToolInputError("n must be a positive number"); } + options.push("-n", String(Math.floor(limit))); if (params.oneline) options.push("--oneline"); if (params.author && !String(params.author).trim()) { throw new ToolInputError("author must be a non-empty string"); diff --git a/src/agent/tools/git/show.ts b/src/agent/tools/git/show.ts index 9a60b0a6a63f2aea84db3c762b37f393bfc6bc29..9c0a5394ea88b6c1cc7f5c61a2b3b9019801ac92 100644 --- a/src/agent/tools/git/show.ts +++ b/src/agent/tools/git/show.ts @@ -2,6 +2,7 @@ import { Type } from "@sinclair/typebox"; import type { AgentTool } from "@mariozechner/pi-ai"; import simpleGit from "simple-git"; import { ToolInputError } from "../../../util/errors.js"; +import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead } from "../../../util/truncate.js"; const ShowSchema = Type.Object({ ref: Type.String({ description: "Commit hash or ref" }), @@ -17,11 +18,17 @@ export const createGitShowTool = (workspacePath: string): AgentTool => ({ throw new ToolInputError("ref must be a non-empty string"); } const git = simpleGit(workspacePath); - const text = await git.show([params.ref]); + const raw = await git.show([params.ref]); + const truncation = truncateHead(raw); + + let text = truncation.content; + if (truncation.truncated) { + text += `\n\n[truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)})]`; + } return { content: [{ type: "text", text }], - details: { ref: params.ref }, + details: { ref: params.ref, ...(truncation.truncated ? { truncation } : {}) }, }; }, }); diff --git a/test/git-tools.test.ts b/test/git-tools.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d59a5206aa5adec48404af8d2833a1ec24be6a2e --- /dev/null +++ b/test/git-tools.test.ts @@ -0,0 +1,131 @@ +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import simpleGit from "simple-git"; +import { createGitShowTool } from "../src/agent/tools/git/show.js"; +import { createGitDiffTool } from "../src/agent/tools/git/diff.js"; +import { createGitBlameTool } from "../src/agent/tools/git/blame.js"; +import { createGitLogTool } from "../src/agent/tools/git/log.js"; +import { DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES } from "../src/util/truncate.js"; + +function textOf(result: any): string { + return result.content[0].text; +} + +let workDir: string; +let git: ReturnType; + +beforeAll(async () => { + workDir = mkdtempSync(join(tmpdir(), "rumilo-git-test-")); + git = simpleGit(workDir); + await git.init(); + await git.addConfig("user.name", "Test"); + await git.addConfig("user.email", "test@test.com"); + + // Create a large file for truncation tests + const largeLine = "x".repeat(100); + const largeContent = Array.from({ length: 3000 }, (_, i) => `${i}: ${largeLine}`).join("\n"); + writeFileSync(join(workDir, "large.txt"), largeContent); + await git.add("large.txt"); + await git.commit("add large file"); + + // Create many commits for log default-limit test + for (let i = 0; i < 30; i++) { + writeFileSync(join(workDir, "counter.txt"), String(i)); + await git.add("counter.txt"); + await git.commit(`commit number ${i}`); + } +}); + +afterAll(() => { + try { + rmSync(workDir, { recursive: true, force: true }); + } catch {} +}); + +describe("git_show truncation (issue #8)", () => { + test("truncates large output and appends notice", async () => { + const tool = createGitShowTool(workDir); + // The first commit has the large file diff, which should exceed truncation limits + const logs = await git.log(); + const firstCommitHash = logs.all[logs.all.length - 1]!.hash; + const result = await tool.execute("call-1", { ref: firstCommitHash }); + const text = textOf(result); + + // Output should be bounded - not return all 3000+ lines raw + const lines = text.split("\n"); + expect(lines.length).toBeLessThanOrEqual(DEFAULT_MAX_LINES + 5); // small margin for notice + expect(Buffer.byteLength(text, "utf-8")).toBeLessThanOrEqual(DEFAULT_MAX_BYTES + 500); // margin for notice + expect(text).toContain("[truncated"); + }); + + test("small output is not truncated", async () => { + const tool = createGitShowTool(workDir); + const result = await tool.execute("call-2", { ref: "HEAD" }); + const text = textOf(result); + // HEAD commit is small (counter.txt change), should NOT be truncated + expect(text).not.toContain("[truncated"); + }); +}); + +describe("git_diff truncation (issue #8)", () => { + test("truncates large diff output", async () => { + const tool = createGitDiffTool(workDir); + const logs = await git.log(); + const firstCommitHash = logs.all[logs.all.length - 1]!.hash; + const secondCommitHash = logs.all[logs.all.length - 2]!.hash; + // Diff between first commit (large file add) and second commit + const result = await tool.execute("call-3", { ref: firstCommitHash, ref2: secondCommitHash }); + const text = textOf(result); + // The diff won't be huge (only counter.txt changes), so let's create a proper large diff scenario + // Instead, diff from the first commit to HEAD which has many changes but also large.txt unchanged + // Better: modify large.txt to create a big diff + // Actually, let's just verify the mechanism works by checking the first commit via show already. + // For diff specifically, create a modified version of large.txt + const largeLine2 = "y".repeat(100); + const largeContent2 = Array.from({ length: 3000 }, (_, i) => `${i}: ${largeLine2}`).join("\n"); + writeFileSync(join(workDir, "large.txt"), largeContent2); + const result2 = await tool.execute("call-3b", { ref: "HEAD" }); + const text2 = textOf(result2); + const lines2 = text2.split("\n"); + expect(lines2.length).toBeLessThanOrEqual(DEFAULT_MAX_LINES + 5); + expect(text2).toContain("[truncated"); + // Restore the file + await git.checkout(["--", "large.txt"]); + }); +}); + +describe("git_blame truncation (issue #8)", () => { + test("truncates large blame output", async () => { + const tool = createGitBlameTool(workDir); + const result = await tool.execute("call-4", { path: "large.txt" }); + const text = textOf(result); + const lines = text.split("\n"); + expect(lines.length).toBeLessThanOrEqual(DEFAULT_MAX_LINES + 5); + expect(Buffer.byteLength(text, "utf-8")).toBeLessThanOrEqual(DEFAULT_MAX_BYTES + 500); + expect(text).toContain("[truncated"); + }); +}); + +describe("git_log default limit (issue #9)", () => { + test("returns at most 20 commits when n is not specified", async () => { + const tool = createGitLogTool(workDir); + const result: any = await tool.execute("call-5", {}); + // We have 31 commits total (1 large file + 30 counter), default should limit to 20 + expect(result.details.count).toBeLessThanOrEqual(20); + expect(result.details.count).toBe(20); + }); + + test("explicit n overrides default limit", async () => { + const tool = createGitLogTool(workDir); + const result: any = await tool.execute("call-6", { n: 5 }); + expect(result.details.count).toBe(5); + }); + + test("explicit n larger than 20 works", async () => { + const tool = createGitLogTool(workDir); + const result: any = await tool.execute("call-7", { n: 25 }); + expect(result.details.count).toBe(25); + }); +});