Skip to content
Open
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
18 changes: 18 additions & 0 deletions backend/__tests__/unit/models/PgMessage.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,24 @@ describe('PG Message model', () => {
);
});

it('finds every substantive agent-active pod in the requested window', async () => {
const since = new Date('2026-08-26T00:00:00.000Z');
pool.query.mockResolvedValueOnce({
rows: [{ pod_id: 'busy-pod', message_count: '15', last_at: new Date('2026-08-26T14:00:00.000Z') }],
});

const result = await Message.findSubstantiveAgentPodActivity(['quiet-pod', 'busy-pod'], since);

expect(pool.query).toHaveBeenCalledWith(
expect.stringContaining('u.is_bot = TRUE'),
[['quiet-pod', 'busy-pod'], since],
);
expect(pool.query.mock.calls[0][0]).toContain("NOT IN ('commonly-bot', 'commonly-ai-agent')");
expect(result).toEqual([{
podId: 'busy-pod', agentMessageCount: 15, lastAt: new Date('2026-08-26T14:00:00.000Z'),
}]);
});

it('findById returns formatted message', async () => {
pool.query.mockResolvedValueOnce({
rows: [
Expand Down
104 changes: 102 additions & 2 deletions backend/__tests__/unit/services/activityService.recap.test.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
jest.mock('../../../models/Pod', () => ({ find: jest.fn() }));
jest.mock('../../../models/Task', () => ({ find: jest.fn() }));
jest.mock('../../../models/pg/Message', () => ({
findSubstantiveAgentPodActivity: jest.fn().mockResolvedValue([]),
}));

const Pod = require('../../../models/Pod');
const Task = require('../../../models/Task');
const PGMessage = require('../../../models/pg/Message');
const Activity = require('../../../models/Activity');
const User = require('../../../models/User');
const ActivityService = require('../../../services/activityService');
Expand Down Expand Up @@ -30,6 +34,7 @@ describe('ActivityService.getRecap', () => {

beforeEach(() => {
jest.clearAllMocks();
PGMessage.findSubstantiveAgentPodActivity.mockResolvedValue([]);
Pod.find.mockReturnValue(podQuery([pod]));
Task.find.mockReturnValue(taskQuery([{
_id: 'board-1',
Expand Down Expand Up @@ -65,7 +70,9 @@ describe('ActivityService.getRecap', () => {
test('projects existing agent activity, direct mentions, and board updates without writing new events', async () => {
const result = await ActivityService.getRecap(ownerId, { window: 'today' });

expect(result.pods).toEqual([expect.objectContaining({ id: 'pod-1', name: pod.name })]);
expect(result.pods).toEqual([expect.objectContaining({
id: 'pod-1', name: pod.name, activeInWindow: true, agentMessageCount: 1,
})]);
expect(result.needsYou).toEqual([expect.objectContaining({
kind: 'mention', podId: 'pod-1', title: 'sprint-impl mentioned you',
})]);
Expand All @@ -80,10 +87,103 @@ describe('ActivityService.getRecap', () => {
expect(spy).toHaveBeenCalledWith(ownerId, { limit: 100 });
expect(Pod.find).toHaveBeenCalledWith(expect.objectContaining({ $or: expect.any(Array) }));
expect(Task.find).toHaveBeenCalledWith(expect.objectContaining({
podId: { $in: ['pod-1'] }, updatedAt: { $gte: expect.any(Date) },
podId: { $in: ['pod-1'] }, $or: expect.any(Array),
}));
});

test('surfaces durable board press and decision facts ahead of incidental mentions', async () => {
Task.find.mockReturnValue(taskQuery([
{
_id: 'press-1', podId: 'pod-1', taskId: 'TASK-201',
title: 'Release the Activity recap', status: 'claimed', updatedAt: new Date(),
prUrl: 'https://github.com/Team-Commonly/commonly/pull/1274',
updates: [{ text: 'Gated #1274 — awaiting human press.', author: 'reviewer', createdAt: new Date() }],
},
{
_id: 'decision-1', podId: 'pod-1', taskId: 'TASK-202',
title: 'DECIDE: retain unread activity state', status: 'blocked', updatedAt: new Date(),
updates: [{ text: 'A human decision unblocks the implementation.', author: 'architect', createdAt: new Date() }],
},
{
_id: 'handoff-1', podId: 'pod-1', taskId: 'TASK-203',
title: 'Press the deployment after the smoke test', status: 'blocked', updatedAt: new Date(),
updates: [{ text: 'Waiting for a human to press the deployment.', author: 'operator', createdAt: new Date() }],
},
]));

const result = await ActivityService.getRecap(ownerId, { window: 'today' });

expect(result.needsYou).toEqual(expect.arrayContaining([
expect.objectContaining({
kind: 'press', taskId: 'TASK-201', podId: 'pod-1',
prUrl: 'https://github.com/Team-Commonly/commonly/pull/1274',
}),
expect.objectContaining({ kind: 'decide', taskId: 'TASK-202', podId: 'pod-1' }),
expect.objectContaining({ kind: 'handoff', taskId: 'TASK-203', podId: 'pod-1' }),
expect.objectContaining({ kind: 'mention', id: 'message-1' }),
]));
expect(result.needsYou.map((item) => item.kind)).toEqual(['press', 'decide', 'handoff', 'mention']);
expect(Task.find.mock.results[0].value.select).toHaveBeenCalledWith(
expect.stringContaining('prUrl'),
);
});

test('excludes system bot noise, ranks real seats by substantive updates, and only exposes active pods in the default scope', async () => {
const activePod = { _id: 'pod-2', name: 'Real work pod', type: 'team' };
Pod.find.mockReturnValue(podQuery([pod, activePod]));
Task.find.mockReturnValue(taskQuery([]));
spy.mockResolvedValue({
activities: [
{
id: 'system-1', type: 'summary', actor: { id: 'system', name: 'commonly-bot', type: 'system' },
action: 'summary', preview: 'Echoed a task update.', timestamp: new Date(),
pod: { id: 'pod-1', name: pod.name }, flags: { isAgentAction: true, isMention: false },
},
{
id: 'agent-a', type: 'message', actor: { id: 'agent-a', name: 'alpha', type: 'agent' },
action: 'message', preview: 'Shipped one change.', timestamp: new Date(Date.now() - 60_000),
pod: { id: 'pod-2', name: activePod.name }, flags: { isAgentAction: true, isMention: false },
},
{
id: 'agent-b-1', type: 'message', actor: { id: 'agent-b', name: 'beta', type: 'agent' },
action: 'message', preview: 'Reviewed a pull request.', timestamp: new Date(Date.now() - 120_000),
pod: { id: 'pod-2', name: activePod.name }, flags: { isAgentAction: true, isMention: false },
},
{
id: 'agent-b-2', type: 'message', actor: { id: 'agent-b', name: 'beta', type: 'agent' },
action: 'message', preview: 'Posted the decision.', timestamp: new Date(Date.now() - 180_000),
pod: { id: 'pod-2', name: activePod.name }, flags: { isAgentAction: true, isMention: false },
},
],
});

const result = await ActivityService.getRecap(ownerId, { window: 'today' });

expect(result.scope).toBe('active');
expect(result.pods).toEqual(expect.arrayContaining([
{ id: 'pod-1', name: pod.name, activeInWindow: false, agentMessageCount: 0 },
{ id: 'pod-2', name: activePod.name, activeInWindow: true, agentMessageCount: 3 },
]));
expect(result.agents.map((agent) => agent.name)).toEqual(['beta', 'alpha']);
expect(result.agents.map((agent) => agent.name)).not.toContain('commonly-bot');
});

test('derives the active pod selector from all substantive agent messages, not the recap feed page', async () => {
const busyPod = { _id: 'pod-busy', name: 'Busy but active', type: 'team' };
Pod.find.mockReturnValue(podQuery([pod, busyPod]));
Task.find.mockReturnValue(taskQuery([]));
spy.mockResolvedValue({ activities: [] });
PGMessage.findSubstantiveAgentPodActivity.mockResolvedValue([{ podId: 'pod-busy', agentMessageCount: 15 }]);

const result = await ActivityService.getRecap(ownerId, { window: 'today' });

expect(PGMessage.findSubstantiveAgentPodActivity).toHaveBeenCalledWith(['pod-1', 'pod-busy'], expect.any(Date));
expect(result.pods).toEqual(expect.arrayContaining([
{ id: 'pod-1', name: pod.name, activeInWindow: false, agentMessageCount: 0 },
{ id: 'pod-busy', name: busyPod.name, activeInWindow: true, agentMessageCount: 15 },
]));
});

test('rejects a requested pod that is outside the viewer membership', async () => {
await expect(ActivityService.getRecap(ownerId, { podId: 'not-a-member-pod' }))
.rejects.toThrow('Access denied');
Expand Down
44 changes: 44 additions & 0 deletions backend/models/pg/Message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ interface PodActivityEntry {
lastAt: unknown;
}

interface AgentPodActivityEntry extends PodActivityEntry {
agentMessageCount: number;
}

function formatMessage(msg: MessageRow): FormattedMessage {
const messageId = msg.id ? msg.id.toString() : '';
const userId = msg.user_id || '';
Expand Down Expand Up @@ -511,6 +515,46 @@ class Message {
}
}

// The Activity recap's default pod scope must describe real agent work in
// the selected window, not merely the first page of its mixed activity
// feed. This is deliberately a grouped database read: a busy pod can have
// more than the feed's display limit before the next active pod's message.
// System summaries are bot-authored too, so exclude their two known seats
// here as well as in the recap projection.
static async findSubstantiveAgentPodActivity(
podIds: unknown[],
since: unknown,
): Promise<AgentPodActivityEntry[]> {
if (!podIds || !podIds.length) return [];
try {
const podIdStrs = podIds.map((id) => (id as { toString(): string } | undefined)?.toString()).filter(Boolean);
if (!podIdStrs.length) return [];
const result = await (pool as PgPool).query(
`SELECT m.pod_id, COUNT(*) AS message_count, MAX(m.created_at) AS last_at
FROM messages m
JOIN users u ON u._id = m.user_id
WHERE m.pod_id = ANY($1)
AND m.created_at >= $2
AND m.message_type != 'system'
AND m.content <> ''
AND u.is_bot = TRUE
AND LOWER(COALESCE(u.username, '')) NOT IN ('commonly-bot', 'commonly-ai-agent')
GROUP BY m.pod_id
ORDER BY last_at DESC`,
[podIdStrs, since],
);
return (result.rows as Array<{ pod_id: string; message_count?: string | number; last_at: unknown }>).map((row) => ({
podId: row.pod_id,
agentMessageCount: parseInt(String(row.message_count || 0), 10),
lastAt: row.last_at,
}));
} catch (error) {
const e = error as { message?: string };
console.error('Error in findSubstantiveAgentPodActivity:', e.message);
return [];
}
}

// One row per pod: the given user's most-recent non-system message in each pod.
// Powers the agent-profile "pods" list (their last message + when, per pod).
static async findLastMessageByUserPerPod(
Expand Down
Loading
Loading