openclaw-cortex/test/thread-tracker.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

533 lines
20 KiB
TypeScript

import { describe, it, expect, beforeEach } from "vitest";
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { ThreadTracker, extractSignals, matchesThread } from "../src/thread-tracker.js";
import type { Thread } from "../src/types.js";
const logger = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} };
function makeWorkspace(): string {
const ws = mkdtempSync(join(tmpdir(), "cortex-tt-"));
mkdirSync(join(ws, "memory", "reboot"), { recursive: true });
return ws;
}
function readThreads(ws: string) {
const raw = readFileSync(join(ws, "memory", "reboot", "threads.json"), "utf-8");
return JSON.parse(raw);
}
function makeThread(overrides: Partial<Thread> = {}): Thread {
return {
id: "test-id",
title: "auth migration OAuth2",
status: "open",
priority: "medium",
summary: "test thread",
decisions: [],
waiting_for: null,
mood: "neutral",
last_activity: new Date().toISOString(),
created: new Date().toISOString(),
...overrides,
};
}
// ════════════════════════════════════════════════════════════
// matchesThread
// ════════════════════════════════════════════════════════════
describe("matchesThread", () => {
it("matches when 2+ title words appear in text", () => {
const thread = makeThread({ title: "auth migration OAuth2" });
expect(matchesThread(thread, "the auth migration is progressing")).toBe(true);
});
it("does not match with only 1 overlapping word", () => {
const thread = makeThread({ title: "auth migration OAuth2" });
expect(matchesThread(thread, "auth is broken")).toBe(false);
});
it("does not match with zero overlapping words", () => {
const thread = makeThread({ title: "auth migration OAuth2" });
expect(matchesThread(thread, "the weather is nice")).toBe(false);
});
it("is case-insensitive", () => {
const thread = makeThread({ title: "Auth Migration" });
expect(matchesThread(thread, "the AUTH MIGRATION works")).toBe(true);
});
it("ignores words shorter than 3 characters", () => {
const thread = makeThread({ title: "a b c migration" });
// Only "migration" is > 2 chars, need 2 matches → false
expect(matchesThread(thread, "a b c something")).toBe(false);
});
it("respects custom minOverlap", () => {
const thread = makeThread({ title: "auth migration OAuth2" });
expect(matchesThread(thread, "auth migration OAuth2 is great", 3)).toBe(true);
expect(matchesThread(thread, "the auth migration is progressing", 3)).toBe(false);
});
it("handles empty title", () => {
const thread = makeThread({ title: "" });
expect(matchesThread(thread, "some text")).toBe(false);
});
it("handles empty text", () => {
const thread = makeThread({ title: "auth migration" });
expect(matchesThread(thread, "")).toBe(false);
});
});
// ════════════════════════════════════════════════════════════
// extractSignals
// ════════════════════════════════════════════════════════════
describe("extractSignals", () => {
it("extracts decision signals", () => {
const signals = extractSignals("We decided to use TypeScript for all plugins", "both");
expect(signals.decisions.length).toBeGreaterThan(0);
expect(signals.decisions[0]).toContain("decided");
});
it("extracts closure signals", () => {
const signals = extractSignals("The bug is fixed and working now", "both");
expect(signals.closures.length).toBeGreaterThan(0);
});
it("extracts wait signals", () => {
const signals = extractSignals("We are waiting for the code review", "both");
expect(signals.waits.length).toBeGreaterThan(0);
expect(signals.waits[0]).toContain("waiting for");
});
it("extracts topic signals", () => {
const signals = extractSignals("Let's get back to the auth migration", "both");
expect(signals.topics.length).toBeGreaterThan(0);
expect(signals.topics[0]).toContain("auth migration");
});
it("extracts multiple signal types from same text", () => {
const signals = extractSignals(
"Back to the auth module. We decided to fix it. It's done!",
"both",
);
expect(signals.topics.length).toBeGreaterThan(0);
expect(signals.decisions.length).toBeGreaterThan(0);
expect(signals.closures.length).toBeGreaterThan(0);
});
it("extracts German signals with 'both'", () => {
const signals = extractSignals("Wir haben beschlossen, das zu machen", "both");
expect(signals.decisions.length).toBeGreaterThan(0);
});
it("returns empty signals for unrelated text", () => {
const signals = extractSignals("The sky is blue and the grass is green", "both");
expect(signals.decisions).toHaveLength(0);
expect(signals.closures).toHaveLength(0);
expect(signals.waits).toHaveLength(0);
expect(signals.topics).toHaveLength(0);
});
it("extracts context window around decisions (50 before, 100 after)", () => {
const padding = "x".repeat(60);
const after = "y".repeat(120);
const text = `${padding}decided to use TypeScript${after}`;
const signals = extractSignals(text, "en");
expect(signals.decisions.length).toBeGreaterThan(0);
// Context window should be trimmed
const ctx = signals.decisions[0];
expect(ctx.length).toBeLessThan(text.length);
});
it("handles empty text", () => {
const signals = extractSignals("", "both");
expect(signals.decisions).toHaveLength(0);
expect(signals.closures).toHaveLength(0);
expect(signals.waits).toHaveLength(0);
expect(signals.topics).toHaveLength(0);
});
});
// ════════════════════════════════════════════════════════════
// ThreadTracker — basic operations
// ════════════════════════════════════════════════════════════
describe("ThreadTracker", () => {
let workspace: string;
let tracker: ThreadTracker;
beforeEach(() => {
workspace = makeWorkspace();
tracker = new ThreadTracker(workspace, {
enabled: true,
pruneDays: 7,
maxThreads: 50,
}, "both", logger);
});
it("starts with empty threads", () => {
expect(tracker.getThreads()).toHaveLength(0);
});
it("detects a new topic from a topic pattern", () => {
tracker.processMessage("Let's get back to the auth migration", "user");
const threads = tracker.getThreads();
expect(threads.length).toBeGreaterThanOrEqual(1);
// Should contain something related to "auth migration"
const found = threads.some(t =>
t.title.toLowerCase().includes("auth migration"),
);
expect(found).toBe(true);
});
it("creates thread with correct defaults", () => {
tracker.processMessage("back to the deployment pipeline", "user");
const thread = tracker.getThreads().find(t =>
t.title.toLowerCase().includes("deployment pipeline"),
);
expect(thread).toBeDefined();
expect(thread!.status).toBe("open");
expect(thread!.decisions).toHaveLength(0);
expect(thread!.waiting_for).toBeNull();
expect(thread!.id).toBeTruthy();
expect(thread!.created).toBeTruthy();
expect(thread!.last_activity).toBeTruthy();
});
it("does not create duplicate threads for same topic", () => {
tracker.processMessage("back to the deployment pipeline", "user");
tracker.processMessage("back to the deployment pipeline", "user");
const threads = tracker.getThreads().filter(t =>
t.title.toLowerCase().includes("deployment pipeline"),
);
expect(threads.length).toBe(1);
});
it("closes a thread when closure pattern detected", () => {
tracker.processMessage("back to the login bug fix", "user");
tracker.processMessage("the login bug fix is done ✅", "assistant");
const threads = tracker.getThreads();
const loginThread = threads.find(t =>
t.title.toLowerCase().includes("login bug"),
);
expect(loginThread?.status).toBe("closed");
});
it("appends decisions to matching threads", () => {
tracker.processMessage("back to the auth migration plan", "user");
tracker.processMessage("For the auth migration plan, we decided to use OAuth2 with PKCE", "assistant");
const thread = tracker.getThreads().find(t =>
t.title.toLowerCase().includes("auth migration"),
);
expect(thread?.decisions.length).toBeGreaterThan(0);
});
it("updates waiting_for on matching threads", () => {
tracker.processMessage("back to the deployment pipeline work", "user");
tracker.processMessage("The deployment pipeline is waiting for the staging environment fix", "user");
const thread = tracker.getThreads().find(t =>
t.title.toLowerCase().includes("deployment pipeline"),
);
expect(thread?.waiting_for).toBeTruthy();
});
it("updates mood on threads when mood detected", () => {
tracker.processMessage("back to the auth migration work", "user");
tracker.processMessage("this auth migration is awesome! auth migration rocks 🚀", "user");
const thread = tracker.getThreads().find(t =>
t.title.toLowerCase().includes("auth migration"),
);
expect(thread?.mood).not.toBe("neutral");
});
it("persists threads to disk", () => {
tracker.processMessage("back to the config refactor", "user");
const data = readThreads(workspace);
expect(data.version).toBe(2);
expect(data.threads.length).toBeGreaterThan(0);
});
it("tracks session mood", () => {
tracker.processMessage("This is awesome! 🚀", "user");
expect(tracker.getSessionMood()).not.toBe("neutral");
});
it("increments events processed", () => {
tracker.processMessage("hello", "user");
tracker.processMessage("world", "user");
expect(tracker.getEventsProcessed()).toBe(2);
});
it("skips empty content", () => {
tracker.processMessage("", "user");
expect(tracker.getEventsProcessed()).toBe(0);
});
it("persists integrity data", () => {
tracker.processMessage("back to something here now", "user");
const data = readThreads(workspace);
expect(data.integrity).toBeDefined();
expect(data.integrity.source).toBe("hooks");
expect(data.integrity.events_processed).toBe(1);
});
});
// ════════════════════════════════════════════════════════════
// ThreadTracker — pruning
// ════════════════════════════════════════════════════════════
describe("ThreadTracker — pruning", () => {
it("prunes closed threads older than pruneDays", () => {
const workspace = makeWorkspace();
// Seed with an old closed thread
const oldDate = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString();
const threadsData = {
version: 2,
updated: oldDate,
threads: [
makeThread({
id: "old-closed",
title: "old deployment pipeline issue",
status: "closed",
last_activity: oldDate,
created: oldDate,
}),
makeThread({
id: "recent-open",
title: "recent auth migration work",
status: "open",
last_activity: new Date().toISOString(),
}),
],
integrity: { last_event_timestamp: oldDate, events_processed: 1, source: "hooks" as const },
session_mood: "neutral",
};
writeFileSync(
join(workspace, "memory", "reboot", "threads.json"),
JSON.stringify(threadsData),
);
const tracker = new ThreadTracker(workspace, {
enabled: true,
pruneDays: 7,
maxThreads: 50,
}, "both", logger);
// Trigger processing + prune
tracker.processMessage("back to the recent auth migration work update", "user");
const threads = tracker.getThreads();
expect(threads.find(t => t.id === "old-closed")).toBeUndefined();
expect(threads.find(t => t.id === "recent-open")).toBeDefined();
});
it("keeps closed threads within pruneDays", () => {
const workspace = makeWorkspace();
const recentDate = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString();
const threadsData = {
version: 2,
updated: recentDate,
threads: [
makeThread({
id: "recent-closed",
title: "recent fix completed done",
status: "closed",
last_activity: recentDate,
}),
],
integrity: { last_event_timestamp: recentDate, events_processed: 1, source: "hooks" as const },
session_mood: "neutral",
};
writeFileSync(
join(workspace, "memory", "reboot", "threads.json"),
JSON.stringify(threadsData),
);
const tracker = new ThreadTracker(workspace, {
enabled: true,
pruneDays: 7,
maxThreads: 50,
}, "both", logger);
tracker.processMessage("back to the something else here", "user");
expect(tracker.getThreads().find(t => t.id === "recent-closed")).toBeDefined();
});
});
// ════════════════════════════════════════════════════════════
// ThreadTracker — maxThreads cap
// ════════════════════════════════════════════════════════════
describe("ThreadTracker — maxThreads cap", () => {
it("enforces maxThreads cap by removing oldest closed threads", () => {
const workspace = makeWorkspace();
const threads: Thread[] = [];
// Create 8 threads: 5 open + 3 closed
for (let i = 0; i < 5; i++) {
threads.push(makeThread({
id: `open-${i}`,
title: `open thread number ${i} task`,
status: "open",
last_activity: new Date(Date.now() - i * 60000).toISOString(),
}));
}
for (let i = 0; i < 3; i++) {
threads.push(makeThread({
id: `closed-${i}`,
title: `closed thread number ${i} done`,
status: "closed",
last_activity: new Date(Date.now() - i * 60000).toISOString(),
}));
}
const threadsData = {
version: 2,
updated: new Date().toISOString(),
threads,
integrity: { last_event_timestamp: new Date().toISOString(), events_processed: 1, source: "hooks" as const },
session_mood: "neutral",
};
writeFileSync(
join(workspace, "memory", "reboot", "threads.json"),
JSON.stringify(threadsData),
);
const tracker = new ThreadTracker(workspace, {
enabled: true,
pruneDays: 7,
maxThreads: 6, // 8 threads → cap at 6
}, "both", logger);
// Trigger processing which runs cap
tracker.processMessage("back to some topic here now", "user");
const result = tracker.getThreads();
expect(result.length).toBeLessThanOrEqual(7); // 6 + possible 1 new
// All open threads should be preserved
const openCount = result.filter(t => t.status === "open").length;
expect(openCount).toBeGreaterThanOrEqual(5);
});
});
// ════════════════════════════════════════════════════════════
// ThreadTracker — loading existing state
// ════════════════════════════════════════════════════════════
describe("ThreadTracker — loading existing state", () => {
it("loads threads from existing threads.json", () => {
const workspace = makeWorkspace();
const threadsData = {
version: 2,
updated: new Date().toISOString(),
threads: [
makeThread({ id: "existing-1", title: "existing auth migration thread" }),
],
integrity: { last_event_timestamp: new Date().toISOString(), events_processed: 5, source: "hooks" as const },
session_mood: "excited",
};
writeFileSync(
join(workspace, "memory", "reboot", "threads.json"),
JSON.stringify(threadsData),
);
const tracker = new ThreadTracker(workspace, {
enabled: true,
pruneDays: 7,
maxThreads: 50,
}, "both", logger);
expect(tracker.getThreads()).toHaveLength(1);
expect(tracker.getThreads()[0].id).toBe("existing-1");
});
it("handles missing threads.json gracefully", () => {
const workspace = makeWorkspace();
const tracker = new ThreadTracker(workspace, {
enabled: true,
pruneDays: 7,
maxThreads: 50,
}, "both", logger);
expect(tracker.getThreads()).toHaveLength(0);
});
it("handles corrupt threads.json gracefully", () => {
const workspace = makeWorkspace();
writeFileSync(
join(workspace, "memory", "reboot", "threads.json"),
"not valid json{{{",
);
const tracker = new ThreadTracker(workspace, {
enabled: true,
pruneDays: 7,
maxThreads: 50,
}, "both", logger);
expect(tracker.getThreads()).toHaveLength(0);
});
});
// ════════════════════════════════════════════════════════════
// ThreadTracker — flush
// ════════════════════════════════════════════════════════════
describe("ThreadTracker — flush", () => {
it("flush() persists dirty state", () => {
const workspace = makeWorkspace();
const tracker = new ThreadTracker(workspace, {
enabled: true,
pruneDays: 7,
maxThreads: 50,
}, "both", logger);
tracker.processMessage("back to the pipeline review", "user");
const result = tracker.flush();
expect(result).toBe(true);
});
it("flush() returns true when no dirty state", () => {
const workspace = makeWorkspace();
const tracker = new ThreadTracker(workspace, {
enabled: true,
pruneDays: 7,
maxThreads: 50,
}, "both", logger);
expect(tracker.flush()).toBe(true);
});
});
// ════════════════════════════════════════════════════════════
// ThreadTracker — priority inference
// ════════════════════════════════════════════════════════════
describe("ThreadTracker — priority inference", () => {
it("assigns high priority for topics with impact keywords", () => {
const workspace = makeWorkspace();
const tracker = new ThreadTracker(workspace, {
enabled: true,
pruneDays: 7,
maxThreads: 50,
}, "both", logger);
tracker.processMessage("back to the security audit review", "user");
const thread = tracker.getThreads().find(t =>
t.title.toLowerCase().includes("security"),
);
expect(thread?.priority).toBe("high");
});
it("assigns medium priority for generic topics", () => {
const workspace = makeWorkspace();
const tracker = new ThreadTracker(workspace, {
enabled: true,
pruneDays: 7,
maxThreads: 50,
}, "both", logger);
tracker.processMessage("back to the feature flag setup", "user");
const thread = tracker.getThreads().find(t =>
t.title.toLowerCase().includes("feature flag"),
);
expect(thread?.priority).toBe("medium");
});
});