Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions test/compact.test.ts
Original file line number Diff line number Diff line change
@@ -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([]), "");
});
});
80 changes: 80 additions & 0 deletions test/cost-tracker.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, number>> = {}) => ({
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, {});
});
});
99 changes: 99 additions & 0 deletions test/knowledge.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {}): Promise<string> {
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, /<knowledge path="policy.md">\nLeave: 20 days\n<\/knowledge>/);
assert.match(out, /<doc path="knowledge\/faq.md" priority="low" tags="support" \/>/);
assert.match(out, /Use the `read` tool/);
});
});
52 changes: 52 additions & 0 deletions test/tool-utils.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});