Files
lettabot/src/cron/heartbeat.test.ts
Ani Tunturi 18010eb14f feat: Matrix adapter with E2EE, TTS/STT, reactions, and heartbeat routing
Full Matrix channel integration for LettaBot:

- E2EE via rust crypto (ephemeral mode, cross-signing bootstrap)
- Proactive SAS verification with Element clients
- TTS (VibeVoice) and STT (Faster-Whisper) voice pipeline
- Streaming message edits with 800ms throttle
- Collapsible reasoning blocks via <details> htmlPrefix
- Per-tool emoji reactions (brain, eyes, tool-specific, max 6)
- Heartbeat room conversation routing (heartbeatTargetChatId)
- Custom heartbeat prompt with first-person voice
- Per-room conversation isolation (per-chat mode)
- !pause, !resume, !status, !new, !timeout, !turns commands
- Audio/image/file upload handlers with E2EE media
- SDK 0.1.11 (approval recovery), CLI 0.18.2

Tested against Synapse homeserver with E2EE enabled for 2+ weeks,
handles key backup/restore and device verification.
2026-03-14 21:27:32 -04:00

455 lines
15 KiB
TypeScript

import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { writeFileSync, mkdirSync, unlinkSync, rmSync, readFileSync, existsSync } from 'node:fs';
import { resolve } from 'node:path';
import { tmpdir, homedir } from 'node:os';
import { execSync } from 'node:child_process';
import { HeartbeatService, type HeartbeatConfig } from './heartbeat.js';
import { buildCustomHeartbeatPrompt, SILENT_MODE_PREFIX } from '../core/prompts.js';
import type { AgentSession } from '../core/interfaces.js';
import { addTodo } from '../todo/store.js';
import { getCronLogPath } from '../utils/paths.js';
const HEARTBEAT_LOG_PATH = getCronLogPath();
// ── buildCustomHeartbeatPrompt ──────────────────────────────────────────
describe('buildCustomHeartbeatPrompt', () => {
it('includes silent mode prefix', () => {
const result = buildCustomHeartbeatPrompt('Do something', '12:00 PM', 'UTC', 60);
expect(result).toContain(SILENT_MODE_PREFIX);
});
it('includes time and interval metadata', () => {
const result = buildCustomHeartbeatPrompt('Do something', '3:30 PM', 'America/Los_Angeles', 45);
expect(result).toContain('TIME: 3:30 PM (America/Los_Angeles)');
expect(result).toContain('NEXT HEARTBEAT: in 45 minutes');
});
it('includes custom prompt text in body', () => {
const result = buildCustomHeartbeatPrompt('Check your todo list.', '12:00 PM', 'UTC', 60);
expect(result).toContain('Check your todo list.');
});
it('includes lettabot-message instructions', () => {
const result = buildCustomHeartbeatPrompt('Custom task', '12:00 PM', 'UTC', 60);
expect(result).toContain('lettabot-message send --text');
});
it('does NOT include default body text', () => {
const result = buildCustomHeartbeatPrompt('Custom task', '12:00 PM', 'UTC', 60);
expect(result).not.toContain('This is your time');
expect(result).not.toContain('Pursue curiosities');
});
});
// ── HeartbeatService prompt resolution ──────────────────────────────────
function createMockBot(): AgentSession {
return {
registerChannel: vi.fn(),
setGroupBatcher: vi.fn(),
processGroupBatch: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
sendToAgent: vi.fn().mockResolvedValue('ok'),
streamToAgent: vi.fn().mockReturnValue((async function* () { yield { type: 'result', success: true }; })()),
deliverToChannel: vi.fn(),
getStatus: vi.fn().mockReturnValue({ agentId: 'test', conversationId: null, channels: [] }),
setAgentId: vi.fn(),
reset: vi.fn(),
getLastMessageTarget: vi.fn().mockReturnValue(null),
getLastUserMessageTime: vi.fn().mockReturnValue(null),
invalidateSession: vi.fn(),
};
}
function createConfig(overrides: Partial<HeartbeatConfig> = {}): HeartbeatConfig {
return {
enabled: true,
intervalMinutes: 30,
workingDir: tmpdir(),
agentKey: 'test-agent',
...overrides,
};
}
describe('HeartbeatService prompt resolution', () => {
let tmpDir: string;
let originalDataDir: string | undefined;
beforeEach(() => {
tmpDir = resolve(tmpdir(), `heartbeat-test-${Date.now()}`);
mkdirSync(tmpDir, { recursive: true });
originalDataDir = process.env.DATA_DIR;
process.env.DATA_DIR = tmpDir;
});
afterEach(() => {
if (originalDataDir === undefined) {
delete process.env.DATA_DIR;
} else {
process.env.DATA_DIR = originalDataDir;
}
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
});
it('uses default prompt when no custom prompt is set', async () => {
const bot = createMockBot();
const service = new HeartbeatService(bot, createConfig({ workingDir: tmpDir }));
await service.trigger();
const sentMessage = (bot.sendToAgent as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
expect(sentMessage).toContain('This is your time');
expect(sentMessage).toContain(SILENT_MODE_PREFIX);
});
it('uses inline prompt when set', async () => {
const bot = createMockBot();
const service = new HeartbeatService(bot, createConfig({
workingDir: tmpDir,
prompt: 'Check your todo list and work on the top item.',
}));
await service.trigger();
const sentMessage = (bot.sendToAgent as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
expect(sentMessage).toContain('Check your todo list and work on the top item.');
expect(sentMessage).not.toContain('This is your time');
expect(sentMessage).toContain(SILENT_MODE_PREFIX);
});
it('uses promptFile when no inline prompt is set', async () => {
const promptPath = resolve(tmpDir, 'heartbeat-prompt.txt');
writeFileSync(promptPath, 'Research quantum computing papers.');
const bot = createMockBot();
const service = new HeartbeatService(bot, createConfig({
workingDir: tmpDir,
promptFile: 'heartbeat-prompt.txt',
}));
await service.trigger();
const sentMessage = (bot.sendToAgent as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
expect(sentMessage).toContain('Research quantum computing papers.');
expect(sentMessage).not.toContain('This is your time');
});
it('inline prompt takes precedence over promptFile', async () => {
const promptPath = resolve(tmpDir, 'heartbeat-prompt.txt');
writeFileSync(promptPath, 'FROM FILE');
const bot = createMockBot();
const service = new HeartbeatService(bot, createConfig({
workingDir: tmpDir,
prompt: 'FROM INLINE',
promptFile: 'heartbeat-prompt.txt',
}));
await service.trigger();
const sentMessage = (bot.sendToAgent as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
expect(sentMessage).toContain('FROM INLINE');
expect(sentMessage).not.toContain('FROM FILE');
});
it('re-reads promptFile on each tick (live reload)', async () => {
const promptPath = resolve(tmpDir, 'heartbeat-prompt.txt');
writeFileSync(promptPath, 'Version 1');
const bot = createMockBot();
const service = new HeartbeatService(bot, createConfig({
workingDir: tmpDir,
promptFile: 'heartbeat-prompt.txt',
}));
// First tick
await service.trigger();
const firstMessage = (bot.sendToAgent as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
expect(firstMessage).toContain('Version 1');
// Update file
writeFileSync(promptPath, 'Version 2');
// Second tick
await service.trigger();
const secondMessage = (bot.sendToAgent as ReturnType<typeof vi.fn>).mock.calls[1][0] as string;
expect(secondMessage).toContain('Version 2');
expect(secondMessage).not.toContain('Version 1');
});
it('falls back to default when promptFile does not exist', async () => {
const bot = createMockBot();
const service = new HeartbeatService(bot, createConfig({
workingDir: tmpDir,
promptFile: 'nonexistent.txt',
}));
await service.trigger();
const sentMessage = (bot.sendToAgent as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
// Should fall back to default since file doesn't exist
expect(sentMessage).toContain('This is your time');
});
it('falls back to default when promptFile is empty', async () => {
const promptPath = resolve(tmpDir, 'empty.txt');
writeFileSync(promptPath, ' \n ');
const bot = createMockBot();
const service = new HeartbeatService(bot, createConfig({
workingDir: tmpDir,
promptFile: 'empty.txt',
}));
await service.trigger();
const sentMessage = (bot.sendToAgent as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
// Empty/whitespace file should fall back to default
expect(sentMessage).toContain('This is your time');
});
it('injects actionable todos into default heartbeat prompt', async () => {
addTodo('test', {
text: 'Deliver morning report',
due: '2026-02-13T08:00:00.000Z',
recurring: 'daily 8am',
});
const bot = createMockBot();
const service = new HeartbeatService(bot, createConfig({ workingDir: tmpDir }));
await service.trigger();
const sentMessage = (bot.sendToAgent as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
expect(sentMessage).toContain('PENDING TO-DOS:');
expect(sentMessage).toContain('Deliver morning report');
expect(sentMessage).toContain('recurring: daily 8am');
expect(sentMessage).toContain('manage_todo');
});
it('does not include snoozed todos that are not actionable yet', async () => {
addTodo('test', {
text: 'Follow up after trip',
snoozed_until: '2099-01-01T00:00:00.000Z',
});
const bot = createMockBot();
const service = new HeartbeatService(bot, createConfig({ workingDir: tmpDir }));
await service.trigger();
const sentMessage = (bot.sendToAgent as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
expect(sentMessage).not.toContain('Follow up after trip');
});
it('skips automatic heartbeat when user messaged within skip window', async () => {
const bot = createMockBot();
(bot.getLastUserMessageTime as ReturnType<typeof vi.fn>).mockReturnValue(
new Date(Date.now() - 2 * 60 * 1000),
);
const service = new HeartbeatService(bot, createConfig({
workingDir: tmpDir,
skipRecentPolicy: 'fixed',
skipRecentUserMinutes: 5,
}));
await (service as any).runHeartbeat(false);
expect(bot.sendToAgent).not.toHaveBeenCalled();
});
it('does not skip automatic heartbeat when skipRecentUserMinutes is 0', async () => {
const bot = createMockBot();
(bot.getLastUserMessageTime as ReturnType<typeof vi.fn>).mockReturnValue(
new Date(Date.now() - 1 * 60 * 1000),
);
const service = new HeartbeatService(bot, createConfig({
workingDir: tmpDir,
skipRecentPolicy: 'fixed',
skipRecentUserMinutes: 0,
}));
await (service as any).runHeartbeat(false);
expect(bot.sendToAgent).toHaveBeenCalledTimes(1);
});
it('defaults to fraction policy (0.5 of interval) when no fixed window is configured', async () => {
const bot = createMockBot();
(bot.getLastUserMessageTime as ReturnType<typeof vi.fn>).mockReturnValue(
new Date(Date.now() - 10 * 60 * 1000),
);
const service = new HeartbeatService(bot, createConfig({
workingDir: tmpDir,
intervalMinutes: 30,
}));
await (service as any).runHeartbeat(false);
// 30m * 0.5 => 15m skip window; 10m ago should skip.
expect(bot.sendToAgent).not.toHaveBeenCalled();
});
it('does not skip when skipRecentPolicy is off', async () => {
const bot = createMockBot();
(bot.getLastUserMessageTime as ReturnType<typeof vi.fn>).mockReturnValue(
new Date(Date.now() - 1 * 60 * 1000),
);
const service = new HeartbeatService(bot, createConfig({
workingDir: tmpDir,
skipRecentPolicy: 'off',
skipRecentUserMinutes: 60,
}));
await (service as any).runHeartbeat(false);
expect(bot.sendToAgent).toHaveBeenCalledTimes(1);
});
});
// ── Memfs health check ─────────────────────────────────────────────────
describe('HeartbeatService memfs health check', () => {
let tmpDir: string;
let memDir: string | undefined;
let originalDataDir: string | undefined;
let originalHome: string | undefined;
let testHome: string;
beforeEach(() => {
tmpDir = resolve(tmpdir(), `heartbeat-memfs-test-${Date.now()}`);
testHome = resolve(tmpDir, 'fake-home');
mkdirSync(tmpDir, { recursive: true });
mkdirSync(testHome, { recursive: true });
originalDataDir = process.env.DATA_DIR;
originalHome = process.env.HOME;
process.env.DATA_DIR = tmpDir;
process.env.HOME = testHome;
});
afterEach(() => {
if (originalDataDir === undefined) {
delete process.env.DATA_DIR;
} else {
process.env.DATA_DIR = originalDataDir;
}
if (originalHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
memDir = undefined;
});
it('emits heartbeat_memfs_dirty when memfs directory has untracked files', async () => {
// Set up a real git repo to act as the memory directory
const agentId = 'agent-memfs-test-' + Date.now();
memDir = resolve(homedir(), '.letta', 'agents', agentId, 'memory');
mkdirSync(memDir, { recursive: true });
execSync('git init', { cwd: memDir, stdio: 'ignore' });
// Create an untracked file
writeFileSync(resolve(memDir, 'untracked.md'), 'test');
const bot = createMockBot();
(bot.getStatus as ReturnType<typeof vi.fn>).mockReturnValue({
agentId,
conversationId: null,
channels: [],
});
const service = new HeartbeatService(bot, createConfig({
workingDir: tmpDir,
memfs: true,
}));
// Access private method for direct testing
const checkMemfsHealth = (service as any).checkMemfsHealth.bind(service);
expect(() => checkMemfsHealth()).not.toThrow();
await new Promise((resolvePromise) => setTimeout(resolvePromise, 10));
const logContents = existsSync(HEARTBEAT_LOG_PATH)
? readFileSync(HEARTBEAT_LOG_PATH, 'utf-8')
: '';
expect(logContents).toContain('heartbeat_memfs_dirty');
expect(logContents).toContain(agentId);
});
it('skips memfs check when memfs is disabled', async () => {
const bot = createMockBot();
const service = new HeartbeatService(bot, createConfig({
workingDir: tmpDir,
memfs: false,
}));
const getMemoryDir = (service as any).getMemoryDir.bind(service);
expect(getMemoryDir()).toBeNull();
});
it('skips memfs check when agent ID is not available', async () => {
const bot = createMockBot();
(bot.getStatus as ReturnType<typeof vi.fn>).mockReturnValue({
agentId: null,
conversationId: null,
channels: [],
});
const service = new HeartbeatService(bot, createConfig({
workingDir: tmpDir,
memfs: true,
}));
const getMemoryDir = (service as any).getMemoryDir.bind(service);
expect(getMemoryDir()).toBeNull();
});
it('resolves memory directory correctly when memfs is enabled', () => {
const bot = createMockBot();
(bot.getStatus as ReturnType<typeof vi.fn>).mockReturnValue({
agentId: 'agent-abc123',
conversationId: null,
channels: [],
});
const service = new HeartbeatService(bot, createConfig({
workingDir: tmpDir,
memfs: true,
}));
const getMemoryDir = (service as any).getMemoryDir.bind(service);
expect(getMemoryDir()).toBe(resolve(homedir(), '.letta', 'agents', 'agent-abc123', 'memory'));
});
it('still calls sendToAgent even when memfs check finds dirty files', async () => {
const agentId = 'agent-memfs-dirty-' + Date.now();
memDir = resolve(homedir(), '.letta', 'agents', agentId, 'memory');
mkdirSync(memDir, { recursive: true });
execSync('git init', { cwd: memDir, stdio: 'ignore' });
writeFileSync(resolve(memDir, 'dirty.md'), 'uncommitted content');
const bot = createMockBot();
(bot.getStatus as ReturnType<typeof vi.fn>).mockReturnValue({
agentId,
conversationId: null,
channels: [],
});
const service = new HeartbeatService(bot, createConfig({
workingDir: tmpDir,
memfs: true,
}));
await service.trigger();
// sendToAgent should still be called (memfs check is non-blocking)
expect(bot.sendToAgent).toHaveBeenCalledTimes(1);
});
});