openclaw-cortex/test/boot-context.test.ts
Claudia d41a13f914 feat: openclaw-cortex v0.1.0 — conversation intelligence plugin
Thread tracking, decision extraction, boot context generation,
pre-compaction snapshots, structured narratives.

- 10 source files, 1983 LOC TypeScript
- 9 test files, 270 tests passing
- Zero runtime dependencies
- Cerberus approved + all findings fixed
- EN/DE pattern matching, atomic file writes
- Graceful degradation (read-only workspace, corrupt JSON)
2026-02-17 12:16:49 +01:00

508 lines
21 KiB
TypeScript

import { describe, it, expect, beforeEach } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, utimesSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
BootContextGenerator,
getExecutionMode,
getOpenThreads,
integrityWarning,
} from "../src/boot-context.js";
import type { CortexConfig } from "../src/types.js";
const logger = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} };
function makeWorkspace(): string {
const ws = mkdtempSync(join(tmpdir(), "cortex-bc-"));
mkdirSync(join(ws, "memory", "reboot"), { recursive: true });
return ws;
}
const defaultBootConfig: CortexConfig["bootContext"] = {
enabled: true,
maxChars: 16000,
onSessionStart: true,
maxThreadsInBoot: 7,
maxDecisionsInBoot: 10,
decisionRecencyDays: 14,
};
function seedThreads(ws: string, threads: Record<string, unknown>[] = []) {
writeFileSync(
join(ws, "memory", "reboot", "threads.json"),
JSON.stringify({
version: 2,
updated: new Date().toISOString(),
threads,
integrity: {
last_event_timestamp: new Date().toISOString(),
events_processed: 5,
source: "hooks",
},
session_mood: "productive",
}),
);
}
function seedDecisions(ws: string, decisions: Record<string, unknown>[] = []) {
writeFileSync(
join(ws, "memory", "reboot", "decisions.json"),
JSON.stringify({
version: 1,
updated: new Date().toISOString(),
decisions,
}),
);
}
function seedNarrative(ws: string, content: string, hoursOld = 0) {
const filePath = join(ws, "memory", "reboot", "narrative.md");
writeFileSync(filePath, content);
if (hoursOld > 0) {
const mtime = new Date(Date.now() - hoursOld * 60 * 60 * 1000);
utimesSync(filePath, mtime, mtime);
}
}
function seedHotSnapshot(ws: string, content: string, hoursOld = 0) {
const filePath = join(ws, "memory", "reboot", "hot-snapshot.md");
writeFileSync(filePath, content);
if (hoursOld > 0) {
const mtime = new Date(Date.now() - hoursOld * 60 * 60 * 1000);
utimesSync(filePath, mtime, mtime);
}
}
// ════════════════════════════════════════════════════════════
// getExecutionMode
// ════════════════════════════════════════════════════════════
describe("getExecutionMode", () => {
it("returns a string containing a mode description", () => {
const mode = getExecutionMode();
expect(typeof mode).toBe("string");
expect(mode.length).toBeGreaterThan(0);
// Should contain one of the known modes
const validModes = ["Morning", "Afternoon", "Evening", "Night"];
expect(validModes.some(m => mode.includes(m))).toBe(true);
});
});
// ════════════════════════════════════════════════════════════
// getOpenThreads
// ════════════════════════════════════════════════════════════
describe("getOpenThreads", () => {
it("returns only open threads", () => {
const ws = makeWorkspace();
seedThreads(ws, [
{ id: "1", title: "open one", status: "open", priority: "medium", summary: "", decisions: [], waiting_for: null, mood: "neutral", last_activity: new Date().toISOString(), created: new Date().toISOString() },
{ id: "2", title: "closed one", status: "closed", priority: "medium", summary: "", decisions: [], waiting_for: null, mood: "neutral", last_activity: new Date().toISOString(), created: new Date().toISOString() },
]);
const threads = getOpenThreads(ws, 7);
expect(threads).toHaveLength(1);
expect(threads[0].id).toBe("1");
});
it("sorts by priority (critical first)", () => {
const ws = makeWorkspace();
seedThreads(ws, [
{ id: "low", title: "low", status: "open", priority: "low", summary: "", decisions: [], waiting_for: null, mood: "neutral", last_activity: new Date().toISOString(), created: new Date().toISOString() },
{ id: "critical", title: "crit", status: "open", priority: "critical", summary: "", decisions: [], waiting_for: null, mood: "neutral", last_activity: new Date().toISOString(), created: new Date().toISOString() },
{ id: "high", title: "high", status: "open", priority: "high", summary: "", decisions: [], waiting_for: null, mood: "neutral", last_activity: new Date().toISOString(), created: new Date().toISOString() },
]);
const threads = getOpenThreads(ws, 7);
expect(threads[0].id).toBe("critical");
expect(threads[1].id).toBe("high");
expect(threads[2].id).toBe("low");
});
it("within same priority, sorts by recency (newest first)", () => {
const ws = makeWorkspace();
const older = new Date(Date.now() - 60000).toISOString();
const newer = new Date().toISOString();
seedThreads(ws, [
{ id: "old", title: "old", status: "open", priority: "medium", summary: "", decisions: [], waiting_for: null, mood: "neutral", last_activity: older, created: older },
{ id: "new", title: "new", status: "open", priority: "medium", summary: "", decisions: [], waiting_for: null, mood: "neutral", last_activity: newer, created: newer },
]);
const threads = getOpenThreads(ws, 7);
expect(threads[0].id).toBe("new");
});
it("respects limit parameter", () => {
const ws = makeWorkspace();
const threads = Array.from({ length: 10 }, (_, i) => ({
id: `t-${i}`, title: `thread ${i}`, status: "open", priority: "medium",
summary: "", decisions: [], waiting_for: null, mood: "neutral",
last_activity: new Date().toISOString(), created: new Date().toISOString(),
}));
seedThreads(ws, threads);
const result = getOpenThreads(ws, 3);
expect(result).toHaveLength(3);
});
it("handles missing threads.json", () => {
const ws = makeWorkspace();
const threads = getOpenThreads(ws, 7);
expect(threads).toHaveLength(0);
});
});
// ════════════════════════════════════════════════════════════
// integrityWarning
// ════════════════════════════════════════════════════════════
describe("integrityWarning", () => {
it("returns warning when no integrity data", () => {
const ws = makeWorkspace();
writeFileSync(
join(ws, "memory", "reboot", "threads.json"),
JSON.stringify({ version: 2, threads: [], integrity: {}, session_mood: "neutral" }),
);
const warning = integrityWarning(ws);
expect(warning).toContain("⚠️");
});
it("returns empty string for fresh data", () => {
const ws = makeWorkspace();
seedThreads(ws);
const warning = integrityWarning(ws);
expect(warning).toBe("");
});
it("returns staleness warning for data > 2h old", () => {
const ws = makeWorkspace();
const old = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString();
writeFileSync(
join(ws, "memory", "reboot", "threads.json"),
JSON.stringify({
version: 2, threads: [], session_mood: "neutral",
integrity: { last_event_timestamp: old, events_processed: 1, source: "hooks" },
}),
);
const warning = integrityWarning(ws);
expect(warning).toContain("⚠️");
expect(warning).toContain("staleness");
});
it("returns STALE DATA for data > 8h old", () => {
const ws = makeWorkspace();
const old = new Date(Date.now() - 10 * 60 * 60 * 1000).toISOString();
writeFileSync(
join(ws, "memory", "reboot", "threads.json"),
JSON.stringify({
version: 2, threads: [], session_mood: "neutral",
integrity: { last_event_timestamp: old, events_processed: 1, source: "hooks" },
}),
);
const warning = integrityWarning(ws);
expect(warning).toContain("🚨");
expect(warning).toContain("STALE DATA");
});
it("handles missing file gracefully", () => {
const ws = makeWorkspace();
const warning = integrityWarning(ws);
expect(warning).toContain("⚠️");
});
});
// ════════════════════════════════════════════════════════════
// BootContextGenerator — generate
// ════════════════════════════════════════════════════════════
describe("BootContextGenerator — generate", () => {
it("produces valid markdown with header", () => {
const ws = makeWorkspace();
seedThreads(ws);
const gen = new BootContextGenerator(ws, defaultBootConfig, logger);
const md = gen.generate();
expect(md).toContain("# Context Briefing");
expect(md).toContain("Generated:");
});
it("includes execution mode", () => {
const ws = makeWorkspace();
seedThreads(ws);
const gen = new BootContextGenerator(ws, defaultBootConfig, logger);
const md = gen.generate();
expect(md).toContain("Mode:");
});
it("includes session mood if not neutral", () => {
const ws = makeWorkspace();
writeFileSync(
join(ws, "memory", "reboot", "threads.json"),
JSON.stringify({
version: 2, threads: [], session_mood: "excited",
integrity: { last_event_timestamp: new Date().toISOString(), events_processed: 1, source: "hooks" },
}),
);
const gen = new BootContextGenerator(ws, defaultBootConfig, logger);
const md = gen.generate();
expect(md).toContain("excited");
expect(md).toContain("🔥");
});
it("includes active threads section", () => {
const ws = makeWorkspace();
seedThreads(ws, [
{
id: "1", title: "auth migration", status: "open", priority: "high",
summary: "Migrating to OAuth2", decisions: ["use PKCE"], waiting_for: "code review",
mood: "productive", last_activity: new Date().toISOString(), created: new Date().toISOString(),
},
]);
const gen = new BootContextGenerator(ws, defaultBootConfig, logger);
const md = gen.generate();
expect(md).toContain("🧵 Active Threads");
expect(md).toContain("auth migration");
expect(md).toContain("🟠"); // high priority
expect(md).toContain("Migrating to OAuth2");
expect(md).toContain("⏳ Waiting for: code review");
expect(md).toContain("use PKCE");
});
it("includes recent decisions section", () => {
const ws = makeWorkspace();
seedThreads(ws);
seedDecisions(ws, [
{
id: "d1", what: "decided to use TypeScript",
date: new Date().toISOString().slice(0, 10),
why: "Type safety", impact: "high", who: "albert",
extracted_at: new Date().toISOString(),
},
]);
const gen = new BootContextGenerator(ws, defaultBootConfig, logger);
const md = gen.generate();
expect(md).toContain("🎯 Recent Decisions");
expect(md).toContain("decided to use TypeScript");
});
it("includes narrative when fresh", () => {
const ws = makeWorkspace();
seedThreads(ws);
seedNarrative(ws, "Today was productive. Built the cortex plugin.", 0);
const gen = new BootContextGenerator(ws, defaultBootConfig, logger);
const md = gen.generate();
expect(md).toContain("📖 Narrative");
expect(md).toContain("Today was productive");
});
it("excludes narrative when stale (>36h)", () => {
const ws = makeWorkspace();
seedThreads(ws);
seedNarrative(ws, "Old narrative content here", 48);
const gen = new BootContextGenerator(ws, defaultBootConfig, logger);
const md = gen.generate();
expect(md).not.toContain("Old narrative content");
});
it("includes hot snapshot when fresh", () => {
const ws = makeWorkspace();
seedThreads(ws);
seedHotSnapshot(ws, "# Hot Snapshot\nRecent conversation...", 0);
const gen = new BootContextGenerator(ws, defaultBootConfig, logger);
const md = gen.generate();
expect(md).toContain("🔥 Last Session Snapshot");
});
it("excludes hot snapshot when stale (>1h)", () => {
const ws = makeWorkspace();
seedThreads(ws);
seedHotSnapshot(ws, "# Old Snapshot\nOld conversation...", 2);
const gen = new BootContextGenerator(ws, defaultBootConfig, logger);
const md = gen.generate();
expect(md).not.toContain("Old Snapshot");
});
it("includes footer with stats", () => {
const ws = makeWorkspace();
seedThreads(ws);
const gen = new BootContextGenerator(ws, defaultBootConfig, logger);
const md = gen.generate();
expect(md).toContain("_Boot context |");
});
it("handles empty state gracefully", () => {
const ws = makeWorkspace();
const gen = new BootContextGenerator(ws, defaultBootConfig, logger);
const md = gen.generate();
expect(md).toContain("# Context Briefing");
expect(md).toContain("_Boot context |");
// Should still be valid markdown
expect(md.length).toBeGreaterThan(50);
});
it("excludes decisions older than decisionRecencyDays", () => {
const ws = makeWorkspace();
seedThreads(ws);
const oldDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
seedDecisions(ws, [
{
id: "old", what: "old decision about fonts",
date: oldDate, why: "legacy", impact: "medium", who: "user",
extracted_at: new Date().toISOString(),
},
]);
const gen = new BootContextGenerator(ws, defaultBootConfig, logger);
const md = gen.generate();
expect(md).not.toContain("old decision about fonts");
});
it("limits threads to maxThreadsInBoot", () => {
const ws = makeWorkspace();
const threads = Array.from({ length: 15 }, (_, i) => ({
id: `t-${i}`, title: `thread ${i}`, status: "open", priority: "medium",
summary: `summary ${i}`, decisions: [], waiting_for: null, mood: "neutral",
last_activity: new Date().toISOString(), created: new Date().toISOString(),
}));
seedThreads(ws, threads);
const config = { ...defaultBootConfig, maxThreadsInBoot: 3 };
const gen = new BootContextGenerator(ws, config, logger);
const md = gen.generate();
// Count thread section headers
const threadHeaders = (md.match(/^### /gm) || []).length;
expect(threadHeaders).toBe(3);
});
});
// ════════════════════════════════════════════════════════════
// BootContextGenerator — truncation
// ════════════════════════════════════════════════════════════
describe("BootContextGenerator — truncation", () => {
it("truncates output exceeding maxChars", () => {
const ws = makeWorkspace();
// Seed many threads to exceed budget
const threads = Array.from({ length: 20 }, (_, i) => ({
id: `t-${i}`, title: `very long thread title number ${i}`,
status: "open", priority: "medium",
summary: "A".repeat(500), decisions: ["X".repeat(100)],
waiting_for: "Y".repeat(100), mood: "neutral",
last_activity: new Date().toISOString(), created: new Date().toISOString(),
}));
seedThreads(ws, threads);
const config = { ...defaultBootConfig, maxChars: 2000 };
const gen = new BootContextGenerator(ws, config, logger);
const md = gen.generate();
expect(md.length).toBeLessThanOrEqual(2100); // 2000 + truncation marker
expect(md).toContain("[truncated");
});
it("does not truncate within budget", () => {
const ws = makeWorkspace();
seedThreads(ws);
const config = { ...defaultBootConfig, maxChars: 64000 };
const gen = new BootContextGenerator(ws, config, logger);
const md = gen.generate();
expect(md).not.toContain("[truncated");
});
});
// ════════════════════════════════════════════════════════════
// BootContextGenerator — write
// ════════════════════════════════════════════════════════════
describe("BootContextGenerator — write", () => {
it("writes BOOTSTRAP.md to workspace root", () => {
const ws = makeWorkspace();
seedThreads(ws);
const gen = new BootContextGenerator(ws, defaultBootConfig, logger);
const result = gen.write();
expect(result).toBe(true);
const content = readFileSync(join(ws, "BOOTSTRAP.md"), "utf-8");
expect(content).toContain("# Context Briefing");
});
it("overwrites existing BOOTSTRAP.md", () => {
const ws = makeWorkspace();
writeFileSync(join(ws, "BOOTSTRAP.md"), "old content");
seedThreads(ws);
const gen = new BootContextGenerator(ws, defaultBootConfig, logger);
gen.write();
const content = readFileSync(join(ws, "BOOTSTRAP.md"), "utf-8");
expect(content).toContain("# Context Briefing");
expect(content).not.toContain("old content");
});
});
// ════════════════════════════════════════════════════════════
// BootContextGenerator — shouldGenerate
// ════════════════════════════════════════════════════════════
describe("BootContextGenerator — shouldGenerate", () => {
it("returns true when enabled and onSessionStart", () => {
const ws = makeWorkspace();
const gen = new BootContextGenerator(ws, defaultBootConfig, logger);
expect(gen.shouldGenerate()).toBe(true);
});
it("returns false when disabled", () => {
const ws = makeWorkspace();
const config = { ...defaultBootConfig, enabled: false };
const gen = new BootContextGenerator(ws, config, logger);
expect(gen.shouldGenerate()).toBe(false);
});
it("returns false when onSessionStart is false", () => {
const ws = makeWorkspace();
const config = { ...defaultBootConfig, onSessionStart: false };
const gen = new BootContextGenerator(ws, config, logger);
expect(gen.shouldGenerate()).toBe(false);
});
});
// ════════════════════════════════════════════════════════════
// BootContextGenerator — mood display
// ════════════════════════════════════════════════════════════
describe("BootContextGenerator — mood display", () => {
it("shows frustrated mood with emoji", () => {
const ws = makeWorkspace();
writeFileSync(
join(ws, "memory", "reboot", "threads.json"),
JSON.stringify({
version: 2, threads: [], session_mood: "frustrated",
integrity: { last_event_timestamp: new Date().toISOString(), events_processed: 1, source: "hooks" },
}),
);
const gen = new BootContextGenerator(ws, defaultBootConfig, logger);
const md = gen.generate();
expect(md).toContain("frustrated");
expect(md).toContain("😤");
});
it("does not show mood line for neutral", () => {
const ws = makeWorkspace();
writeFileSync(
join(ws, "memory", "reboot", "threads.json"),
JSON.stringify({
version: 2, threads: [], session_mood: "neutral",
integrity: { last_event_timestamp: new Date().toISOString(), events_processed: 1, source: "hooks" },
}),
);
const gen = new BootContextGenerator(ws, defaultBootConfig, logger);
const md = gen.generate();
expect(md).not.toContain("Last session mood:");
});
});
// ════════════════════════════════════════════════════════════
// BootContextGenerator — staleness warnings in output
// ════════════════════════════════════════════════════════════
describe("BootContextGenerator — staleness in output", () => {
it("includes staleness warning for old data", () => {
const ws = makeWorkspace();
const old = new Date(Date.now() - 5 * 60 * 60 * 1000).toISOString();
writeFileSync(
join(ws, "memory", "reboot", "threads.json"),
JSON.stringify({
version: 2, threads: [], session_mood: "neutral",
integrity: { last_event_timestamp: old, events_processed: 1, source: "hooks" },
}),
);
const gen = new BootContextGenerator(ws, defaultBootConfig, logger);
const md = gen.generate();
expect(md).toContain("⚠️");
});
});