feat(comms): P1 presence — minimal Synapse + fleet presence room + mosaic.presence heartbeat + liveness (#888)
Co-authored-by: jason.woltje <[email protected]> Co-committed-by: jason.woltje <[email protected]>
This commit was merged in pull request #888.
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { HeartbeatEmitter, startHeartbeatLoop } from '../heartbeat.js';
|
||||
import type { PresenceHeartbeatContent } from '../types.js';
|
||||
|
||||
const agent = { mxid: '@agent-alpha:matrix.localhost', slug: 'alpha', harness: 'claude-code' };
|
||||
|
||||
describe('HeartbeatEmitter', () => {
|
||||
it('increments seq starting at 1 and stamps the envelope', () => {
|
||||
let t = 1000;
|
||||
const em = new HeartbeatEmitter({ agent, intervalMs: 5000, now: () => t });
|
||||
const a = em.next();
|
||||
t = 6000;
|
||||
const b = em.next('away');
|
||||
|
||||
expect(a.seq).toBe(1);
|
||||
expect(a.ts).toBe(1000);
|
||||
expect(a.status).toBe('online');
|
||||
expect(a.macp_type).toBe('presence');
|
||||
expect(a.msgtype).toBe('mosaic.presence');
|
||||
expect(a.macp_version).toBe('1.0');
|
||||
expect(a.interval_ms).toBe(5000);
|
||||
expect(a.agent).toEqual(agent);
|
||||
expect(a.body).toContain('alpha');
|
||||
|
||||
expect(b.seq).toBe(2);
|
||||
expect(b.ts).toBe(6000);
|
||||
expect(b.status).toBe('away');
|
||||
expect(em.currentSeq).toBe(2);
|
||||
});
|
||||
|
||||
it('includes mission_id only when provided', () => {
|
||||
const withMission = new HeartbeatEmitter({
|
||||
agent,
|
||||
intervalMs: 1000,
|
||||
missionId: 'KBN-101',
|
||||
}).next();
|
||||
const without = new HeartbeatEmitter({ agent, intervalMs: 1000 }).next();
|
||||
expect(withMission.mission_id).toBe('KBN-101');
|
||||
expect(without.mission_id).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('startHeartbeatLoop', () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('emits immediately, then once per interval, until stopped', () => {
|
||||
vi.useFakeTimers();
|
||||
const sent: PresenceHeartbeatContent[] = [];
|
||||
const em = new HeartbeatEmitter({ agent, intervalMs: 1000, now: () => Date.now() });
|
||||
const loop = startHeartbeatLoop({
|
||||
emitter: em,
|
||||
intervalMs: 1000,
|
||||
send: (c) => {
|
||||
sent.push(c);
|
||||
},
|
||||
});
|
||||
|
||||
expect(sent).toHaveLength(1); // immediate beat
|
||||
vi.advanceTimersByTime(3000);
|
||||
expect(sent).toHaveLength(4); // +3 beats
|
||||
expect(sent.map((s) => s.seq)).toEqual([1, 2, 3, 4]);
|
||||
|
||||
loop.stop();
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(sent).toHaveLength(4); // no more after stop
|
||||
loop.stop(); // idempotent
|
||||
});
|
||||
|
||||
it('routes a rejected async send to onError without killing the loop', async () => {
|
||||
vi.useFakeTimers();
|
||||
const onError = vi.fn();
|
||||
let n = 0;
|
||||
const em = new HeartbeatEmitter({ agent, intervalMs: 1000 });
|
||||
const loop = startHeartbeatLoop({
|
||||
emitter: em,
|
||||
intervalMs: 1000,
|
||||
onError,
|
||||
send: () => {
|
||||
n += 1;
|
||||
return Promise.reject(new Error(`boom ${n}`));
|
||||
},
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2000); // immediate + 2
|
||||
expect(n).toBe(3);
|
||||
expect(onError).toHaveBeenCalledTimes(3);
|
||||
loop.stop();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user