test(context): add unit tests for context window guard
Add tests for resolveContextWindowInfo, evaluateContextWindowGuard, and checkContextWindow functions with various threshold scenarios. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
4201095519
commit
0f8d032b68
2 changed files with 536 additions and 0 deletions
259
src/agent/context-window/guard.test.ts
Normal file
259
src/agent/context-window/guard.test.ts
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
CONTEXT_WINDOW_HARD_MIN_TOKENS,
|
||||
CONTEXT_WINDOW_WARN_BELOW_TOKENS,
|
||||
DEFAULT_CONTEXT_TOKENS,
|
||||
resolveContextWindowInfo,
|
||||
evaluateContextWindowGuard,
|
||||
checkContextWindow,
|
||||
} from "./guard.js";
|
||||
|
||||
describe("guard", () => {
|
||||
describe("constants", () => {
|
||||
it("should have correct hard minimum tokens", () => {
|
||||
expect(CONTEXT_WINDOW_HARD_MIN_TOKENS).toBe(16_000);
|
||||
});
|
||||
|
||||
it("should have correct warning threshold tokens", () => {
|
||||
expect(CONTEXT_WINDOW_WARN_BELOW_TOKENS).toBe(32_000);
|
||||
});
|
||||
|
||||
it("should have correct default context tokens", () => {
|
||||
expect(DEFAULT_CONTEXT_TOKENS).toBe(200_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveContextWindowInfo", () => {
|
||||
it("should prioritize model context window", () => {
|
||||
const result = resolveContextWindowInfo({
|
||||
modelContextWindow: 100_000,
|
||||
configContextTokens: 50_000,
|
||||
defaultTokens: 200_000,
|
||||
});
|
||||
|
||||
expect(result.tokens).toBe(100_000);
|
||||
expect(result.source).toBe("model");
|
||||
});
|
||||
|
||||
it("should fall back to config when model is undefined", () => {
|
||||
const result = resolveContextWindowInfo({
|
||||
modelContextWindow: undefined,
|
||||
configContextTokens: 50_000,
|
||||
defaultTokens: 200_000,
|
||||
});
|
||||
|
||||
expect(result.tokens).toBe(50_000);
|
||||
expect(result.source).toBe("config");
|
||||
});
|
||||
|
||||
it("should fall back to default when both model and config are undefined", () => {
|
||||
const result = resolveContextWindowInfo({
|
||||
modelContextWindow: undefined,
|
||||
configContextTokens: undefined,
|
||||
defaultTokens: 150_000,
|
||||
});
|
||||
|
||||
expect(result.tokens).toBe(150_000);
|
||||
expect(result.source).toBe("default");
|
||||
});
|
||||
|
||||
it("should use DEFAULT_CONTEXT_TOKENS when no default provided", () => {
|
||||
const result = resolveContextWindowInfo({});
|
||||
|
||||
expect(result.tokens).toBe(DEFAULT_CONTEXT_TOKENS);
|
||||
expect(result.source).toBe("default");
|
||||
});
|
||||
|
||||
it("should ignore non-positive model values", () => {
|
||||
const result = resolveContextWindowInfo({
|
||||
modelContextWindow: 0,
|
||||
configContextTokens: 50_000,
|
||||
});
|
||||
|
||||
expect(result.tokens).toBe(50_000);
|
||||
expect(result.source).toBe("config");
|
||||
});
|
||||
|
||||
it("should ignore negative model values", () => {
|
||||
const result = resolveContextWindowInfo({
|
||||
modelContextWindow: -1000,
|
||||
configContextTokens: 50_000,
|
||||
});
|
||||
|
||||
expect(result.tokens).toBe(50_000);
|
||||
expect(result.source).toBe("config");
|
||||
});
|
||||
|
||||
it("should ignore NaN values", () => {
|
||||
const result = resolveContextWindowInfo({
|
||||
modelContextWindow: NaN,
|
||||
configContextTokens: NaN,
|
||||
defaultTokens: 100_000,
|
||||
});
|
||||
|
||||
expect(result.tokens).toBe(100_000);
|
||||
expect(result.source).toBe("default");
|
||||
});
|
||||
|
||||
it("should ignore Infinity values", () => {
|
||||
const result = resolveContextWindowInfo({
|
||||
modelContextWindow: Infinity,
|
||||
configContextTokens: 50_000,
|
||||
});
|
||||
|
||||
expect(result.tokens).toBe(50_000);
|
||||
expect(result.source).toBe("config");
|
||||
});
|
||||
|
||||
it("should floor decimal values", () => {
|
||||
const result = resolveContextWindowInfo({
|
||||
modelContextWindow: 100_000.9,
|
||||
});
|
||||
|
||||
expect(result.tokens).toBe(100_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateContextWindowGuard", () => {
|
||||
it("should not warn or block when tokens are high enough", () => {
|
||||
const result = evaluateContextWindowGuard({
|
||||
info: { tokens: 100_000, source: "model" },
|
||||
});
|
||||
|
||||
expect(result.shouldWarn).toBe(false);
|
||||
expect(result.shouldBlock).toBe(false);
|
||||
expect(result.tokens).toBe(100_000);
|
||||
expect(result.source).toBe("model");
|
||||
});
|
||||
|
||||
it("should warn but not block when tokens are between thresholds", () => {
|
||||
const result = evaluateContextWindowGuard({
|
||||
info: { tokens: 20_000, source: "config" },
|
||||
});
|
||||
|
||||
expect(result.shouldWarn).toBe(true);
|
||||
expect(result.shouldBlock).toBe(false);
|
||||
});
|
||||
|
||||
it("should both warn and block when tokens are below hard minimum", () => {
|
||||
const result = evaluateContextWindowGuard({
|
||||
info: { tokens: 10_000, source: "default" },
|
||||
});
|
||||
|
||||
expect(result.shouldWarn).toBe(true);
|
||||
expect(result.shouldBlock).toBe(true);
|
||||
});
|
||||
|
||||
it("should use custom thresholds", () => {
|
||||
const result = evaluateContextWindowGuard({
|
||||
info: { tokens: 5_000, source: "model" },
|
||||
warnBelowTokens: 10_000,
|
||||
hardMinTokens: 3_000,
|
||||
});
|
||||
|
||||
expect(result.shouldWarn).toBe(true);
|
||||
expect(result.shouldBlock).toBe(false);
|
||||
});
|
||||
|
||||
it("should block with custom hard minimum", () => {
|
||||
const result = evaluateContextWindowGuard({
|
||||
info: { tokens: 5_000, source: "model" },
|
||||
hardMinTokens: 8_000,
|
||||
});
|
||||
|
||||
expect(result.shouldBlock).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle zero tokens", () => {
|
||||
const result = evaluateContextWindowGuard({
|
||||
info: { tokens: 0, source: "model" },
|
||||
});
|
||||
|
||||
expect(result.shouldWarn).toBe(false);
|
||||
expect(result.shouldBlock).toBe(false);
|
||||
expect(result.tokens).toBe(0);
|
||||
});
|
||||
|
||||
it("should floor negative tokens to zero", () => {
|
||||
const result = evaluateContextWindowGuard({
|
||||
info: { tokens: -1000, source: "model" },
|
||||
});
|
||||
|
||||
expect(result.tokens).toBe(0);
|
||||
});
|
||||
|
||||
it("should ensure minimum threshold of 1", () => {
|
||||
// When tokens is 5 and thresholds are floored to 1,
|
||||
// 5 >= 1 so shouldWarn and shouldBlock are false
|
||||
const result = evaluateContextWindowGuard({
|
||||
info: { tokens: 5, source: "model" },
|
||||
warnBelowTokens: 0,
|
||||
hardMinTokens: -100,
|
||||
});
|
||||
|
||||
// 5 is not < 1, so neither warn nor block
|
||||
expect(result.shouldWarn).toBe(false);
|
||||
expect(result.shouldBlock).toBe(false);
|
||||
});
|
||||
|
||||
it("should correctly apply floored threshold of 1", () => {
|
||||
// With tokens = 0, the condition (tokens > 0 && tokens < 1) is false
|
||||
// because tokens > 0 is false
|
||||
const result = evaluateContextWindowGuard({
|
||||
info: { tokens: 0, source: "model" },
|
||||
warnBelowTokens: 0,
|
||||
hardMinTokens: -100,
|
||||
});
|
||||
|
||||
expect(result.shouldWarn).toBe(false);
|
||||
expect(result.shouldBlock).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkContextWindow", () => {
|
||||
it("should combine resolution and evaluation", () => {
|
||||
const result = checkContextWindow({
|
||||
modelContextWindow: 100_000,
|
||||
});
|
||||
|
||||
expect(result.tokens).toBe(100_000);
|
||||
expect(result.source).toBe("model");
|
||||
expect(result.shouldWarn).toBe(false);
|
||||
expect(result.shouldBlock).toBe(false);
|
||||
});
|
||||
|
||||
it("should warn for low config tokens", () => {
|
||||
const result = checkContextWindow({
|
||||
configContextTokens: 25_000,
|
||||
});
|
||||
|
||||
expect(result.tokens).toBe(25_000);
|
||||
expect(result.source).toBe("config");
|
||||
expect(result.shouldWarn).toBe(true);
|
||||
expect(result.shouldBlock).toBe(false);
|
||||
});
|
||||
|
||||
it("should block for very low tokens", () => {
|
||||
const result = checkContextWindow({
|
||||
modelContextWindow: 8_000,
|
||||
});
|
||||
|
||||
expect(result.shouldBlock).toBe(true);
|
||||
});
|
||||
|
||||
it("should use all custom parameters", () => {
|
||||
const result = checkContextWindow({
|
||||
modelContextWindow: undefined,
|
||||
configContextTokens: undefined,
|
||||
defaultTokens: 50_000,
|
||||
warnBelowTokens: 60_000,
|
||||
hardMinTokens: 40_000,
|
||||
});
|
||||
|
||||
expect(result.tokens).toBe(50_000);
|
||||
expect(result.source).toBe("default");
|
||||
expect(result.shouldWarn).toBe(true);
|
||||
expect(result.shouldBlock).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
277
src/agent/session/storage.test.ts
Normal file
277
src/agent/session/storage.test.ts
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
resolveBaseDir,
|
||||
resolveSessionDir,
|
||||
resolveSessionPath,
|
||||
ensureSessionDir,
|
||||
readEntries,
|
||||
appendEntry,
|
||||
writeEntries,
|
||||
} from "./storage.js";
|
||||
import type { SessionEntry } from "./types.js";
|
||||
|
||||
describe("session/storage", () => {
|
||||
const testBaseDir = join(tmpdir(), `multica-session-test-${Date.now()}`);
|
||||
|
||||
beforeEach(() => {
|
||||
if (existsSync(testBaseDir)) {
|
||||
rmSync(testBaseDir, { recursive: true });
|
||||
}
|
||||
mkdirSync(testBaseDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(testBaseDir)) {
|
||||
rmSync(testBaseDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("resolveBaseDir", () => {
|
||||
it("should return custom baseDir when provided", () => {
|
||||
const result = resolveBaseDir({ baseDir: "/custom/path" });
|
||||
expect(result).toBe("/custom/path");
|
||||
});
|
||||
|
||||
it("should return default path when no options provided", () => {
|
||||
const result = resolveBaseDir();
|
||||
expect(result).toContain(".super-multica");
|
||||
expect(result).toContain("sessions");
|
||||
});
|
||||
|
||||
it("should return default path when options is empty", () => {
|
||||
const result = resolveBaseDir({});
|
||||
expect(result).toContain("sessions");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSessionDir", () => {
|
||||
it("should return session directory path", () => {
|
||||
const result = resolveSessionDir("test-session", { baseDir: testBaseDir });
|
||||
expect(result).toBe(join(testBaseDir, "test-session"));
|
||||
});
|
||||
|
||||
it("should handle session IDs with special characters", () => {
|
||||
const result = resolveSessionDir("session-123-abc", { baseDir: testBaseDir });
|
||||
expect(result).toBe(join(testBaseDir, "session-123-abc"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSessionPath", () => {
|
||||
it("should return path to session.jsonl file", () => {
|
||||
const result = resolveSessionPath("test-session", { baseDir: testBaseDir });
|
||||
expect(result).toBe(join(testBaseDir, "test-session", "session.jsonl"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureSessionDir", () => {
|
||||
it("should create session directory if it does not exist", () => {
|
||||
const sessionId = "new-session";
|
||||
ensureSessionDir(sessionId, { baseDir: testBaseDir });
|
||||
|
||||
const dir = join(testBaseDir, sessionId);
|
||||
expect(existsSync(dir)).toBe(true);
|
||||
});
|
||||
|
||||
it("should not fail if directory already exists", () => {
|
||||
const sessionId = "existing-session";
|
||||
const dir = join(testBaseDir, sessionId);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
expect(() => ensureSessionDir(sessionId, { baseDir: testBaseDir })).not.toThrow();
|
||||
expect(existsSync(dir)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readEntries", () => {
|
||||
it("should return empty array for non-existent session", () => {
|
||||
const entries = readEntries("non-existent", { baseDir: testBaseDir });
|
||||
expect(entries).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return empty array for empty file", () => {
|
||||
const sessionId = "empty-session";
|
||||
const dir = join(testBaseDir, sessionId);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "session.jsonl"), "");
|
||||
|
||||
const entries = readEntries(sessionId, { baseDir: testBaseDir });
|
||||
expect(entries).toEqual([]);
|
||||
});
|
||||
|
||||
it("should parse valid JSONL entries", () => {
|
||||
const sessionId = "valid-session";
|
||||
const dir = join(testBaseDir, sessionId);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
const entry1: SessionEntry = {
|
||||
type: "message",
|
||||
message: { role: "user", content: "Hello" },
|
||||
timestamp: 1000,
|
||||
};
|
||||
const entry2: SessionEntry = {
|
||||
type: "message",
|
||||
message: { role: "assistant", content: "Hi there" },
|
||||
timestamp: 2000,
|
||||
};
|
||||
|
||||
writeFileSync(
|
||||
join(dir, "session.jsonl"),
|
||||
`${JSON.stringify(entry1)}\n${JSON.stringify(entry2)}\n`
|
||||
);
|
||||
|
||||
const entries = readEntries(sessionId, { baseDir: testBaseDir });
|
||||
expect(entries).toHaveLength(2);
|
||||
expect(entries[0]).toEqual(entry1);
|
||||
expect(entries[1]).toEqual(entry2);
|
||||
});
|
||||
|
||||
it("should skip malformed lines", () => {
|
||||
const sessionId = "malformed-session";
|
||||
const dir = join(testBaseDir, sessionId);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
const validEntry: SessionEntry = {
|
||||
type: "message",
|
||||
message: { role: "user", content: "Valid" },
|
||||
timestamp: 1000,
|
||||
};
|
||||
|
||||
writeFileSync(
|
||||
join(dir, "session.jsonl"),
|
||||
`${JSON.stringify(validEntry)}\nnot valid json\n{broken: json}\n`
|
||||
);
|
||||
|
||||
const entries = readEntries(sessionId, { baseDir: testBaseDir });
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]).toEqual(validEntry);
|
||||
});
|
||||
|
||||
it("should handle meta entries", () => {
|
||||
const sessionId = "meta-session";
|
||||
const dir = join(testBaseDir, sessionId);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
const metaEntry: SessionEntry = {
|
||||
type: "meta",
|
||||
meta: { provider: "anthropic", model: "claude-3" },
|
||||
timestamp: 1000,
|
||||
};
|
||||
|
||||
writeFileSync(join(dir, "session.jsonl"), `${JSON.stringify(metaEntry)}\n`);
|
||||
|
||||
const entries = readEntries(sessionId, { baseDir: testBaseDir });
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]).toEqual(metaEntry);
|
||||
});
|
||||
|
||||
it("should handle compaction entries", () => {
|
||||
const sessionId = "compaction-session";
|
||||
const dir = join(testBaseDir, sessionId);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
const compactionEntry: SessionEntry = {
|
||||
type: "compaction",
|
||||
removed: 10,
|
||||
kept: 5,
|
||||
timestamp: 1000,
|
||||
tokensRemoved: 500,
|
||||
tokensKept: 200,
|
||||
reason: "tokens",
|
||||
};
|
||||
|
||||
writeFileSync(join(dir, "session.jsonl"), `${JSON.stringify(compactionEntry)}\n`);
|
||||
|
||||
const entries = readEntries(sessionId, { baseDir: testBaseDir });
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]).toEqual(compactionEntry);
|
||||
});
|
||||
});
|
||||
|
||||
describe("appendEntry", () => {
|
||||
it("should create file and append entry", async () => {
|
||||
const sessionId = "append-session";
|
||||
const entry: SessionEntry = {
|
||||
type: "message",
|
||||
message: { role: "user", content: "Hello" },
|
||||
timestamp: 1000,
|
||||
};
|
||||
|
||||
await appendEntry(sessionId, entry, { baseDir: testBaseDir });
|
||||
|
||||
const filePath = join(testBaseDir, sessionId, "session.jsonl");
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
|
||||
const content = readFileSync(filePath, "utf8");
|
||||
expect(content).toBe(`${JSON.stringify(entry)}\n`);
|
||||
});
|
||||
|
||||
it("should append to existing file", async () => {
|
||||
const sessionId = "append-existing";
|
||||
const entry1: SessionEntry = {
|
||||
type: "message",
|
||||
message: { role: "user", content: "First" },
|
||||
timestamp: 1000,
|
||||
};
|
||||
const entry2: SessionEntry = {
|
||||
type: "message",
|
||||
message: { role: "assistant", content: "Second" },
|
||||
timestamp: 2000,
|
||||
};
|
||||
|
||||
await appendEntry(sessionId, entry1, { baseDir: testBaseDir });
|
||||
await appendEntry(sessionId, entry2, { baseDir: testBaseDir });
|
||||
|
||||
const entries = readEntries(sessionId, { baseDir: testBaseDir });
|
||||
expect(entries).toHaveLength(2);
|
||||
expect(entries[0]).toEqual(entry1);
|
||||
expect(entries[1]).toEqual(entry2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeEntries", () => {
|
||||
it("should write all entries to file", async () => {
|
||||
const sessionId = "write-session";
|
||||
const entries: SessionEntry[] = [
|
||||
{ type: "message", message: { role: "user", content: "One" }, timestamp: 1000 },
|
||||
{ type: "message", message: { role: "assistant", content: "Two" }, timestamp: 2000 },
|
||||
];
|
||||
|
||||
await writeEntries(sessionId, entries, { baseDir: testBaseDir });
|
||||
|
||||
const readBack = readEntries(sessionId, { baseDir: testBaseDir });
|
||||
expect(readBack).toHaveLength(2);
|
||||
expect(readBack).toEqual(entries);
|
||||
});
|
||||
|
||||
it("should overwrite existing entries", async () => {
|
||||
const sessionId = "overwrite-session";
|
||||
|
||||
await writeEntries(
|
||||
sessionId,
|
||||
[{ type: "message", message: { role: "user", content: "Old" }, timestamp: 1000 }],
|
||||
{ baseDir: testBaseDir }
|
||||
);
|
||||
|
||||
const newEntries: SessionEntry[] = [
|
||||
{ type: "message", message: { role: "user", content: "New" }, timestamp: 2000 },
|
||||
];
|
||||
await writeEntries(sessionId, newEntries, { baseDir: testBaseDir });
|
||||
|
||||
const entries = readEntries(sessionId, { baseDir: testBaseDir });
|
||||
expect(entries).toHaveLength(1);
|
||||
expect((entries[0] as any).message.content).toBe("New");
|
||||
});
|
||||
|
||||
it("should handle empty entries array", async () => {
|
||||
const sessionId = "empty-write";
|
||||
await writeEntries(sessionId, [], { baseDir: testBaseDir });
|
||||
|
||||
const filePath = join(testBaseDir, sessionId, "session.jsonl");
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
expect(readFileSync(filePath, "utf8")).toBe("");
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue