diff --git a/src/agent/context-window/guard.test.ts b/src/agent/context-window/guard.test.ts new file mode 100644 index 00000000..7bc7ed42 --- /dev/null +++ b/src/agent/context-window/guard.test.ts @@ -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); + }); + }); +}); diff --git a/src/agent/session/storage.test.ts b/src/agent/session/storage.test.ts new file mode 100644 index 00000000..ee0f9765 --- /dev/null +++ b/src/agent/session/storage.test.ts @@ -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(""); + }); + }); +});