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
84 changes: 84 additions & 0 deletions backend/__tests__/unit/models/recentContentByAuthor.pgmem.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* `recentContentByAuthor`, EXECUTED against pg-mem. The handoff cue
* (agentMentionService) asks it whether an agent recently @mentioned another;
* the service suite mocks it, so only this file proves the WHERE clause
* discriminates by pod, author, window and row type.
*/

const { newDb } = require('pg-mem');

const mockDb = newDb();
const mockPool = new (mockDb.adapters.createPg().Pool)();

jest.mock('../../../config/db-pg', () => ({ pool: mockPool }));

const PGMessage = require('../../../models/pg/Message');

const POD = 'aaaaaaaaaaaaaaaaaaaaaa01';
const OTHER_POD = 'aaaaaaaaaaaaaaaaaaaaaa02';
const CODEX = 'cccccccccccccccccccccc01';
const CLAUDE = 'cccccccccccccccccccccc02';
const NOW = new Date('2026-10-20T20:00:00Z');
const minutesAgo = (n) => new Date(NOW.getTime() - n * 60 * 1000);
const SINCE = minutesAgo(30);

const insert = (podId, userId, content, createdAt, type = 'text') => mockPool.query(
'INSERT INTO messages (pod_id, user_id, content, message_type, created_at) VALUES ($1, $2, $3, $4, $5)',
[podId, userId, content, type, createdAt],
);

beforeAll(async () => {
await mockPool.query(`CREATE TABLE messages (
id SERIAL PRIMARY KEY,
pod_id VARCHAR(24) NOT NULL,
user_id VARCHAR(24) NOT NULL,
content TEXT NOT NULL,
message_type VARCHAR(20) DEFAULT 'text' NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
)`);
await insert(POD, CODEX, 'older than the window @claude', minutesAgo(45));
await insert(POD, CODEX, 'first in the window', minutesAgo(20));
await insert(OTHER_POD, CODEX, 'another pod @claude', minutesAgo(10));
await insert(POD, CLAUDE, 'another author @codex', minutesAgo(8));
await insert(POD, CODEX, 'joined the pod', minutesAgo(6), 'system');
await insert(POD, CODEX, 'done, @claude', minutesAgo(5));
});

describe('recentContentByAuthor', () => {
test("returns this author's rows in this pod inside the window, newest first", async () => {
await expect(PGMessage.recentContentByAuthor(POD, CODEX, SINCE))
.resolves.toEqual(['done, @claude', 'first in the window']);
});

test("another author's rows are not this author's", async () => {
await expect(PGMessage.recentContentByAuthor(POD, CLAUDE, SINCE)).resolves.toEqual(['another author @codex']);
});

test('another pod is never read', async () => {
await expect(PGMessage.recentContentByAuthor(OTHER_POD, CLAUDE, SINCE)).resolves.toEqual([]);
await expect(PGMessage.recentContentByAuthor(OTHER_POD, CODEX, SINCE)).resolves.toEqual(['another pod @claude']);
});

test('rows before the window are not returned', async () => {
const all = await PGMessage.recentContentByAuthor(POD, CODEX, minutesAgo(60));
expect(all).toContain('older than the window @claude');
await expect(PGMessage.recentContentByAuthor(POD, CODEX, SINCE))
.resolves.not.toContain('older than the window @claude');
});

test('system rows are not what the author said', async () => {
await expect(PGMessage.recentContentByAuthor(POD, CODEX, minutesAgo(60)))
.resolves.not.toContain('joined the pod');
});

test('the row cap keeps the newest', async () => {
await expect(PGMessage.recentContentByAuthor(POD, CODEX, minutesAgo(60), 1)).resolves.toEqual(['done, @claude']);
});

test('accepts ObjectId-like values and refuses blanks', async () => {
await expect(PGMessage.recentContentByAuthor({ toString: () => POD }, { toString: () => CLAUDE }, SINCE))
.resolves.toEqual(['another author @codex']);
await expect(PGMessage.recentContentByAuthor('', CODEX, SINCE)).resolves.toEqual([]);
await expect(PGMessage.recentContentByAuthor(POD, null, SINCE)).resolves.toEqual([]);
});
});
164 changes: 164 additions & 0 deletions backend/__tests__/unit/services/agentMentionService.handoffCue.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/**
* The handoff cue (C14 call rehearsal, 2026-10-20). Claude handed a change to
* Codex and promised to review it; Codex posted the result without @mentioning
* Claude, so Claude never woke and the review never came. When one agent
* @mentions another, the target's event now says who handed it over and to
* @mention them back with the result: only on the FIRST handoff of a chain,
* so the result coming back (and the thanks after it) carries no cue.
*/

jest.mock('../../../services/agentEventService', () => ({ enqueue: jest.fn() }));
jest.mock('../../../models/AgentRegistry', () => ({
AgentInstallation: { find: jest.fn(), findOne: jest.fn() },
}));
jest.mock('../../../models/AgentProfile', () => ({ find: jest.fn() }));
jest.mock('../../../models/Pod', () => ({ findById: jest.fn(), find: jest.fn() }));
jest.mock('../../../models/User', () => ({ find: jest.fn(), findById: jest.fn(), findOne: jest.fn() }));
jest.mock('../../../services/chatSummarizerService', () => ({
constructor: { getLatestPodSummary: jest.fn() },
summarizePodMessages: jest.fn(),
}));
jest.mock('../../../models/AgentEvent', () => ({ countDocuments: jest.fn() }));
jest.mock('../../../services/welcomeWakeService', () => ({ maybeFireWelcomeWake: jest.fn() }));
jest.mock('../../../models/pg/Message', () => ({ recentContentByAuthor: jest.fn() }));

const AgentMentionService = require('../../../services/agentMentionService');
const AgentEventService = require('../../../services/agentEventService');
const { AgentInstallation } = require('../../../models/AgentRegistry');
const AgentProfile = require('../../../models/AgentProfile');
const Pod = require('../../../models/Pod');
const User = require('../../../models/User');
const AgentEvent = require('../../../models/AgentEvent');
const PGMessage = require('../../../models/pg/Message');

const POD = 'pod-c14';
// Two own agents in one pod, as on the call: each is its own agentName with
// the default instance, so each answers to its bare name.
const AGENTS = {
'laruiss-claude': { _id: 'u-claude', agentName: 'laruiss-claude', instanceId: 'default' },
'laruiss-codex': { _id: 'u-codex', agentName: 'laruiss-codex', instanceId: 'default' },
};
const HUMAN = { _id: 'u-laruiss', isBot: false, username: 'laruiss' };

// What each agent said in the pod, by user id (what recentContentByAuthor reads).
let posted;

beforeEach(() => {
jest.clearAllMocks();
posted = {};
AgentEvent.countDocuments.mockResolvedValue(0);
Pod.findById.mockReturnValue({
select: jest.fn().mockReturnThis(),
lean: jest.fn().mockResolvedValue({ _id: POD, type: 'chat', contacts: {} }),
});
AgentInstallation.find.mockReturnValue({
lean: jest.fn().mockResolvedValue(Object.values(AGENTS).map(({ agentName, instanceId }) => (
{ agentName, instanceId, displayName: agentName }
))),
});
AgentProfile.find.mockReturnValue({ lean: jest.fn().mockResolvedValue([]) });
User.findOne.mockImplementation(({ username }) => ({
select: jest.fn().mockReturnThis(),
lean: jest.fn().mockResolvedValue(AGENTS[username] ? { _id: AGENTS[username]._id } : null),
}));
PGMessage.recentContentByAuthor.mockImplementation(async (_podId, userId) => posted[userId] || []);
});

const as = (sender) => {
User.findById.mockReturnValue({
select: jest.fn().mockReturnThis(),
lean: jest.fn().mockResolvedValue(sender.isBot === false ? sender : {
_id: sender._id,
isBot: true,
username: sender.agentName,
botMetadata: { agentName: sender.agentName, instanceId: sender.instanceId },
}),
});
};

// Post as `sender`: record it in the pod, run the mention fan-out, and return
// the content of the event each mentioned agent received.
const post = async (sender, content) => {
as(sender);
AgentEventService.enqueue.mockClear();
const result = await AgentMentionService.enqueueMentions({
podId: POD,
message: { content, id: `m-${Math.random()}` },
userId: sender._id,
username: sender.username || sender.agentName,
});
posted[sender._id] = [content, ...(posted[sender._id] || [])];
const events = {};
AgentEventService.enqueue.mock.calls.forEach(([event]) => { events[event.agentName] = event.payload.content; });
return { result, events };
};

const CUE = /handed this to you/;

describe('handoff cue', () => {
test('the first agent-to-agent handoff tells the target to @mention the sender with the result', async () => {
const { events } = await post(AGENTS['laruiss-claude'], '@laruiss-codex add a size prop to DsfrButton');
expect(events['laruiss-codex']).toMatch(
'[@laruiss-claude handed this to you. When it\'s done, post the result and @mention @laruiss-claude',
);
});

test('a two-agent chain ends: the result coming back and the thanks after it carry no cue', async () => {
const handoff = await post(AGENTS['laruiss-claude'], '@laruiss-codex add a size prop to DsfrButton');
expect(handoff.events['laruiss-codex']).toMatch(CUE);

const result = await post(AGENTS['laruiss-codex'], 'Done: feat(DsfrButton): add size prop. @laruiss-claude');
expect(result.result.enqueued).toEqual(['laruiss-claude']);
expect(result.events['laruiss-claude']).not.toMatch(CUE);

const thanks = await post(AGENTS['laruiss-claude'], 'Reviewed, looks right. Thanks @laruiss-codex');
expect(thanks.result.enqueued).toEqual(['laruiss-codex']);
expect(thanks.events['laruiss-codex']).not.toMatch(CUE);
});

test('a person mentioning an agent gets no handoff cue', async () => {
const { events } = await post(HUMAN, '@laruiss-claude review the PR');
expect(events['laruiss-claude']).toBeDefined();
expect(events['laruiss-claude']).not.toMatch(CUE);
});

test('a mention back by another handle for the sender still counts as the answer', async () => {
// The target answered with the sender's `<agentName>-<instance>` key, not
// its bare name; both resolve to the sender in this pod.
posted['u-codex'] = ['all done @laruiss-claude-default'];
const { events } = await post(AGENTS['laruiss-claude'], 'Thanks @laruiss-codex');
expect(events['laruiss-codex']).not.toMatch(CUE);
});

test('a longer handle that starts with the sender\'s is not a mention of the sender', async () => {
posted['u-codex'] = ['pinging @laruiss-claude-bot about something else'];
const { events } = await post(AGENTS['laruiss-claude'], '@laruiss-codex next task');
expect(events['laruiss-codex']).toMatch(CUE);
});

test('a sender that is not installed in the pod cannot be woken by a mention, so it is not named', async () => {
const outsider = { _id: 'u-out', agentName: 'roaming-bot', instanceId: 'default' };
const { events } = await post(outsider, '@laruiss-codex take this');
expect(events['laruiss-codex']).toBeDefined();
expect(events['laruiss-codex']).not.toMatch(CUE);
});

test('a failed check means no cue, and the mention is still delivered', async () => {
PGMessage.recentContentByAuthor.mockRejectedValue(new Error('pg down'));
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
const { result, events } = await post(AGENTS['laruiss-claude'], '@laruiss-codex take this');
expect(result.enqueued).toEqual(['laruiss-codex']);
expect(events['laruiss-codex']).not.toMatch(CUE);
expect(warn).toHaveBeenCalledWith('[handoff-cue] check failed (no cue):', 'pg down');
warn.mockRestore();
});

test('the answer check reads the TARGET\'s messages in this pod within 30 minutes', async () => {
await post(AGENTS['laruiss-claude'], '@laruiss-codex take this');
expect(User.findOne).toHaveBeenCalledWith({ username: 'laruiss-codex', isBot: true });
const [podId, userId, since] = PGMessage.recentContentByAuthor.mock.calls[0];
expect([podId, userId]).toEqual([POD, 'u-codex']);
expect(Date.now() - since.getTime()).toBeGreaterThanOrEqual(30 * 60 * 1000 - 1000);
expect(Date.now() - since.getTime()).toBeLessThanOrEqual(30 * 60 * 1000 + 1000);
});
});
18 changes: 18 additions & 0 deletions backend/models/pg/Message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,24 @@ class Message {
return result.rows.length > 0;
}

/**
* What one author posted in a pod since `since`, newest first: content only,
* system rows excluded, at most `limit` rows. The handoff cue
* (agentMentionService) reads it to ask whether an agent recently
* @mentioned another, in which case a mention back is an answer rather
* than a new handoff.
*/
static async recentContentByAuthor(podId: unknown, userId: unknown, since: Date, limit = 20): Promise<string[]> {
if (!podId || !userId) return [];
const result = await (pool as PgPool).query(
`SELECT content FROM messages
WHERE pod_id = $1 AND user_id = $2 AND created_at > $3 AND message_type != 'system'
ORDER BY id DESC LIMIT $4`,
[(podId as { toString(): string }).toString(), (userId as { toString(): string }).toString(), since, limit],
);
return (result.rows as Array<{ content?: unknown }>).map((row) => String(row.content || ''));
}

static async update(id: string, content: string): Promise<MessageRow | undefined> {
const query = `
UPDATE messages
Expand Down
Loading
Loading