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
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>
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(-)
@@ -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
@@ -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": {
@@ -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 } : {}) },
};
},
});
@@ -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;
@@ -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() {
@@ -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");
+ });
+});
@@ -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);
});
});
@@ -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();