feat(comms): P1 presence — minimal Synapse + fleet presence room + mosaic.presence heartbeat + liveness (#888)
Co-authored-by: jason.woltje <jason@diversecanvas.com> Co-committed-by: jason.woltje <jason@diversecanvas.com>
This commit was merged in pull request #888.
This commit is contained in:
131
packages/comms/src/__tests__/matrix-client.test.ts
Normal file
131
packages/comms/src/__tests__/matrix-client.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { MatrixError, MinimalMatrixClient, toMatrixPresence } from '../matrix-client.js';
|
||||
|
||||
const jsonResponse = (status: number, body: unknown): Response =>
|
||||
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
// A fetch mock typed with the (URL, RequestInit?) shape the client actually
|
||||
// calls, so mock.calls has a proper tuple type under noUncheckedIndexedAccess.
|
||||
const mkFetch = (impl: (url: URL, init?: RequestInit) => Promise<Response>) => vi.fn(impl);
|
||||
|
||||
const cfg = {
|
||||
homeserverUrl: 'https://matrix.localhost:8448',
|
||||
accessToken: 'as-secret',
|
||||
actAsUserId: '@agent-alpha:matrix.localhost',
|
||||
};
|
||||
|
||||
describe('toMatrixPresence', () => {
|
||||
it('maps liveness states to native presence EDU values', () => {
|
||||
expect(toMatrixPresence('online')).toBe('online');
|
||||
expect(toMatrixPresence('away')).toBe('unavailable');
|
||||
expect(toMatrixPresence('offline')).toBe('offline');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MinimalMatrixClient', () => {
|
||||
it('setPresence PUTs native presence and masquerades via user_id', async () => {
|
||||
const fetchMock = mkFetch(async () => jsonResponse(200, {}));
|
||||
const client = new MinimalMatrixClient(cfg, fetchMock as unknown as typeof fetch);
|
||||
await client.setPresence('@agent-alpha:matrix.localhost', 'away', 'hb');
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0]!;
|
||||
const u = new URL((url as URL).toString());
|
||||
expect(u.pathname).toBe('/_matrix/client/v3/presence/%40agent-alpha%3Amatrix.localhost/status');
|
||||
expect(u.searchParams.get('user_id')).toBe('@agent-alpha:matrix.localhost');
|
||||
expect(JSON.parse((init as RequestInit).body as string)).toEqual({
|
||||
presence: 'unavailable',
|
||||
status_msg: 'hb',
|
||||
});
|
||||
expect((init as RequestInit).method).toBe('PUT');
|
||||
});
|
||||
|
||||
it('sendHeartbeat posts an m.room.message and returns the event_id', async () => {
|
||||
const fetchMock = mkFetch(async () => jsonResponse(200, { event_id: '$evt1' }));
|
||||
const client = new MinimalMatrixClient(cfg, fetchMock as unknown as typeof fetch);
|
||||
const id = await client.sendHeartbeat('!room:matrix.localhost', {
|
||||
macp_version: '1.0',
|
||||
macp_type: 'presence',
|
||||
msgtype: 'mosaic.presence',
|
||||
agent: { mxid: cfg.actAsUserId, slug: 'alpha', harness: 'claude-code' },
|
||||
ts: 1,
|
||||
body: 'alpha online (seq 1)',
|
||||
status: 'online',
|
||||
seq: 1,
|
||||
interval_ms: 1000,
|
||||
});
|
||||
expect(id).toBe('$evt1');
|
||||
const [url] = fetchMock.mock.calls[0]!;
|
||||
expect((url as URL).pathname).toContain('/rooms/!room%3Amatrix.localhost/send/m.room.message/');
|
||||
});
|
||||
|
||||
it('throws a MatrixError carrying errcode on a non-2xx', async () => {
|
||||
const fetchMock = mkFetch(async () =>
|
||||
jsonResponse(403, { errcode: 'M_FORBIDDEN', error: 'nope' }),
|
||||
);
|
||||
const client = new MinimalMatrixClient(cfg, fetchMock as unknown as typeof fetch);
|
||||
await expect(client.whoami()).rejects.toMatchObject({
|
||||
name: 'MatrixError',
|
||||
status: 403,
|
||||
errcode: 'M_FORBIDDEN',
|
||||
});
|
||||
await expect(client.whoami()).rejects.toBeInstanceOf(MatrixError);
|
||||
});
|
||||
|
||||
it('readHeartbeats reduces the timeline to the latest beat per agent', async () => {
|
||||
// Timeline (dir=b => most-recent first). alpha has two beats; keep highest seq.
|
||||
const chunk = [
|
||||
{
|
||||
sender: '@agent-bravo:matrix.localhost',
|
||||
origin_server_ts: 9000,
|
||||
content: {
|
||||
msgtype: 'mosaic.presence',
|
||||
agent: { slug: 'bravo', mxid: '@agent-bravo:matrix.localhost' },
|
||||
seq: 5,
|
||||
status: 'online',
|
||||
ts: 8999,
|
||||
},
|
||||
},
|
||||
{
|
||||
sender: '@agent-alpha:matrix.localhost',
|
||||
origin_server_ts: 8000,
|
||||
content: {
|
||||
msgtype: 'mosaic.presence',
|
||||
agent: { slug: 'alpha', mxid: '@agent-alpha:matrix.localhost' },
|
||||
seq: 12,
|
||||
status: 'online',
|
||||
ts: 7999,
|
||||
},
|
||||
},
|
||||
{
|
||||
// an ordinary chat message must be ignored
|
||||
sender: '@human:matrix.localhost',
|
||||
origin_server_ts: 7000,
|
||||
content: { msgtype: 'm.text', body: 'hi' },
|
||||
},
|
||||
{
|
||||
sender: '@agent-alpha:matrix.localhost',
|
||||
origin_server_ts: 6000,
|
||||
content: {
|
||||
msgtype: 'mosaic.presence',
|
||||
agent: { slug: 'alpha', mxid: '@agent-alpha:matrix.localhost' },
|
||||
seq: 11,
|
||||
status: 'online',
|
||||
ts: 5999,
|
||||
},
|
||||
},
|
||||
];
|
||||
const fetchMock = mkFetch(async () => jsonResponse(200, { chunk }));
|
||||
const client = new MinimalMatrixClient(cfg, fetchMock as unknown as typeof fetch);
|
||||
const obs = await client.readHeartbeats('!room:matrix.localhost');
|
||||
|
||||
const bySlug = Object.fromEntries(obs.map((o) => [o.slug, o]));
|
||||
expect(Object.keys(bySlug).sort()).toEqual(['alpha', 'bravo']);
|
||||
expect(bySlug.alpha).toMatchObject({ lastSeq: 12, lastSeenTs: 8000 }); // highest seq wins, server ts
|
||||
expect(bySlug.bravo).toMatchObject({ lastSeq: 5, lastSeenTs: 9000 });
|
||||
|
||||
const [url] = fetchMock.mock.calls[0]!;
|
||||
const u = new URL((url as URL).toString());
|
||||
expect(u.searchParams.get('dir')).toBe('b');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user