Initial commit: Discord-Claude Gateway with event-driven agent runtime

This commit is contained in:
2026-02-22 00:31:25 -05:00
commit 77d7c74909
58 changed files with 11772 additions and 0 deletions

View File

@@ -0,0 +1,129 @@
import { describe, it, expect, beforeEach } from "vitest";
import { ChannelQueue } from "../../src/channel-queue.js";
describe("ChannelQueue", () => {
let queue: ChannelQueue;
beforeEach(() => {
queue = new ChannelQueue();
});
it("executes a single task immediately", async () => {
let executed = false;
await queue.enqueue("ch-1", async () => { executed = true; });
expect(executed).toBe(true);
});
it("enqueue resolves when the task completes", async () => {
const order: number[] = [];
const p = queue.enqueue("ch-1", async () => {
await delay(20);
order.push(1);
});
await p;
expect(order).toEqual([1]);
});
it("executes tasks for the same channel sequentially in FIFO order", async () => {
const order: number[] = [];
const p1 = queue.enqueue("ch-1", async () => {
await delay(30);
order.push(1);
});
const p2 = queue.enqueue("ch-1", async () => {
await delay(10);
order.push(2);
});
const p3 = queue.enqueue("ch-1", async () => {
order.push(3);
});
await Promise.all([p1, p2, p3]);
expect(order).toEqual([1, 2, 3]);
});
it("executes tasks for different channels concurrently", async () => {
const order: string[] = [];
const p1 = queue.enqueue("ch-1", async () => {
await delay(40);
order.push("ch-1");
});
const p2 = queue.enqueue("ch-2", async () => {
await delay(10);
order.push("ch-2");
});
await Promise.all([p1, p2]);
// ch-2 should finish first since it has a shorter delay
expect(order).toEqual(["ch-2", "ch-1"]);
});
it("no concurrent execution within the same channel", async () => {
let concurrent = 0;
let maxConcurrent = 0;
const makeTask = () => async () => {
concurrent++;
maxConcurrent = Math.max(maxConcurrent, concurrent);
await delay(10);
concurrent--;
};
const promises = [
queue.enqueue("ch-1", makeTask()),
queue.enqueue("ch-1", makeTask()),
queue.enqueue("ch-1", makeTask()),
];
await Promise.all(promises);
expect(maxConcurrent).toBe(1);
});
it("propagates task errors to the enqueue caller", async () => {
await expect(
queue.enqueue("ch-1", async () => { throw new Error("boom"); })
).rejects.toThrow("boom");
});
it("continues processing after a task error", async () => {
let secondRan = false;
const p1 = queue.enqueue("ch-1", async () => { throw new Error("fail"); }).catch(() => {});
const p2 = queue.enqueue("ch-1", async () => { secondRan = true; });
await Promise.all([p1, p2]);
expect(secondRan).toBe(true);
});
it("drainAll resolves immediately when no tasks are queued", async () => {
await queue.drainAll();
});
it("drainAll waits for all in-flight and queued tasks", async () => {
const order: string[] = [];
queue.enqueue("ch-1", async () => {
await delay(20);
order.push("ch-1-a");
});
queue.enqueue("ch-1", async () => {
order.push("ch-1-b");
});
queue.enqueue("ch-2", async () => {
await delay(10);
order.push("ch-2-a");
});
await queue.drainAll();
expect(order).toContain("ch-1-a");
expect(order).toContain("ch-1-b");
expect(order).toContain("ch-2-a");
expect(order.length).toBe(3);
});
});
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@@ -0,0 +1,81 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { loadConfig } from "../../src/config.js";
describe("loadConfig", () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
// Set required vars by default
process.env.DISCORD_BOT_TOKEN = "test-discord-token";
process.env.ANTHROPIC_API_KEY = "test-anthropic-key";
});
afterEach(() => {
process.env = originalEnv;
});
it("should load required environment variables", () => {
const config = loadConfig();
expect(config.discordBotToken).toBe("test-discord-token");
expect(config.anthropicApiKey).toBe("test-anthropic-key");
});
it("should apply default values for optional config", () => {
const config = loadConfig();
expect(config.allowedTools).toEqual(["Read", "Write", "Edit", "Glob", "Grep", "WebSearch", "WebFetch"]);
expect(config.permissionMode).toBe("bypassPermissions");
expect(config.queryTimeoutMs).toBe(120_000);
expect(config.maxConcurrentQueries).toBe(5);
expect(config.configDir).toBe("./config");
expect(config.maxQueueDepth).toBe(100);
expect(config.outputChannelId).toBeUndefined();
});
it("should parse ALLOWED_TOOLS from comma-separated string", () => {
process.env.ALLOWED_TOOLS = "Read,Write,Bash";
const config = loadConfig();
expect(config.allowedTools).toEqual(["Read", "Write", "Bash"]);
});
it("should trim whitespace from ALLOWED_TOOLS entries", () => {
process.env.ALLOWED_TOOLS = " Read , Write , Bash ";
const config = loadConfig();
expect(config.allowedTools).toEqual(["Read", "Write", "Bash"]);
});
it("should read all optional environment variables", () => {
process.env.PERMISSION_MODE = "default";
process.env.QUERY_TIMEOUT_MS = "60000";
process.env.MAX_CONCURRENT_QUERIES = "10";
process.env.CONFIG_DIR = "/custom/config";
process.env.MAX_QUEUE_DEPTH = "200";
process.env.OUTPUT_CHANNEL_ID = "123456789";
const config = loadConfig();
expect(config.permissionMode).toBe("default");
expect(config.queryTimeoutMs).toBe(60_000);
expect(config.maxConcurrentQueries).toBe(10);
expect(config.configDir).toBe("/custom/config");
expect(config.maxQueueDepth).toBe(200);
expect(config.outputChannelId).toBe("123456789");
});
it("should throw when DISCORD_BOT_TOKEN is missing", () => {
delete process.env.DISCORD_BOT_TOKEN;
expect(() => loadConfig()).toThrow("DISCORD_BOT_TOKEN");
});
it("should throw when ANTHROPIC_API_KEY is missing", () => {
delete process.env.ANTHROPIC_API_KEY;
expect(() => loadConfig()).toThrow("ANTHROPIC_API_KEY");
});
it("should list all missing required variables in error message", () => {
delete process.env.DISCORD_BOT_TOKEN;
delete process.env.ANTHROPIC_API_KEY;
expect(() => loadConfig()).toThrow(
"Missing required environment variables: DISCORD_BOT_TOKEN, ANTHROPIC_API_KEY"
);
});
});

View File

@@ -0,0 +1,264 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { CronScheduler, type CronJob } from "../../src/cron-scheduler.js";
import type { Event } from "../../src/event-queue.js";
// Mock node-cron
vi.mock("node-cron", () => {
const tasks: Array<{ expression: string; callback: () => void; stopped: boolean }> = [];
return {
default: {
validate: (expr: string) => {
// Simple validation: reject obviously invalid expressions
if (expr === "invalid" || expr === "bad-cron" || expr === "not a cron") return false;
// Accept standard 5-field cron expressions
const parts = expr.trim().split(/\s+/);
return parts.length === 5 || parts.length === 6;
},
schedule: (expression: string, callback: () => void) => {
const task = { expression, callback, stopped: false };
tasks.push(task);
return {
stop: () => { task.stopped = true; },
start: () => { task.stopped = false; },
};
},
// Helper to get tasks for testing
_getTasks: () => tasks,
_clearTasks: () => { tasks.length = 0; },
_fireLast: () => {
const last = tasks[tasks.length - 1];
if (last && !last.stopped) last.callback();
},
_fireAll: () => {
for (const t of tasks) {
if (!t.stopped) t.callback();
}
},
},
};
});
// Import the mock to access helpers
import cron from "node-cron";
const mockCron = cron as unknown as {
validate: (expr: string) => boolean;
schedule: (expression: string, callback: () => void) => { stop: () => void };
_getTasks: () => Array<{ expression: string; callback: () => void; stopped: boolean }>;
_clearTasks: () => void;
_fireLast: () => void;
_fireAll: () => void;
};
type EnqueueFn = (event: Omit<Event, "id" | "timestamp">) => Event | null;
describe("CronScheduler", () => {
let scheduler: CronScheduler;
beforeEach(() => {
mockCron._clearTasks();
scheduler = new CronScheduler();
});
afterEach(() => {
scheduler.stop();
});
describe("parseConfig", () => {
it("parses a single cron job from agents.md content", () => {
const content = `## Cron Jobs
### daily-email-check
Cron: 0 9 * * *
Instruction: Check email and flag anything urgent`;
const jobs = scheduler.parseConfig(content);
expect(jobs).toEqual([
{ name: "daily-email-check", expression: "0 9 * * *", instruction: "Check email and flag anything urgent" },
]);
});
it("parses multiple cron jobs", () => {
const content = `## Cron Jobs
### daily-email-check
Cron: 0 9 * * *
Instruction: Check email and flag anything urgent
### weekly-review
Cron: 0 15 * * 1
Instruction: Review calendar for the week`;
const jobs = scheduler.parseConfig(content);
expect(jobs).toHaveLength(2);
expect(jobs[0]).toEqual({
name: "daily-email-check",
expression: "0 9 * * *",
instruction: "Check email and flag anything urgent",
});
expect(jobs[1]).toEqual({
name: "weekly-review",
expression: "0 15 * * 1",
instruction: "Review calendar for the week",
});
});
it("returns empty array when no Cron Jobs section exists", () => {
const content = `## Hooks
### startup
Instruction: Say hello`;
expect(scheduler.parseConfig(content)).toEqual([]);
});
it("returns empty array for empty content", () => {
expect(scheduler.parseConfig("")).toEqual([]);
});
it("skips incomplete job definitions (missing instruction)", () => {
const content = `## Cron Jobs
### incomplete-job
Cron: 0 9 * * *`;
expect(scheduler.parseConfig(content)).toEqual([]);
});
it("skips incomplete job definitions (missing cron expression)", () => {
const content = `## Cron Jobs
### incomplete-job
Instruction: Do something`;
expect(scheduler.parseConfig(content)).toEqual([]);
});
it("only parses jobs within the Cron Jobs section", () => {
const content = `## Hooks
### startup
Cron: 0 0 * * *
Instruction: This should not be parsed
## Cron Jobs
### real-job
Cron: 0 9 * * *
Instruction: This should be parsed
## Other Section
### not-a-job
Cron: 0 0 * * *
Instruction: This should not be parsed either`;
const jobs = scheduler.parseConfig(content);
expect(jobs).toHaveLength(1);
expect(jobs[0].name).toBe("real-job");
});
});
describe("start", () => {
it("schedules valid cron jobs and enqueues events on trigger", () => {
const enqueued: Omit<Event, "id" | "timestamp">[] = [];
const enqueue: EnqueueFn = (event) => {
enqueued.push(event);
return { ...event, id: enqueued.length, timestamp: new Date() } as Event;
};
const jobs: CronJob[] = [
{ name: "daily-check", expression: "0 9 * * *", instruction: "Check email" },
];
scheduler.start(jobs, enqueue);
// Simulate cron trigger
mockCron._fireAll();
expect(enqueued).toHaveLength(1);
expect(enqueued[0]).toEqual({
type: "cron",
payload: { instruction: "Check email", jobName: "daily-check" },
source: "cron-scheduler",
});
});
it("skips jobs with invalid cron expressions and logs a warning", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const enqueue: EnqueueFn = (event) =>
({ ...event, id: 1, timestamp: new Date() }) as Event;
const jobs: CronJob[] = [
{ name: "bad-job", expression: "invalid", instruction: "Do something" },
];
scheduler.start(jobs, enqueue);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("bad-job")
);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("invalid cron expression")
);
warnSpy.mockRestore();
});
it("schedules valid jobs and skips invalid ones in the same batch", () => {
const enqueued: Omit<Event, "id" | "timestamp">[] = [];
const enqueue: EnqueueFn = (event) => {
enqueued.push(event);
return { ...event, id: enqueued.length, timestamp: new Date() } as Event;
};
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const jobs: CronJob[] = [
{ name: "bad-job", expression: "invalid", instruction: "Bad" },
{ name: "good-job", expression: "0 9 * * *", instruction: "Good" },
];
scheduler.start(jobs, enqueue);
expect(warnSpy).toHaveBeenCalledTimes(1);
// Only the valid job should have been scheduled — fire all
mockCron._fireAll();
expect(enqueued).toHaveLength(1);
expect(enqueued[0].payload).toEqual({ instruction: "Good", jobName: "good-job" });
warnSpy.mockRestore();
});
});
describe("stop", () => {
it("stops all scheduled cron tasks", () => {
const enqueued: Omit<Event, "id" | "timestamp">[] = [];
const enqueue: EnqueueFn = (event) => {
enqueued.push(event);
return { ...event, id: enqueued.length, timestamp: new Date() } as Event;
};
const jobs: CronJob[] = [
{ name: "job-a", expression: "0 9 * * *", instruction: "Do A" },
{ name: "job-b", expression: "0 15 * * 1", instruction: "Do B" },
];
scheduler.start(jobs, enqueue);
// Fire once before stopping
mockCron._fireAll();
expect(enqueued).toHaveLength(2);
scheduler.stop();
// After stop, firing should not enqueue (tasks are stopped)
mockCron._fireAll();
expect(enqueued).toHaveLength(2);
});
it("is safe to call stop when no tasks are scheduled", () => {
expect(() => scheduler.stop()).not.toThrow();
});
});
});

View File

@@ -0,0 +1,65 @@
import { describe, it, expect } from "vitest";
import { formatErrorForUser } from "../../src/error-formatter.js";
describe("formatErrorForUser", () => {
it("includes the error name for standard Error instances", () => {
const err = new TypeError("something went wrong");
const result = formatErrorForUser(err);
expect(result).toContain("TypeError");
expect(result).toContain("something went wrong");
});
it("includes custom error names", () => {
const err = new Error("bad auth");
err.name = "AuthenticationError";
const result = formatErrorForUser(err);
expect(result).toContain("AuthenticationError");
expect(result).toContain("bad auth");
});
it("excludes stack traces from error messages", () => {
const err = new Error("fail\n at Object.<anonymous> (/home/user/app.js:10:5)\n at Module._compile (node:internal/modules/cjs/loader:1234:14)");
const result = formatErrorForUser(err);
expect(result).not.toMatch(/\bat\s+/);
expect(result).not.toContain("app.js");
});
it("excludes API keys (sk-... pattern)", () => {
const err = new Error("Auth failed with key sk-ant-api03-abcdefghijklmnop");
const result = formatErrorForUser(err);
expect(result).not.toContain("sk-ant-api03");
expect(result).toContain("[REDACTED]");
});
it("excludes Unix file paths", () => {
const err = new Error("File not found: /home/user/projects/app/config.ts");
const result = formatErrorForUser(err);
expect(result).not.toContain("/home/user");
expect(result).toContain("[REDACTED_PATH]");
});
it("excludes Windows file paths", () => {
const err = new Error("Cannot read C:\\Users\\admin\\secrets.txt");
const result = formatErrorForUser(err);
expect(result).not.toContain("C:\\Users");
expect(result).toContain("[REDACTED_PATH]");
});
it("handles string errors", () => {
const result = formatErrorForUser("something broke");
expect(result).toBe("Error: something broke");
});
it("handles non-Error, non-string values", () => {
expect(formatErrorForUser(42)).toBe("An unknown error occurred");
expect(formatErrorForUser(null)).toBe("An unknown error occurred");
expect(formatErrorForUser(undefined)).toBe("An unknown error occurred");
});
it("handles Error with empty message", () => {
const err = new Error("");
err.name = "RangeError";
const result = formatErrorForUser(err);
expect(result).toContain("RangeError");
});
});

View File

@@ -0,0 +1,150 @@
import { describe, it, expect } from "vitest";
import { EventQueue, type Event, type EventType } from "../../src/event-queue.js";
function makeEvent(type: EventType = "message", source = "test"): Omit<Event, "id" | "timestamp"> {
if (type === "message") {
return { type, payload: { prompt: { text: "hello", channelId: "ch1", userId: "u1", guildId: null } }, source };
}
if (type === "heartbeat") {
return { type, payload: { instruction: "check email", checkName: "email-check" }, source };
}
if (type === "cron") {
return { type, payload: { instruction: "run report", jobName: "daily-report" }, source };
}
return { type, payload: { hookType: "startup" as const }, source };
}
describe("EventQueue", () => {
it("enqueue assigns monotonically increasing IDs", () => {
const q = new EventQueue(10);
const e1 = q.enqueue(makeEvent());
const e2 = q.enqueue(makeEvent());
const e3 = q.enqueue(makeEvent());
expect(e1).not.toBeNull();
expect(e2).not.toBeNull();
expect(e3).not.toBeNull();
expect(e1!.id).toBe(1);
expect(e2!.id).toBe(2);
expect(e3!.id).toBe(3);
});
it("enqueue assigns timestamps", () => {
const q = new EventQueue(10);
const e = q.enqueue(makeEvent());
expect(e).not.toBeNull();
expect(e!.timestamp).toBeInstanceOf(Date);
});
it("returns null when queue is at max depth", () => {
const q = new EventQueue(2);
expect(q.enqueue(makeEvent())).not.toBeNull();
expect(q.enqueue(makeEvent())).not.toBeNull();
expect(q.enqueue(makeEvent())).toBeNull();
expect(q.size()).toBe(2);
});
it("dequeue returns events in FIFO order", () => {
const q = new EventQueue(10);
q.enqueue(makeEvent("message"));
q.enqueue(makeEvent("heartbeat"));
q.enqueue(makeEvent("cron"));
expect(q.dequeue()!.type).toBe("message");
expect(q.dequeue()!.type).toBe("heartbeat");
expect(q.dequeue()!.type).toBe("cron");
});
it("dequeue returns undefined when empty", () => {
const q = new EventQueue(10);
expect(q.dequeue()).toBeUndefined();
});
it("size returns current queue depth", () => {
const q = new EventQueue(10);
expect(q.size()).toBe(0);
q.enqueue(makeEvent());
expect(q.size()).toBe(1);
q.enqueue(makeEvent());
expect(q.size()).toBe(2);
q.dequeue();
expect(q.size()).toBe(1);
});
it("onEvent handler processes events sequentially", async () => {
const q = new EventQueue(10);
const processed: number[] = [];
q.enqueue(makeEvent());
q.enqueue(makeEvent());
q.enqueue(makeEvent());
q.onEvent(async (event) => {
processed.push(event.id);
await new Promise((r) => setTimeout(r, 10));
});
await q.drain();
expect(processed).toEqual([1, 2, 3]);
});
it("onEvent auto-processes newly enqueued events", async () => {
const q = new EventQueue(10);
const processed: number[] = [];
q.onEvent(async (event) => {
processed.push(event.id);
});
q.enqueue(makeEvent());
q.enqueue(makeEvent());
await q.drain();
expect(processed).toEqual([1, 2]);
});
it("drain resolves immediately when queue is empty and not processing", async () => {
const q = new EventQueue(10);
await q.drain(); // should not hang
});
it("drain waits for in-flight processing to complete", async () => {
const q = new EventQueue(10);
let handlerDone = false;
q.onEvent(async () => {
await new Promise((r) => setTimeout(r, 50));
handlerDone = true;
});
q.enqueue(makeEvent());
await q.drain();
expect(handlerDone).toBe(true);
});
it("handler errors do not block subsequent processing", async () => {
const q = new EventQueue(10);
const processed: number[] = [];
q.onEvent(async (event) => {
if (event.id === 1) throw new Error("fail");
processed.push(event.id);
});
q.enqueue(makeEvent());
q.enqueue(makeEvent());
await q.drain();
expect(processed).toEqual([2]);
});
it("accepts all event types", () => {
const q = new EventQueue(10);
const types: EventType[] = ["message", "heartbeat", "cron", "hook", "webhook"];
for (const type of types) {
const e = q.enqueue(makeEvent(type));
expect(e).not.toBeNull();
expect(e!.type).toBe(type);
}
});
});

View File

@@ -0,0 +1,176 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { HeartbeatScheduler, type HeartbeatCheck } from "../../src/heartbeat-scheduler.js";
import type { Event } from "../../src/event-queue.js";
type EnqueueFn = (event: Omit<Event, "id" | "timestamp">) => Event | null;
describe("HeartbeatScheduler", () => {
let scheduler: HeartbeatScheduler;
beforeEach(() => {
vi.useFakeTimers();
scheduler = new HeartbeatScheduler();
});
afterEach(() => {
scheduler.stop();
vi.useRealTimers();
});
describe("parseConfig", () => {
it("parses a single check definition", () => {
const content = `## check-email
Interval: 300
Instruction: Check my inbox for urgent items`;
const checks = scheduler.parseConfig(content);
expect(checks).toEqual([
{ name: "check-email", instruction: "Check my inbox for urgent items", intervalSeconds: 300 },
]);
});
it("parses multiple check definitions", () => {
const content = `## check-email
Interval: 300
Instruction: Check my inbox for urgent items
## check-calendar
Interval: 600
Instruction: Review upcoming calendar events`;
const checks = scheduler.parseConfig(content);
expect(checks).toHaveLength(2);
expect(checks[0]).toEqual({ name: "check-email", instruction: "Check my inbox for urgent items", intervalSeconds: 300 });
expect(checks[1]).toEqual({ name: "check-calendar", instruction: "Review upcoming calendar events", intervalSeconds: 600 });
});
it("returns empty array for empty content", () => {
expect(scheduler.parseConfig("")).toEqual([]);
});
it("skips incomplete check definitions (missing instruction)", () => {
const content = `## incomplete-check
Interval: 300`;
expect(scheduler.parseConfig(content)).toEqual([]);
});
it("skips incomplete check definitions (missing interval)", () => {
const content = `## incomplete-check
Instruction: Do something`;
expect(scheduler.parseConfig(content)).toEqual([]);
});
});
describe("start", () => {
it("starts timers for valid checks and enqueues heartbeat events", () => {
const enqueued: Omit<Event, "id" | "timestamp">[] = [];
const enqueue: EnqueueFn = (event) => {
enqueued.push(event);
return { ...event, id: enqueued.length, timestamp: new Date() } as Event;
};
const checks: HeartbeatCheck[] = [
{ name: "check-email", instruction: "Check inbox", intervalSeconds: 120 },
];
scheduler.start(checks, enqueue);
// No events yet — timer hasn't fired
expect(enqueued).toHaveLength(0);
// Advance time by 120 seconds
vi.advanceTimersByTime(120_000);
expect(enqueued).toHaveLength(1);
expect(enqueued[0]).toEqual({
type: "heartbeat",
payload: { instruction: "Check inbox", checkName: "check-email" },
source: "heartbeat-scheduler",
});
// Advance another 120 seconds
vi.advanceTimersByTime(120_000);
expect(enqueued).toHaveLength(2);
});
it("rejects checks with interval < 60 seconds with a warning", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const enqueue: EnqueueFn = () => null;
const checks: HeartbeatCheck[] = [
{ name: "too-fast", instruction: "Do something", intervalSeconds: 30 },
];
scheduler.start(checks, enqueue);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("too-fast")
);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("below the minimum")
);
// Advance time — no events should be enqueued
vi.advanceTimersByTime(60_000);
// No way to check enqueue wasn't called since it returns null, but the warn confirms rejection
warnSpy.mockRestore();
});
it("starts valid checks and skips invalid ones in the same batch", () => {
const enqueued: Omit<Event, "id" | "timestamp">[] = [];
const enqueue: EnqueueFn = (event) => {
enqueued.push(event);
return { ...event, id: enqueued.length, timestamp: new Date() } as Event;
};
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const checks: HeartbeatCheck[] = [
{ name: "too-fast", instruction: "Bad check", intervalSeconds: 10 },
{ name: "valid-check", instruction: "Good check", intervalSeconds: 60 },
];
scheduler.start(checks, enqueue);
expect(warnSpy).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(60_000);
expect(enqueued).toHaveLength(1);
expect(enqueued[0].payload).toEqual({ instruction: "Good check", checkName: "valid-check" });
warnSpy.mockRestore();
});
});
describe("stop", () => {
it("clears all timers so no more events are enqueued", () => {
const enqueued: Omit<Event, "id" | "timestamp">[] = [];
const enqueue: EnqueueFn = (event) => {
enqueued.push(event);
return { ...event, id: enqueued.length, timestamp: new Date() } as Event;
};
const checks: HeartbeatCheck[] = [
{ name: "check-a", instruction: "Do A", intervalSeconds: 60 },
{ name: "check-b", instruction: "Do B", intervalSeconds: 120 },
];
scheduler.start(checks, enqueue);
// Fire one tick for check-a
vi.advanceTimersByTime(60_000);
expect(enqueued).toHaveLength(1);
scheduler.stop();
// Advance more time — no new events
vi.advanceTimersByTime(300_000);
expect(enqueued).toHaveLength(1);
});
it("is safe to call stop when no timers are running", () => {
expect(() => scheduler.stop()).not.toThrow();
});
});
});

View File

@@ -0,0 +1,144 @@
import { describe, it, expect } from "vitest";
import { splitMessage } from "../../src/response-formatter.js";
describe("splitMessage", () => {
it("should return empty array for empty text", () => {
expect(splitMessage("")).toEqual([]);
});
it("should return single chunk for text shorter than maxLength", () => {
const text = "Hello, world!";
expect(splitMessage(text)).toEqual([text]);
});
it("should return single chunk for text exactly at maxLength", () => {
const text = "a".repeat(2000);
expect(splitMessage(text)).toEqual([text]);
});
it("should split text exceeding maxLength into multiple chunks", () => {
const text = "a".repeat(3000);
const chunks = splitMessage(text, 2000);
expect(chunks.length).toBeGreaterThan(1);
for (const chunk of chunks) {
expect(chunk.length).toBeLessThanOrEqual(2000);
}
});
it("should prefer splitting at line boundaries", () => {
const line = "x".repeat(80) + "\n";
// 25 lines of 81 chars each = 2025 chars total
const text = line.repeat(25);
const chunks = splitMessage(text, 2000);
// Each chunk should end at a newline boundary
for (const chunk of chunks.slice(0, -1)) {
expect(chunk.endsWith("\n") || chunk.endsWith("```")).toBe(true);
}
});
it("should handle custom maxLength", () => {
const text = "Hello\nWorld\nFoo\nBar";
const chunks = splitMessage(text, 10);
for (const chunk of chunks) {
expect(chunk.length).toBeLessThanOrEqual(10);
}
});
it("should preserve content when splitting plain text", () => {
const lines = Array.from({ length: 50 }, (_, i) => `Line ${i + 1}`);
const text = lines.join("\n");
const chunks = splitMessage(text, 100);
const reassembled = chunks.join("");
expect(reassembled).toBe(text);
});
it("should close and reopen code blocks across splits", () => {
const codeContent = "x\n".repeat(1500);
const text = "Before\n```typescript\n" + codeContent + "```\nAfter";
const chunks = splitMessage(text, 2000);
expect(chunks.length).toBeGreaterThan(1);
// First chunk should contain the opening fence
expect(chunks[0]).toContain("```typescript");
// First chunk should end with a closing fence since it splits mid-block
expect(chunks[0].trimEnd().endsWith("```")).toBe(true);
// Second chunk should reopen the code block
expect(chunks[1].startsWith("```typescript")).toBe(true);
});
it("should handle multiple code blocks in the same text", () => {
const block1 = "```js\nconsole.log('hello');\n```";
const block2 = "```python\nprint('world')\n```";
const text = block1 + "\n\nSome text\n\n" + block2;
const chunks = splitMessage(text, 2000);
// Should fit in one chunk
expect(chunks).toEqual([text]);
});
it("should handle code block that fits entirely in one chunk", () => {
const text = "Hello\n```js\nconst x = 1;\n```\nGoodbye";
const chunks = splitMessage(text, 2000);
expect(chunks).toEqual([text]);
});
it("should handle text with no newlines", () => {
const text = "a".repeat(5000);
const chunks = splitMessage(text, 2000);
expect(chunks.length).toBeGreaterThanOrEqual(3);
for (const chunk of chunks) {
expect(chunk.length).toBeLessThanOrEqual(2000);
}
expect(chunks.join("")).toBe(text);
});
it("should ensure every chunk is within maxLength", () => {
const codeContent = "x\n".repeat(2000);
const text = "```\n" + codeContent + "```";
const chunks = splitMessage(text, 2000);
for (const chunk of chunks) {
expect(chunk.length).toBeLessThanOrEqual(2000);
}
});
it("should handle code block with language tag across splits", () => {
const longCode = ("const x = 1;\n").repeat(200);
const text = "```typescript\n" + longCode + "```";
const chunks = splitMessage(text, 500);
// All continuation chunks should reopen with the language tag
for (let i = 1; i < chunks.length; i++) {
if (chunks[i].startsWith("```")) {
expect(chunks[i].startsWith("```typescript")).toBe(true);
}
}
});
it("should produce chunks that reconstruct original text modulo fence delimiters", () => {
const longCode = ("line\n").repeat(300);
const text = "Start\n```\n" + longCode + "```\nEnd";
const chunks = splitMessage(text, 500);
// Remove inserted fence closers/openers and rejoin
const cleaned = chunks.map((chunk, i) => {
let c = chunk;
// Remove reopened fence at start (for continuation chunks)
if (i > 0 && c.startsWith("```")) {
const newlineIdx = c.indexOf("\n");
if (newlineIdx !== -1) {
c = c.slice(newlineIdx + 1);
}
}
// Remove inserted closing fence at end (for non-last chunks that close a block)
if (i < chunks.length - 1 && c.trimEnd().endsWith("```")) {
const lastFence = c.lastIndexOf("\n```");
if (lastFence !== -1) {
c = c.slice(0, lastFence);
}
}
return c;
});
expect(cleaned.join("")).toBe(text);
});
});

View File

@@ -0,0 +1,52 @@
import { describe, it, expect, beforeEach } from "vitest";
import { SessionManager } from "../../src/session-manager.js";
describe("SessionManager", () => {
let manager: SessionManager;
beforeEach(() => {
manager = new SessionManager();
});
it("returns undefined for unknown channel", () => {
expect(manager.getSessionId("unknown")).toBeUndefined();
});
it("stores and retrieves a session", () => {
manager.setSessionId("ch-1", "sess-abc");
expect(manager.getSessionId("ch-1")).toBe("sess-abc");
});
it("overwrites an existing session", () => {
manager.setSessionId("ch-1", "sess-old");
manager.setSessionId("ch-1", "sess-new");
expect(manager.getSessionId("ch-1")).toBe("sess-new");
});
it("removes a session", () => {
manager.setSessionId("ch-1", "sess-abc");
manager.removeSession("ch-1");
expect(manager.getSessionId("ch-1")).toBeUndefined();
});
it("removeSession on unknown channel is a no-op", () => {
expect(() => manager.removeSession("nope")).not.toThrow();
});
it("clear removes all sessions", () => {
manager.setSessionId("ch-1", "s1");
manager.setSessionId("ch-2", "s2");
manager.clear();
expect(manager.getSessionId("ch-1")).toBeUndefined();
expect(manager.getSessionId("ch-2")).toBeUndefined();
});
it("isolates sessions across channels", () => {
manager.setSessionId("ch-1", "s1");
manager.setSessionId("ch-2", "s2");
expect(manager.getSessionId("ch-1")).toBe("s1");
expect(manager.getSessionId("ch-2")).toBe("s2");
manager.removeSession("ch-1");
expect(manager.getSessionId("ch-2")).toBe("s2");
});
});

View File

@@ -0,0 +1,57 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { registerShutdownHandler } from "../../src/shutdown-handler.js";
describe("registerShutdownHandler", () => {
let mockGateway: { shutdown: ReturnType<typeof vi.fn> };
let sigintListeners: Array<() => void>;
let sigtermListeners: Array<() => void>;
beforeEach(() => {
mockGateway = { shutdown: vi.fn() };
sigintListeners = [];
sigtermListeners = [];
vi.spyOn(process, "on").mockImplementation((event: string, listener: (...args: unknown[]) => void) => {
if (event === "SIGINT") sigintListeners.push(listener as () => void);
if (event === "SIGTERM") sigtermListeners.push(listener as () => void);
return process;
});
vi.spyOn(console, "log").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("registers listeners for SIGTERM and SIGINT", () => {
registerShutdownHandler(mockGateway as never);
expect(sigtermListeners).toHaveLength(1);
expect(sigintListeners).toHaveLength(1);
});
it("calls gateway.shutdown() on SIGTERM", () => {
registerShutdownHandler(mockGateway as never);
sigtermListeners[0]();
expect(mockGateway.shutdown).toHaveBeenCalledOnce();
});
it("calls gateway.shutdown() on SIGINT", () => {
registerShutdownHandler(mockGateway as never);
sigintListeners[0]();
expect(mockGateway.shutdown).toHaveBeenCalledOnce();
});
it("prevents double-shutdown on repeated signals", () => {
registerShutdownHandler(mockGateway as never);
sigtermListeners[0]();
sigintListeners[0]();
sigtermListeners[0]();
expect(mockGateway.shutdown).toHaveBeenCalledOnce();
});
it("logs the signal name", () => {
registerShutdownHandler(mockGateway as never);
sigtermListeners[0]();
expect(console.log).toHaveBeenCalledWith("Received SIGTERM, shutting down...");
});
});