Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions backend/__tests__/service/agent-states-listener-check-in.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// /agent-states reads listener check-ins (TASK-721), through the real models and
// the real route. The unit suite builds token rows by hand and so cannot see a
// dropped projection field or a stamp written to the wrong store: this one
// writes with `recordListenerCheckIn` and reads back over HTTP.
const express = require('express');
const request = require('supertest');
const jwt = require('jsonwebtoken');
const { setupMongoDb, closeMongoDb } = require('../utils/testUtils');
const User = require('../../models/User');
const Pod = require('../../models/Pod');
const { AgentInstallation } = require('../../models/AgentRegistry');
const podsRouter = require('../../routes/pods');
const { recordListenerCheckIn, resetListenerCheckInThrottle } = require('../../services/agentListenerCheckIn');

const SECRET = 'agent-states-listener-test';
const WRAPPER = { runtimeType: 'webhook', host: 'byo' };

describe('GET /api/pods/:podId/agent-states reads listener check-ins', () => {
let app; let owner; let pod; let authHeader;

const install = (agentName, runtimeTokens = []) => AgentInstallation.create({
agentName,
instanceId: 'default',
podId: pod._id,
version: '1',
installedBy: owner._id,
config: new Map([['runtime', WRAPPER]]),
runtimeTokens,
});
const botUser = (agentName, agentRuntimeTokens) => User.create({
username: agentName,
email: `${agentName}@test.com`,
password: 'password123',
isBot: true,
botMetadata: { agentName, instanceId: 'default' },
agentRuntimeTokens,
});
const states = async () => {
const res = await request(app).get(`/api/pods/${pod._id}/agent-states`).set('Authorization', authHeader);
expect(res.status).toBe(200);
return Object.fromEntries(res.body.agents.map((a) => [a.agentName, a]));
};
// The check-in writes are fire-and-forget, so wait for them to land.
const settle = () => new Promise((resolve) => { setTimeout(resolve, 150); });

beforeAll(async () => {
process.env.JWT_SECRET = SECRET;
await setupMongoDb();
app = express();
app.use(express.json());
app.use('/api/pods', podsRouter);
owner = await User.create({ username: 'states-owner', email: 'states-owner@test.com', password: 'password123' });
pod = await Pod.create({
name: 'States', type: 'chat', createdBy: owner._id, members: [owner._id],
});
authHeader = `Bearer ${jwt.sign({ id: String(owner._id) }, SECRET)}`;
});

afterAll(async () => closeMongoDb());
beforeEach(() => resetListenerCheckInThrottle());

it('an MCP call alone is never-connected; a check-in on the User token flips it to listening', async () => {
const used = new Date();
await install('mcp-only');
await botUser('mcp-only', [{ tokenHash: 'h-user', lastUsedAt: used }]);
expect((await states())['mcp-only'].state).toBe('never-connected');

recordListenerCheckIn((await User.findOne({ username: 'mcp-only' }))._id, 'h-user');
await settle();
expect((await states())['mcp-only'].state).toBe('listening');
});

it('a check-in on an installation-only token flips it to listening', async () => {
await install('install-only', [{ tokenHash: 'h-install', lastUsedAt: new Date() }]);
expect((await states())['install-only'].state).toBe('never-connected');

recordListenerCheckIn(null, 'h-install');
await settle();
expect((await states())['install-only'].state).toBe('listening');
});
});
31 changes: 30 additions & 1 deletion backend/__tests__/services/agentWebSocketService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ jest.mock('../../models/User', () => ({
updateOne: jest.fn(),
}));

const mockRecordListenerCheckIn = jest.fn();
jest.mock('../../services/agentListenerCheckIn', () => ({
recordListenerCheckIn: (...args) => mockRecordListenerCheckIn(...args),
}));
const mockMarkByoConnectionAttempt = jest.fn();
jest.mock('../../services/onboardingMarks', () => ({
markByoConnectionAttempt: (...args) => mockMarkByoConnectionAttempt(...args),
Expand Down Expand Up @@ -147,7 +151,13 @@ describe('agentWebSocketService', () => {
});
expect(AgentInstallation.updateOne).toHaveBeenCalledWith(
{ _id: 'install-1', 'runtimeTokens.tokenHash': hash(token) },
{ $set: { 'runtimeTokens.$.lastUsedAt': expect.any(Date) } },
// An installation-bound token is stamped as a listener check-in too (TASK-721).
{
$set: {
'runtimeTokens.$.lastUsedAt': expect.any(Date),
'runtimeTokens.$.lastListenedAt': expect.any(Date),
},
},
);
expect(result).toEqual({
agentName: 'openclaw',
Expand Down Expand Up @@ -354,6 +364,25 @@ describe('agentWebSocketService', () => {
expect(agentData.lastPong).toBeGreaterThan(initialPong);
});

it('a pong from the live socket is a listener check-in, one from a replaced socket is not', () => {
AgentInstallation.findOne.mockResolvedValue({ agentName: 'openclaw', instanceId: 'default' });
agentWebSocketService.init(mockIo);
if (connectionHandler) connectionHandler(mockSocket);
mockSocket.agentUserId = 'bot-user-1';
mockSocket.tokenHash = 'hashed-token';
const pongHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'pong')?.[1];

mockRecordListenerCheckIn.mockClear();
pongHandler();
expect(mockRecordListenerCheckIn).toHaveBeenCalledWith('bot-user-1', 'hashed-token');

// A reconnect replaced this socket: the old one must not keep the seat listening.
agentWebSocketService.connectedAgents.get('openclaw:default').socket = { id: 'socket-2' };
mockRecordListenerCheckIn.mockClear();
pongHandler();
expect(mockRecordListenerCheckIn).not.toHaveBeenCalled();
});

it('sends ping to all connected agents every 30 seconds', () => {
AgentInstallation.findOne.mockResolvedValue({
agentName: 'openclaw',
Expand Down
50 changes: 50 additions & 0 deletions backend/__tests__/unit/routes/agentsRuntime.dm-events.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ jest.mock('../../../services/agentEventService', () => ({
list: jest.fn(),
listInboxPage: jest.fn(),
renewDelivery: jest.fn(),
holdsEvent: jest.fn(),
}));
jest.mock('../../../services/agentIdentityService', () => ({
buildAgentUsername: jest.fn((agentName, instanceId = 'default') => (
Expand Down Expand Up @@ -178,6 +179,55 @@ describe('inbox delivery renewal route', () => {
expect(AgentEventService.renewDelivery).toHaveBeenLastCalledWith(eventId, 'wren', 'writer-instance', 'nonce');
expect(res.json).toHaveBeenCalledWith({ renewed: [eventId] });
});
test('a renew is a listener check-in, so a seat inside a long turn stays listening', async () => {
AgentEventService.renewDelivery.mockResolvedValue(true);
recordListenerCheckIn.mockClear();
await getRouteHandler('/events/renew', 'post')({
agentInstallation: { agentName: 'wren', instanceId: 'writer-instance' },
agentUser: { _id: 'bot-user-1' },
agentTokenHash: 'hashed-token',
body: { events: [{ eventId, deliveryId: 'nonce' }] },
}, response());
expect(recordListenerCheckIn).toHaveBeenCalledWith('bot-user-1', 'hashed-token');
});
test('a renew naming an event this agent does not own is not a check-in', async () => {
AgentEventService.renewDelivery.mockResolvedValue(false);
AgentEventService.holdsEvent.mockResolvedValue(false);
recordListenerCheckIn.mockClear();
const res = response();
await getRouteHandler('/events/renew', 'post')({
agentInstallation: { agentName: 'wren', instanceId: 'writer-instance' },
agentUser: { _id: 'bot-user-1' },
agentTokenHash: 'hashed-token',
body: { events: [{ eventId, deliveryId: 'stale-nonce' }] },
}, res);
expect(res.json).toHaveBeenCalledWith({ renewed: [] });
expect(recordListenerCheckIn).not.toHaveBeenCalled();
});
test('a renew after the turn checkpointed the event still checks in, though nothing renews', async () => {
// commonly_checkpoint_event acks mid-turn: the row is acked, so renewDelivery
// matches nothing, but the runner is alive and still working.
AgentEventService.renewDelivery.mockResolvedValue(false);
AgentEventService.holdsEvent.mockResolvedValue(true);
recordListenerCheckIn.mockClear();
const res = response();
await getRouteHandler('/events/renew', 'post')({
agentInstallation: { agentName: 'wren', instanceId: 'writer-instance' },
agentUser: { _id: 'bot-user-1' },
agentTokenHash: 'hashed-token',
body: { events: [{ eventId, deliveryId: 'nonce' }] },
}, res);
expect(AgentEventService.holdsEvent).toHaveBeenLastCalledWith(eventId, 'wren', 'writer-instance');
expect(res.json).toHaveBeenCalledWith({ renewed: [] });
expect(recordListenerCheckIn).toHaveBeenCalledWith('bot-user-1', 'hashed-token');
});
test('a malformed renew is not a check-in', async () => {
recordListenerCheckIn.mockClear();
await getRouteHandler('/events/renew', 'post')({
agentUser: { _id: 'bot-user-1' }, agentTokenHash: 'hashed-token', body: { events: [] },
}, response());
expect(recordListenerCheckIn).not.toHaveBeenCalled();
});
test.each([undefined, [], Array(51).fill({ eventId, deliveryId: 'nonce' }),
[{ eventId, deliveryId: '' }], [{ eventId: {}, deliveryId: 'nonce' }]])('rejects malformed or unbounded input %j', async (events) => {
AgentEventService.renewDelivery.mockClear();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ describe('stamped and legacy polling seats both reach the honesty surface', () =
// knowable in both directions.
const state = deriveAgentState(
install({ runtimeType: 'webhook', host: 'byo' }),
[{ lastUsedAt: new Date() }],
[{ lastListenedAt: new Date() }],
'u1',
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,26 @@ beforeEach(async () => {
await AgentDigestState.deleteMany({});
});

describe('holdsEvent (the listener check-in on /events/renew)', () => {
const make = (over = {}) => AgentEvent.create({
agentName: AGENT, instanceId: INSTANCE, podId: POD_ID, type: 'chat.mention', payload: { content: 'q' }, ...over,
});

test('true for this agent\'s delivered and checkpointed events, false for pending, other agents and unknown ids', async () => {
const delivered = await make({ status: 'delivered', deliveryNonce: 'n1' });
const acked = await make({ status: 'acked', deliveryNonce: null });
const pending = await make({ status: 'pending' });
const other = await make({ agentName: 'someone-else', status: 'acked' });
const unknownId = String(new mongoose.Types.ObjectId());
const holds = (id, agent = AGENT) => AgentEventService.holdsEvent(String(id), agent, INSTANCE);
expect(await holds(delivered._id)).toBe(true);
expect(await holds(acked._id)).toBe(true);
expect(await holds(pending._id)).toBe(false);
expect(await holds(other._id)).toBe(false);
expect(await holds(unknownId)).toBe(false);
});
});

describe('legacy ambient digest bridge', () => {
test('a push hint does not consume durable inbox state before the reconciling poll', async () => {
const ws = require('../../../services/agentWebSocketService');
Expand Down
31 changes: 30 additions & 1 deletion backend/__tests__/unit/services/agentListenerCheckIn.test.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
// TASK-719: the listener check-in stamp is its own field, written at most once
// a minute per token, and a failed write retries on the next poll.
jest.mock('../../../models/User', () => ({ updateOne: jest.fn() }));
jest.mock('../../../models/AgentRegistry', () => ({ AgentInstallation: { updateMany: jest.fn() } }));

const User = require('../../../models/User');
const { AgentInstallation } = require('../../../models/AgentRegistry');
const {
recordListenerCheckIn,
resetListenerCheckInThrottle,
Expand All @@ -12,6 +14,7 @@ describe('recordListenerCheckIn', () => {
beforeEach(() => {
jest.useFakeTimers().setSystemTime(new Date('2026-10-31T12:00:00.000Z'));
User.updateOne.mockReset().mockResolvedValue({});
AgentInstallation.updateMany.mockReset().mockResolvedValue({});
resetListenerCheckInThrottle();
});

Expand All @@ -37,11 +40,37 @@ describe('recordListenerCheckIn', () => {
expect(User.updateOne).toHaveBeenCalledTimes(3);
});

it('writes nothing without an agent id or a token hash', () => {
it('stamps the installation copy of the token too', () => {
recordListenerCheckIn('agent-1', 'hash-1');

expect(AgentInstallation.updateMany).toHaveBeenCalledWith(
{ 'runtimeTokens.tokenHash': 'hash-1' },
{ $set: { 'runtimeTokens.$.lastListenedAt': new Date('2026-10-31T12:00:00.000Z') } },
);
});

it('stamps an installation-only token when there is no bot user row', () => {
recordListenerCheckIn(undefined, 'hash-1');

expect(User.updateOne).not.toHaveBeenCalled();
expect(AgentInstallation.updateMany).toHaveBeenCalledTimes(1);
});

it('writes nothing without a token hash', () => {
recordListenerCheckIn('agent-1', undefined);
recordListenerCheckIn('agent-1', '');
expect(User.updateOne).not.toHaveBeenCalled();
expect(AgentInstallation.updateMany).not.toHaveBeenCalled();
});

it('retries on the next poll when only the installation write fails', async () => {
AgentInstallation.updateMany.mockRejectedValueOnce(new Error('mongo down'));
recordListenerCheckIn('agent-1', 'hash-1');
await Promise.resolve();
await Promise.resolve();

recordListenerCheckIn('agent-1', 'hash-1');
expect(AgentInstallation.updateMany).toHaveBeenCalledTimes(2);
});

it('retries on the next poll when the write fails, and never throws', async () => {
Expand Down
Loading
Loading