fix: review round 2 — credential error types, test gaps, git_refs truncation

Amolith and Shelley created

- Use ConfigError (not ToolInputError) for missing Kagi/Tabstack
  credentials in web command — these are config/env errors
- Fix web_search test that silently passed when no error was thrown
- Truncate git_refs output consistent with other git tools
- Add expandHomePath unit tests
- Add "test" script to package.json
- Update AGENTS.md to reflect test suite existence
- Minor import formatting cleanup in cli/index.ts

Co-authored-by: Shelley <shelley@exe.dev>

Change summary

AGENTS.md                      |  2 
package.json                   |  1 
src/agent/tools/git/refs.ts    | 29 +++++++++++++----------
src/cli/commands/web.ts        |  6 ++--
src/cli/index.ts               |  1 
test/expand-home-path.test.ts  | 44 ++++++++++++++++++++++++++++++++++++
test/web-search.test.ts        | 19 ++++++++-------
test/workspace-cleanup.test.ts |  4 +-
8 files changed, 77 insertions(+), 29 deletions(-)

Detailed changes

AGENTS.md 🔗

@@ -10,7 +10,7 @@ bun run build     # Build to dist/
 bun run typecheck # TypeScript check (also: bun run lint)
 ```
 
-No test suite currently exists (`test/` is empty).
+Run `bun test` to execute the test suite.
 
 ## Architecture
 

package.json 🔗

@@ -11,6 +11,7 @@
     "build": "bun build src/cli/index.ts --outdir dist --target=node",
     "start": "bun dist/cli/index.js",
     "lint": "bun run --silent typecheck",
+    "test": "bun test",
     "typecheck": "tsc --noEmit"
   },
   "dependencies": {

src/agent/tools/git/refs.ts 🔗

@@ -1,6 +1,7 @@
 import { Type } from "@sinclair/typebox";
 import type { AgentTool } from "@mariozechner/pi-ai";
 import simpleGit from "simple-git";
+import { formatSize, truncateHead } from "../../../util/truncate.js";
 
 const RefsSchema = Type.Object({
   type: Type.Union([
@@ -18,26 +19,28 @@ export const createGitRefsTool = (workspacePath: string): AgentTool => ({
   execute: async (_toolCallId: string, params: any) => {
     const git = simpleGit(workspacePath);
 
+    let raw: string;
+    let baseDetails: Record<string, any> = {};
+
     if (params.type === "tags") {
       const tags = await git.tags();
-      return {
-        content: [{ type: "text", text: tags.all.join("\n") }],
-        details: { count: tags.all.length },
-      };
+      raw = tags.all.join("\n");
+      baseDetails = { count: tags.all.length };
+    } else if (params.type === "remotes") {
+      raw = await git.raw(["branch", "-r"]);
+    } else {
+      raw = await git.raw(["branch", "-a"]);
     }
 
-    if (params.type === "remotes") {
-      const raw = await git.raw(["branch", "-r"]);
-      return {
-        content: [{ type: "text", text: raw }],
-        details: {},
-      };
+    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)})]`;
     }
 
-    const raw = await git.raw(["branch", "-a"]);
     return {
-      content: [{ type: "text", text: raw }],
-      details: {},
+      content: [{ type: "text", text }],
+      details: { ...baseDetails, ...(truncation.truncated ? { truncation } : {}) },
     };
   },
 });

src/cli/commands/web.ts 🔗

@@ -13,7 +13,7 @@ import { createWebSearchTool } from "../../agent/tools/web-search.js";
 import { runAgent } from "../../agent/runner.js";
 import { WEB_SYSTEM_PROMPT } from "../../agent/prompts/web.js";
 import { createEventLogger, printUsageSummary } from "../output.js";
-import { FetchError, ToolInputError } from "../../util/errors.js";
+import { ConfigError, FetchError } from "../../util/errors.js";
 
 const INJECT_THRESHOLD = 50 * 1024;
 
@@ -45,10 +45,10 @@ export async function runWebCommand(options: WebCommandOptions): Promise<void> {
       process.env["TABSTACK_API_KEY"];
 
     if (!kagiSession) {
-      throw new ToolInputError("Missing Kagi session token (set KAGI_SESSION_TOKEN or config)");
+      throw new ConfigError("Missing Kagi session token (set KAGI_SESSION_TOKEN or config)");
     }
     if (!tabstackKey) {
-      throw new ToolInputError("Missing Tabstack API key (set TABSTACK_API_KEY or config)");
+      throw new ConfigError("Missing Tabstack API key (set TABSTACK_API_KEY or config)");
     }
 
     let systemPrompt = WEB_SYSTEM_PROMPT;

src/cli/index.ts 🔗

@@ -2,7 +2,6 @@
 import { runWebCommand } from "./commands/web.js";
 import { runRepoCommand } from "./commands/repo.js";
 import { RumiloError } from "../util/errors.js";
-
 import { parseArgs } from "./parse-args.js";
 
 async function main() {

test/expand-home-path.test.ts 🔗

@@ -0,0 +1,44 @@
+import { describe, test, expect, beforeEach, afterEach } from "bun:test";
+import { expandHomePath } from "../src/util/path.js";
+
+describe("expandHomePath", () => {
+  let savedHome: string | undefined;
+
+  beforeEach(() => {
+    savedHome = process.env["HOME"];
+  });
+
+  afterEach(() => {
+    if (savedHome === undefined) {
+      delete process.env["HOME"];
+    } else {
+      process.env["HOME"] = savedHome;
+    }
+  });
+
+  test("returns path unchanged when HOME is unset", () => {
+    delete process.env["HOME"];
+    expect(expandHomePath("~/foo/bar")).toBe("~/foo/bar");
+  });
+
+  test("bare ~ returns HOME", () => {
+    process.env["HOME"] = "/Users/alice";
+    expect(expandHomePath("~")).toBe("/Users/alice");
+  });
+
+  test("~/foo/bar expands to $HOME/foo/bar", () => {
+    process.env["HOME"] = "/Users/alice";
+    expect(expandHomePath("~/foo/bar")).toBe("/Users/alice/foo/bar");
+  });
+
+  test("paths without tilde are returned unchanged", () => {
+    process.env["HOME"] = "/Users/alice";
+    expect(expandHomePath("/absolute/path")).toBe("/absolute/path");
+    expect(expandHomePath("relative/path")).toBe("relative/path");
+  });
+
+  test("~user/foo is returned unchanged (not our expansion)", () => {
+    process.env["HOME"] = "/Users/alice";
+    expect(expandHomePath("~user/foo")).toBe("~user/foo");
+  });
+});

test/web-search.test.ts 🔗

@@ -1,5 +1,13 @@
-import { describe, test, expect } from "bun:test";
+import { describe, test, expect, mock } from "bun:test";
 import { FetchError, ToolInputError } from "../src/util/errors.js";
+
+// Mock kagi-ken so the search function throws, exercising the FetchError wrapping
+mock.module("kagi-ken", () => ({
+  search: async () => {
+    throw new Error("Unauthorized");
+  },
+}));
+
 import { createWebSearchTool } from "../src/agent/tools/web-search.js";
 
 describe("web_search error handling (issue #11)", () => {
@@ -9,14 +17,7 @@ describe("web_search error handling (issue #11)", () => {
   });
 
   test("search API failure is wrapped as FetchError", async () => {
-    // Use a bogus token so the kagi API call fails
     const tool = createWebSearchTool("invalid-token-xxx");
-    try {
-      await tool.execute("id", { query: "test query" });
-      // If it somehow succeeds (unlikely), that's fine
-    } catch (e: any) {
-      // After fix, errors from the search API should be wrapped as FetchError
-      expect(e).toBeInstanceOf(FetchError);
-    }
+    await expect(tool.execute("id", { query: "test query" })).rejects.toThrow(FetchError);
   });
 });

test/workspace-cleanup.test.ts 🔗

@@ -4,7 +4,7 @@ import { rm } from "node:fs/promises";
 import { tmpdir } from "node:os";
 import { join } from "node:path";
 import { execSync } from "node:child_process";
-import { ToolInputError } from "../src/util/errors.js";
+import { ConfigError } from "../src/util/errors.js";
 
 /**
  * Snapshot rumilo-* dirs in tmpdir so we can detect leaks.
@@ -50,7 +50,7 @@ describe("web command – workspace cleanup on early failure", () => {
         cleanup: true,
       });
     } catch (e: any) {
-      expect(e).toBeInstanceOf(ToolInputError);
+      expect(e).toBeInstanceOf(ConfigError);
     }
 
     const after = rumiloTmpDirs();