From ae57f13301fbcee6dacab4fcdd4d885afd94c0bb Mon Sep 17 00:00:00 2001 From: Amogh Sunil Date: Mon, 6 Jul 2026 22:55:39 +0530 Subject: [PATCH] test: add unit coverage for cost-tracker, compact, knowledge, tool-utils MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These core modules had no unit tests. Added deterministic, offline suites (node:test) covering their pure logic: - cost-tracker: session/per-model aggregation, derived totalTokens, defensive get() copy, reset - compact: token estimation, per-message-type accounting, compaction threshold, tool-result truncation, transcript rendering + compact prompt building - knowledge: index loading (always_load preload, available listing, missing files, malformed index) and prompt formatting - tool-utils: GCToolDefinition → AgentTool mapping, string vs object handler results, abort-signal forwarding All 26 new cases live under test/ so `npm test` runs them. Full suite: 53 passing. --- test/compact.test.ts | 105 ++++++++++++++++++++++++++++++++++++++ test/cost-tracker.test.ts | 80 +++++++++++++++++++++++++++++ test/knowledge.test.ts | 99 +++++++++++++++++++++++++++++++++++ test/tool-utils.test.ts | 52 +++++++++++++++++++ 4 files changed, 336 insertions(+) create mode 100644 test/compact.test.ts create mode 100644 test/cost-tracker.test.ts create mode 100644 test/knowledge.test.ts create mode 100644 test/tool-utils.test.ts diff --git a/test/compact.test.ts b/test/compact.test.ts new file mode 100644 index 0000000..51db39d --- /dev/null +++ b/test/compact.test.ts @@ -0,0 +1,105 @@ +import { describe, it, before } from "node:test"; +import assert from "node:assert/strict"; + +let compact: typeof import("../dist/compact.js"); + +before(async () => { + compact = await import("../dist/compact.js"); +}); + +describe("estimateTokens", () => { + it("uses the ~4 chars per token heuristic (rounding up)", () => { + assert.equal(compact.estimateTokens(""), 0); + assert.equal(compact.estimateTokens("abcd"), 1); + assert.equal(compact.estimateTokens("abcde"), 2); + }); +}); + +describe("estimateMessageTokens", () => { + it("sums across message types and adds overhead for tool_use", () => { + const msgs = [ + { type: "user", content: "aaaa" }, // 1 + { type: "assistant", content: "bbbb", thinking: "cccc" }, // 1 + 1 + { type: "tool_use", toolName: "x", toolCallId: "1", args: {} }, // "{}" → 1 + 50 + { type: "tool_result", toolName: "x", toolCallId: "1", content: "dddd", isError: false }, // 1 + ] as any; + // 1 + 2 + 51 + 1 + assert.equal(compact.estimateMessageTokens(msgs), 55); + }); + + it("returns 0 for an empty conversation", () => { + assert.equal(compact.estimateMessageTokens([]), 0); + }); +}); + +describe("needsCompaction", () => { + it("flags when estimate exceeds 75% of the context window", () => { + const big = [{ type: "user", content: "x".repeat(4 * 800) }] as any; // ~800 tokens + const r = compact.needsCompaction(big, 1000); + assert.equal(r.needed, true); + assert.equal(r.tokenEstimate, 800); + assert.equal(r.ratio, 0.8); + }); + + it("does not flag when comfortably under the limit", () => { + const small = [{ type: "user", content: "hello" }] as any; + const r = compact.needsCompaction(small, 200000); + assert.equal(r.needed, false); + assert.ok(r.ratio < 0.75); + }); +}); + +describe("truncateToolResults", () => { + it("truncates oversized tool results, keeping head and tail", () => { + const content = "A".repeat(6000) + "B".repeat(6000); // 12000 chars + const out = compact.truncateToolResults( + [{ type: "tool_result", toolName: "t", toolCallId: "1", content, isError: false }] as any, + 10000, + ); + const text = (out[0] as any).content; + assert.ok(text.length < content.length); + assert.ok(text.startsWith("A")); + assert.ok(text.endsWith("B")); + assert.match(text, /chars truncated/); + }); + + it("leaves small results and non-tool messages untouched", () => { + const msgs = [ + { type: "tool_result", toolName: "t", toolCallId: "1", content: "short", isError: false }, + { type: "user", content: "x".repeat(50000) }, + ] as any; + const out = compact.truncateToolResults(msgs, 100); + assert.equal((out[0] as any).content, "short"); + assert.equal((out[1] as any).content.length, 50000, "user message is never truncated"); + }); +}); + +describe("messagesToText / buildCompactPrompt", () => { + it("renders substantive turns and drops deltas/system noise", () => { + const msgs = [ + { type: "system", subtype: "session_start", content: "started" }, + { type: "delta", deltaType: "text", content: "streaming..." }, + { type: "user", content: "hi" }, + { type: "assistant", content: "hello" }, + { type: "tool_use", toolName: "read", toolCallId: "1", args: { path: "a" } }, + { type: "tool_result", toolName: "read", toolCallId: "1", content: "file body", isError: false }, + ] as any; + const text = compact.messagesToText(msgs); + assert.match(text, /User: hi/); + assert.match(text, /Assistant: hello/); + assert.match(text, /Tool call: read/); + assert.match(text, /Tool result \[read\]/); + assert.doesNotMatch(text, /streaming/); + assert.doesNotMatch(text, /started/); + }); + + it("buildCompactPrompt wraps the transcript with instructions", () => { + const prompt = compact.buildCompactPrompt([{ type: "user", content: "hi" }] as any); + assert.match(prompt, /Summarize this conversation/); + assert.match(prompt, /User: hi/); + }); + + it("buildCompactPrompt returns empty string for an empty conversation", () => { + assert.equal(compact.buildCompactPrompt([]), ""); + }); +}); diff --git a/test/cost-tracker.test.ts b/test/cost-tracker.test.ts new file mode 100644 index 0000000..7bc9ff9 --- /dev/null +++ b/test/cost-tracker.test.ts @@ -0,0 +1,80 @@ +import { describe, it, before } from "node:test"; +import assert from "node:assert/strict"; + +let CostTracker: typeof import("../dist/cost-tracker.js").CostTracker; + +before(async () => { + ({ CostTracker } = await import("../dist/cost-tracker.js")); +}); + +const usage = (over: Partial> = {}) => ({ + inputTokens: 100, + outputTokens: 40, + cacheReadTokens: 0, + cacheWriteTokens: 0, + totalTokens: 140, + costUsd: 0.5, + ...over, +}); + +describe("CostTracker", () => { + it("starts empty", () => { + const c = new CostTracker().get(); + assert.equal(c.totalInputTokens, 0); + assert.equal(c.totalOutputTokens, 0); + assert.equal(c.totalCostUsd, 0); + assert.equal(c.totalRequests, 0); + assert.deepEqual(c.modelUsage, {}); + assert.ok(c.startTime > 0); + }); + + it("accumulates session totals across requests", () => { + const t = new CostTracker(); + t.add("openai:gpt-4o", usage()); + t.add("openai:gpt-4o", usage({ inputTokens: 200, outputTokens: 60, costUsd: 1 })); + const c = t.get(); + assert.equal(c.totalInputTokens, 300); + assert.equal(c.totalOutputTokens, 100); + assert.equal(c.totalCostUsd, 1.5); + assert.equal(c.totalRequests, 2); + }); + + it("aggregates usage per model key", () => { + const t = new CostTracker(); + t.add("openai:gpt-4o", usage()); + t.add("anthropic:claude", usage({ inputTokens: 10, outputTokens: 5, costUsd: 0.1 })); + t.add("openai:gpt-4o", usage({ inputTokens: 1, outputTokens: 1, costUsd: 0 })); + const c = t.get(); + assert.equal(Object.keys(c.modelUsage).length, 2); + assert.equal(c.modelUsage["openai:gpt-4o"].requests, 2); + assert.equal(c.modelUsage["openai:gpt-4o"].inputTokens, 101); + assert.equal(c.modelUsage["anthropic:claude"].requests, 1); + assert.equal(c.modelUsage["anthropic:claude"].inputTokens, 10); + }); + + it("derives totalTokens per model when not supplied", () => { + const t = new CostTracker(); + // Omit totalTokens → falls back to input + output. + t.add("m", { inputTokens: 7, outputTokens: 3 } as any); + assert.equal(t.get().modelUsage["m"].totalTokens, 10); + }); + + it("get() returns a defensive copy of modelUsage", () => { + const t = new CostTracker(); + t.add("m", usage()); + const snapshot = t.get(); + delete (snapshot.modelUsage as any)["m"]; + // Mutating the snapshot must not corrupt tracker state. + assert.ok(t.get().modelUsage["m"], "internal modelUsage survived external mutation"); + }); + + it("reset() clears all counters", () => { + const t = new CostTracker(); + t.add("m", usage()); + t.reset(); + const c = t.get(); + assert.equal(c.totalInputTokens, 0); + assert.equal(c.totalRequests, 0); + assert.deepEqual(c.modelUsage, {}); + }); +}); diff --git a/test/knowledge.test.ts b/test/knowledge.test.ts new file mode 100644 index 0000000..129f0e7 --- /dev/null +++ b/test/knowledge.test.ts @@ -0,0 +1,99 @@ +import { describe, it, before } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +let knowledge: typeof import("../dist/knowledge.js"); + +before(async () => { + knowledge = await import("../dist/knowledge.js"); +}); + +// Build a temp agent dir with a knowledge/ index + docs. +async function makeAgent(indexYaml: string, docs: Record = {}): Promise { + const dir = await mkdtemp(join(tmpdir(), "gitagent-kn-")); + const kdir = join(dir, "knowledge"); + await mkdir(kdir, { recursive: true }); + await writeFile(join(kdir, "index.yaml"), indexYaml, "utf-8"); + for (const [name, body] of Object.entries(docs)) { + await writeFile(join(kdir, name), body, "utf-8"); + } + return dir; +} + +describe("loadKnowledge", () => { + it("returns empty when there is no knowledge index", async () => { + const dir = await mkdtemp(join(tmpdir(), "gitagent-kn-empty-")); + const k = await knowledge.loadKnowledge(dir); + assert.deepEqual(k, { preloaded: [], available: [] }); + await rm(dir, { recursive: true, force: true }); + }); + + it("preloads always_load docs and lists the rest as available", async () => { + const dir = await makeAgent( + [ + "entries:", + " - path: policy.md", + " tags: [hr]", + " priority: high", + " always_load: true", + " - path: faq.md", + " tags: [support]", + " priority: low", + "", + ].join("\n"), + { "policy.md": " Leave policy: 20 days. ", "faq.md": "Q&A" }, + ); + + const k = await knowledge.loadKnowledge(dir); + assert.equal(k.preloaded.length, 1); + assert.equal(k.preloaded[0].path, "policy.md"); + assert.equal(k.preloaded[0].content, "Leave policy: 20 days.", "content is trimmed"); + assert.equal(k.available.length, 1); + assert.equal(k.available[0].path, "faq.md"); + await rm(dir, { recursive: true, force: true }); + }); + + it("skips always_load entries whose file is missing", async () => { + const dir = await makeAgent( + [ + "entries:", + " - path: gone.md", + " tags: []", + " priority: high", + " always_load: true", + "", + ].join("\n"), + // no gone.md written + ); + const k = await knowledge.loadKnowledge(dir); + assert.deepEqual(k.preloaded, []); + assert.deepEqual(k.available, []); + await rm(dir, { recursive: true, force: true }); + }); + + it("returns empty for a malformed index", async () => { + const dir = await makeAgent("not: [a, valid, entries, list]\n"); + const k = await knowledge.loadKnowledge(dir); + assert.deepEqual(k, { preloaded: [], available: [] }); + await rm(dir, { recursive: true, force: true }); + }); +}); + +describe("formatKnowledgeForPrompt", () => { + it("returns empty string when there is nothing to inject", () => { + assert.equal(knowledge.formatKnowledgeForPrompt({ preloaded: [], available: [] }), ""); + }); + + it("inlines preloaded docs and lists available ones with a read hint", () => { + const out = knowledge.formatKnowledgeForPrompt({ + preloaded: [{ path: "policy.md", content: "Leave: 20 days" }], + available: [{ path: "faq.md", tags: ["support"], priority: "low" }], + }); + assert.match(out, /^# Knowledge/); + assert.match(out, /\nLeave: 20 days\n<\/knowledge>/); + assert.match(out, //); + assert.match(out, /Use the `read` tool/); + }); +}); diff --git a/test/tool-utils.test.ts b/test/tool-utils.test.ts new file mode 100644 index 0000000..14e20fa --- /dev/null +++ b/test/tool-utils.test.ts @@ -0,0 +1,52 @@ +import { describe, it, before } from "node:test"; +import assert from "node:assert/strict"; + +let toAgentTool: typeof import("../dist/tool-utils.js").toAgentTool; + +before(async () => { + ({ toAgentTool } = await import("../dist/tool-utils.js")); +}); + +const def = (handler: any) => ({ + name: "echo", + description: "Echoes input", + inputSchema: { properties: { text: { type: "string" } }, required: ["text"] }, + handler, +}); + +describe("toAgentTool", () => { + it("maps GCToolDefinition fields onto an AgentTool with a built schema", () => { + const t = toAgentTool(def(async (a: any) => a.text)); + assert.equal(t.name, "echo"); + assert.equal(t.label, "echo"); + assert.equal(t.description, "Echoes input"); + assert.equal(t.parameters.type, "object"); + assert.ok(t.parameters.properties.text); + assert.equal(typeof t.execute, "function"); + }); + + it("wraps a string handler result as text content", async () => { + const t = toAgentTool(def(async (a: any) => `got: ${a.text}`)); + const res = await t.execute("call-1", { text: "hi" }); + assert.deepEqual(res.content, [{ type: "text", text: "got: hi" }]); + assert.equal(res.details, undefined); + }); + + it("passes through text and details from an object handler result", async () => { + const t = toAgentTool(def(async () => ({ text: "done", details: { rows: 3 } }))); + const res = await t.execute("call-1", { text: "x" }); + assert.equal(res.content[0].text, "done"); + assert.deepEqual(res.details, { rows: 3 }); + }); + + it("forwards the abort signal to the handler", async () => { + let received: AbortSignal | undefined; + const t = toAgentTool(def(async (_a: any, signal?: AbortSignal) => { + received = signal; + return "ok"; + })); + const ac = new AbortController(); + await t.execute("call-1", { text: "x" }, ac.signal); + assert.equal(received, ac.signal); + }); +});