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
24 changes: 24 additions & 0 deletions backend/__tests__/unit/models/AttentionItem.actioned.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// The schema itself: `actioned` is a value the store accepts, and an unknown
// value is still refused.
const mongoose = require('mongoose');
const AttentionItem = require('../../../models/AttentionItem');

const row = (resolvedBy) => new AttentionItem({
recipientUserId: new mongoose.Types.ObjectId(),
podId: new mongoose.Types.ObjectId(),
kind: 'mention',
source: { type: 'message', id: '42' },
title: 'Your agent is installed but not running',
status: 'resolved',
resolvedBy,
});

describe('AttentionItem.resolvedBy accepts actioned', () => {
it.each(['replied', 'acknowledged', 'actioned'])('accepts %s', (value) => {
expect(row(value).validateSync()).toBeUndefined();
});

it('refuses an unknown value', () => {
expect(row('done').validateSync()?.errors?.resolvedBy).toBeDefined();
});
});
24 changes: 24 additions & 0 deletions backend/__tests__/unit/models/AttentionItem.resolvedBy.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// The schema itself, not a mock: `missed` must be a value the store accepts,
// alongside the two it already had, and nothing else may slip in.
const mongoose = require('mongoose');
const AttentionItem = require('../../../models/AttentionItem');

const row = (resolvedBy) => new AttentionItem({
recipientUserId: new mongoose.Types.ObjectId(),
podId: new mongoose.Types.ObjectId(),
kind: 'mention',
source: { type: 'message', id: '42' },
title: 'Kai mentioned you',
status: 'resolved',
resolvedBy,
});

describe('AttentionItem.resolvedBy', () => {
it.each(['replied', 'acknowledged', 'missed'])('accepts %s', (value) => {
expect(row(value).validateSync()).toBeUndefined();
});

it('refuses anything else', () => {
expect(row('expired').validateSync()?.errors?.resolvedBy).toBeDefined();
});
});
174 changes: 171 additions & 3 deletions backend/__tests__/unit/services/attentionItemService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,10 @@ describe('attentionItemService', () => {
expect(mockUpdateOne).not.toHaveBeenCalled();
});

it('materializes a blocked board row once for each current human recipient', async () => {
// A blocked card that names nobody goes to the pod's creator, not to every
// human in the pod: a status flip in a shared work pod used to page all of
// them (2026-10-20 needs-you audit).
it('materializes a blocked board row for the pod creator when the update names nobody', async () => {
mockPodFindById.mockReturnValue(chain({ _id: 'pod-1', name: 'Ship room', createdBy: 'owner', members: ['owner', 'sam'] }));
mockUserFind.mockReturnValue(chain([
{ _id: 'owner', username: 'owner', isBot: false },
Expand All @@ -432,14 +435,99 @@ describe('attentionItemService', () => {
title: 'Choose a deploy shape', updates: [{ _id: 'update-1', text: 'Blocked on an upstream choice.' }],
}, { includeBlocked: true });

expect(mockUpdateOne).toHaveBeenCalledTimes(2);
expect(mockUpdateOne).toHaveBeenCalledTimes(1);
expect(mockUpdateOne).toHaveBeenCalledWith(
expect.objectContaining({ 'source.type': 'task', 'source.id': 'task-1:update-1' }),
expect.objectContaining({ recipientUserId: 'owner', 'source.type': 'task', 'source.id': 'task-1:update-1' }),
expect.objectContaining({ $setOnInsert: expect.objectContaining({ kind: 'handoff', title: 'Choose a deploy shape' }) }),
{ upsert: true },
);
});

describe('a handoff asks; a report of a ruling does not', () => {
const shipRoom = () => {
mockPodFindById.mockReturnValue(chain({ _id: 'pod-1', name: 'Ship room', createdBy: 'owner', members: ['owner', 'sam', 'lee'] }));
mockUserFind.mockReturnValue(chain([
{ _id: 'owner', username: 'owner', isBot: false },
{ _id: 'sam', username: 'Sam', isBot: false },
{ _id: 'lee', username: 'lee', isBot: false },
]));
};
const update = (text) => ({
_id: 'task-2', podId: 'pod-1', taskId: 'TASK-2', status: 'pending',
title: 'Wave 1 #1', updates: [{ _id: 'update-2', text }],
});
const recipients = () => mockUpdateOne.mock.calls.map(([selector]) => selector.recipientUserId);

it("a note reporting a ruling already made opens nothing (the 2026-10-19 \"Sam's ruling\" fan-out)", async () => {
shipRoom();
// "needs" passes the cheap gate, so this reaches isHandoffRequest itself.
await AttentionItemService.recordTaskAttention(update("Sam's ruling, 10-19 (relayed by Cofounder): writes are allowed when the member approves them; the Linear entry needs a re-pin."));
expect(mockUserFind).toHaveBeenCalled();
expect(mockUpdateOne).not.toHaveBeenCalled();
});

it('an ask naming a human member goes to that member only', async () => {
shipRoom();
await AttentionItemService.recordTaskAttention(update("Awaiting @sam's call on the scope before the build."));
expect(recipients()).toEqual(['sam']);
});

it('an ask of "the human" goes to the pod creator', async () => {
shipRoom();
await AttentionItemService.recordTaskAttention(update('Gated and green; ready for the human press.'));
expect(recipients()).toEqual(['owner']);
});

it('an ask naming someone who is not a member of this pod opens nothing', async () => {
shipRoom();
await AttentionItemService.recordTaskAttention(update("Awaiting Bob's call before the build."));
expect(mockUpdateOne).not.toHaveBeenCalled();
});

it('ordinary progress prose costs no member lookup', async () => {
shipRoom();
await AttentionItemService.recordTaskAttention(update('Rebased onto main; CI is running.'));
expect(mockUserFind).not.toHaveBeenCalled();
expect(mockUpdateOne).not.toHaveBeenCalled();
});
});

describe('isHandoffRequest and handoffRecipients', () => {
it('knows no names of its own: a name counts only when it is a member of this pod', () => {
expect(AttentionItemService.isHandoffRequest('ready for sam', [])).toBe(false);
expect(AttentionItemService.isHandoffRequest('ready for sam', ['Sam'])).toBe(true);
expect(AttentionItemService.isHandoffRequest('needs lee-ops review', ['lee'])).toBe(false);
expect(AttentionItemService.isHandoffRequest("Sam's ruling, 10-19: ship it", ['Sam'])).toBe(false);
expect(AttentionItemService.isHandoffRequest('Waiting on a human merge press.', [])).toBe(true);
});

it('"human (merge) press" is an ask on its own, with no lead word and no members (only HUMAN_PRESS_RE accepts it)', () => {
expect(AttentionItemService.isHandoffRequest('Gated and green; human merge press please.', [])).toBe(true);
expect(AttentionItemService.isHandoffRequest('human press', [])).toBe(true);
});

it('with no human members left, a bare lead word is not an ask (the empty-names guard)', () => {
// Without the guard the alternation is empty and the lookahead passes on
// any non-word character, so these would page the creator fallback.
expect(AttentionItemService.isHandoffRequest('awaiting .', [])).toBe(false);
expect(AttentionItemService.isHandoffRequest('needs ', [])).toBe(false);
expect(AttentionItemService.isHandoffRequest('awaiting .', ['', ' '])).toBe(false);
});

it('every accepted text passes the cheap candidate gate', () => {
for (const text of ['ready for sam', 'Awaiting the human', 'human merge press', 'needs @lee', 'waiting for lee']) {
expect(AttentionItemService.TASK_HANDOFF_CANDIDATE_RE.test(text)).toBe(true);
}
});

it('named members first, then the creator, then everyone when the creator has left', () => {
const members = [{ _id: 'owner', username: 'owner' }, { _id: 'sam', username: 'Sam' }];
expect(AttentionItemService.handoffRecipients(members, 'over to @Sam', 'owner').map((m) => m._id)).toEqual(['sam']);
expect(AttentionItemService.handoffRecipients(members, 'blocked', 'owner').map((m) => m._id)).toEqual(['owner']);
expect(AttentionItemService.handoffRecipients(members, 'blocked', 'gone').map((m) => m._id)).toEqual(['owner', 'sam']);
});
});

// createdBy keeps a departed creator's id after leavePod. Membership is
// Pod.members alone (isListedPodMember), so the creator who left gets no
// row from the pod they can no longer open.
Expand Down Expand Up @@ -626,3 +714,83 @@ describe('an agent answering the person who asked', () => {
warn.mockRestore();
});
});

describe('actioned: the recipient did what the row asked, outside its thread', () => {
beforeEach(() => { jest.clearAllMocks(); mockUpdateMany.mockResolvedValue({ modifiedCount: 1 }); });

it("closes a source's open rows as actioned, which Growth counts as acted on", async () => {
await AttentionItemService.resolveActioned('message', 'msg-nudge-1');
expect(mockUpdateMany).toHaveBeenCalledWith(
{ 'source.type': 'message', 'source.id': 'msg-nudge-1', status: 'open' },
{ $set: { status: 'resolved', resolvedAt: expect.any(Date), resolvedBy: 'actioned' } },
);
});

it('does nothing without a source id', async () => {
await AttentionItemService.resolveActioned('message', '');
expect(mockUpdateMany).not.toHaveBeenCalled();
});

it('plain resolve() still writes no resolvedBy (approval, decision and task resolvers rely on that)', async () => {
await AttentionItemService.resolve('approval', 'appr-1');
expect(mockUpdateMany.mock.calls[0][1].$set).not.toHaveProperty('resolvedBy');
});
});

describe('an agent mention nobody answered for a week leaves the queue as missed', () => {
const NOW = new Date('2026-10-20T12:00:00Z');
const CUTOFF = new Date('2026-10-13T12:00:00Z');

beforeEach(() => {
jest.clearAllMocks();
mockUpdateMany.mockResolvedValue({ modifiedCount: 1 });
});

it("resolves an agent's week-old mention as missed and leaves a person's alone", async () => {
mockFind.mockReturnValue(chain([{ _id: 'row-agent', actorUserId: 'bot-1' }, { _id: 'row-person', actorUserId: 'human-1' }]));
mockUserFind.mockReturnValue(chain([{ _id: 'bot-1' }]));

const result = await AttentionItemService.sweepMissedAgentMentions({ now: NOW });

expect(mockUserFind).toHaveBeenCalledWith({ _id: { $in: ['bot-1', 'human-1'] }, isBot: true });
expect(mockUpdateMany).toHaveBeenCalledTimes(1);
expect(mockUpdateMany).toHaveBeenCalledWith(
{ _id: { $in: ['row-agent'] }, kind: 'mention', status: 'open' },
{ $set: { status: 'resolved', resolvedAt: NOW, resolvedBy: 'missed' } },
);
expect(result).toEqual({ scanned: 2, eligible: 1, missed: 1 });
});

it('looks only at open mentions with an actor, older than seven days by their source time', async () => {
mockFind.mockReturnValue(chain([]));

await AttentionItemService.sweepMissedAgentMentions({ now: NOW });

expect(mockFind).toHaveBeenCalledWith({
kind: 'mention',
status: 'open',
actorUserId: { $exists: true, $ne: null },
$or: [
{ sourceCreatedAt: { $lt: CUTOFF } },
{ sourceCreatedAt: { $exists: false }, createdAt: { $lt: CUTOFF } },
],
});
// Nothing old enough: no actor lookup, no write.
expect(mockUserFind).not.toHaveBeenCalled();
expect(mockUpdateMany).not.toHaveBeenCalled();
});

it('a dry run counts without writing', async () => {
mockFind.mockReturnValue(chain([{ _id: 'row-agent', actorUserId: 'bot-1' }]));
mockUserFind.mockReturnValue(chain([{ _id: 'bot-1' }]));

const result = await AttentionItemService.sweepMissedAgentMentions({ now: NOW, apply: false });

expect(mockUpdateMany).not.toHaveBeenCalled();
expect(result).toEqual({ scanned: 1, eligible: 1, missed: 0 });
});

it('the window is seven days', () => {
expect(AttentionItemService.AGENT_MENTION_MISSED_DAYS).toBe(7);
});
});
31 changes: 31 additions & 0 deletions backend/__tests__/unit/services/stalledConnectService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ jest.mock('../../../services/agentIdentityService', () => ({
buildAgentUsername: (n, i) => (i && i !== 'default' ? `${n}-${i}` : n),
}));

const mockAttentionResolve = jest.fn();
jest.mock('../../../services/attentionItemService', () => ({
resolveActioned: (...a) => mockAttentionResolve(...a),
}));

const mockEpCreate = jest.fn();
const mockEpFind = jest.fn();
const mockEpUpdate = jest.fn();
Expand Down Expand Up @@ -276,6 +281,31 @@ describe('keeping the forward promise', () => {
expect(posted.content).toContain('commonly agent run ai1-agent');
});

it("resolves the installer's needs-you row for the nudge once the agent connects", async () => {
const usedAt = new Date(NOW.getTime() - 10 * MINUTE);
setup({ installs: [], open: [{ ...openEpisode, nudgeMessageId: 'msg-nudge-1' }] });
mockInstallFindById.mockReturnValue({
lean: () => Promise.resolve(install({ runtimeTokens: [{ createdAt: OLD_TOKEN, lastUsedAt: usedAt }] })),
});

await scan({ now: NOW });

expect(mockAttentionResolve).toHaveBeenCalledWith('message', 'msg-nudge-1');
});

it('resolves the nudge row even when the confirmation post fails: the person still did it', async () => {
const usedAt = new Date(NOW.getTime() - 10 * MINUTE);
setup({ installs: [], open: [{ ...openEpisode, nudgeMessageId: 'msg-nudge-2' }] });
mockInstallFindById.mockReturnValue({
lean: () => Promise.resolve(install({ runtimeTokens: [{ createdAt: OLD_TOKEN, lastUsedAt: usedAt }] })),
});
mockPost.mockRejectedValueOnce(new Error('post down'));

await scan({ now: NOW });

expect(mockAttentionResolve).toHaveBeenCalledWith('message', 'msg-nudge-2');
});

it('keeps waiting while it is still never-connected', async () => {
setup({ installs: [], open: [openEpisode] });
mockInstallFindById.mockReturnValue({ lean: () => Promise.resolve(install()) });
Expand All @@ -284,6 +314,7 @@ describe('keeping the forward promise', () => {

expect(r.resolved).toHaveLength(0);
expect(mockPost).not.toHaveBeenCalled();
expect(mockAttentionResolve).not.toHaveBeenCalled();
});

it('closes silently when the seat was uninstalled — no room, no promise to keep', async () => {
Expand Down
14 changes: 9 additions & 5 deletions backend/models/AttentionItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,14 @@ export interface IAttentionItem extends Document {
status: 'open' | 'resolved';
resolvedAt?: Date;
// Mention rows may resolve when their recipient replies; mention and handoff
// rows also support an explicit recipient acknowledgement. Decision and
// approval actions have source-specific writers and never become dismissible
// through this field.
resolvedBy?: 'replied' | 'acknowledged';
// rows also support an explicit recipient acknowledgement. `actioned` means the
// recipient did what the row asked, outside the thread (today: started the
// agent a stalled-connect nudge asked them to start). An agent's mention
// nobody answered in its thread for a week leaves the queue as `missed`:
// resolved, never counted as acted on. Decision and approval actions have
// source-specific writers and never become dismissible through this field.
// Growth's NEEDSYOU metric allowlists what counts as acted on; tell it when a value is added or a new kind starts writing one.
resolvedBy?: 'replied' | 'acknowledged' | 'actioned' | 'missed';
createdAt: Date;
updatedAt: Date;
}
Expand Down Expand Up @@ -62,7 +66,7 @@ const attentionItemSchema = new Schema<IAttentionItem>({
sourceCreatedAt: { type: Date },
status: { type: String, enum: ['open', 'resolved'], default: 'open', required: true },
resolvedAt: { type: Date },
resolvedBy: { type: String, enum: ['replied', 'acknowledged'] },
resolvedBy: { type: String, enum: ['replied', 'acknowledged', 'actioned', 'missed'] },
}, { timestamps: true });

// A source can notify each recipient once. Retried source writes must not
Expand Down
4 changes: 2 additions & 2 deletions backend/scripts/backfill-attention-items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const Message = require('../models/Message');
const Task = require('../models/Task');
const User = require('../models/User');
const {
recordApproval, recordDecision, recordMentionedUsers, recordTaskAttention, TASK_HANDOFF_RE,
recordApproval, recordDecision, recordMentionedUsers, recordTaskAttention, TASK_HANDOFF_CANDIDATE_RE,
} = require('../services/attentionItemService');

const APPLY = process.argv.includes('--apply');
Expand Down Expand Up @@ -54,7 +54,7 @@ export const main = async (): Promise<void> => {
Activity.find({ type: 'approval_needed', 'approval.status': 'pending', deleted: { $ne: true } }).lean(),
DecisionRequest.find({ status: 'pending' }).lean(),
Task.find({
$or: [{ status: 'blocked' }, { 'updates.text': { $regex: TASK_HANDOFF_RE } }],
$or: [{ status: 'blocked' }, { 'updates.text': { $regex: TASK_HANDOFF_CANDIDATE_RE } }],
}).select('podId taskId title status notes updates updatedAt').lean(),
legacyAcknowledgements(),
Message.find({ createdAt: { $gte: cutoff } }).lean(),
Expand Down
Loading
Loading