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
107 changes: 107 additions & 0 deletions backend/__tests__/unit/services/neverConnectedMentionService.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// TASK-655 item 3: a person's @mention of a Bring your own agent that has never
// polled gets one system line in the room, at most one per agent per UTC day.

const mockFindUser = jest.fn();
const mockFindInstall = jest.fn();
const mockFindBot = jest.fn();
const mockEmitNotConnected = jest.fn();
const mockHasLineToday = jest.fn();

jest.mock('../../../models/User', () => ({
findById: (...args) => mockFindUser(...args),
findOne: (...args) => mockFindBot(...args),
}));
jest.mock('../../../models/AgentRegistry', () => ({
AgentInstallation: { findOne: (...args) => mockFindInstall(...args) },
}));
jest.mock('../../../services/systemActionLineService', () => ({
emitNotConnectedLine: (...args) => mockEmitNotConnected(...args),
hasNotConnectedLineToday: (...args) => mockHasLineToday(...args),
}));

const chain = (value) => ({ select: () => ({ lean: async () => value }) });
const leanOnly = (value) => ({ lean: async () => value });

const { noteNeverConnectedMention } = require('../../../services/neverConnectedMentionService');

const NOW = new Date('2026-10-31T20:00:00.000Z');
const byoInstall = {
agentName: 'nova',
instanceId: 'default',
installedBy: 'owner-1',
status: 'active',
config: { runtime: { host: 'byo', runtimeType: 'openclaw' } },
runtimeTokens: [],
};
const botRow = (tokens = []) => ({
_id: 'bot-1',
username: 'nova',
profilePicture: null,
botMetadata: { displayName: 'Nova', instanceId: 'default' },
agentRuntimeTokens: tokens,
});
const args = {
podId: 'pod-1',
agentName: 'nova',
instanceId: 'default',
senderId: 'human-1',
messageId: '555',
threadRootId: null,
now: NOW,
};

beforeEach(() => {
jest.clearAllMocks();
mockFindUser.mockReturnValue(chain({ isBot: false }));
mockFindInstall.mockReturnValue(leanOnly(byoInstall));
mockFindBot.mockReturnValue(chain(botRow([])));
mockHasLineToday.mockResolvedValue(false);
mockEmitNotConnected.mockResolvedValue(undefined);
});

it('posts one not-connected line when a person mentions a BYO agent that never polled', async () => {
const posted = await noteNeverConnectedMention(args);

expect(posted).toBe(true);
expect(mockHasLineToday).toHaveBeenCalledWith('pod-1', 'nova:default', NOW);
expect(mockEmitNotConnected).toHaveBeenCalledTimes(1);
expect(mockEmitNotConnected.mock.calls[0][0]).toMatchObject({
podId: 'pod-1',
actorUserId: 'bot-1',
agentKey: 'nova:default',
messageId: '555',
});
});

it('stays silent for a bot sender', async () => {
mockFindUser.mockReturnValue(chain({ isBot: true }));
expect(await noteNeverConnectedMention(args)).toBe(false);
expect(mockEmitNotConnected).not.toHaveBeenCalled();
});

it('stays silent once the agent has a live credential use', async () => {
mockFindBot.mockReturnValue(chain(botRow([{ lastUsedAt: new Date(NOW.getTime() - 60 * 1000) }])));
expect(await noteNeverConnectedMention(args)).toBe(false);
expect(mockEmitNotConnected).not.toHaveBeenCalled();
});

it('stays silent when a line for this agent already sits in the pod today', async () => {
mockHasLineToday.mockResolvedValue(true);
expect(await noteNeverConnectedMention(args)).toBe(false);
expect(mockEmitNotConnected).not.toHaveBeenCalled();
});

it('stays silent when there is no active install', async () => {
mockFindInstall.mockReturnValue(leanOnly(null));
expect(await noteNeverConnectedMention(args)).toBe(false);
expect(mockEmitNotConnected).not.toHaveBeenCalled();
});

it('posts one line when the same agent is mentioned twice at once', async () => {
const [a, b] = await Promise.all([
noteNeverConnectedMention(args),
noteNeverConnectedMention(args),
]);
expect([a, b].filter(Boolean)).toHaveLength(1);
expect(mockEmitNotConnected).toHaveBeenCalledTimes(1);
});
25 changes: 25 additions & 0 deletions backend/services/agentMentionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ const { assertAgentRuntimeMayJoinPod } = require('./outsiderGrantAgentJoinPolicy
// eslint-disable-next-line global-require
const chatSummarizerService = require('./chatSummarizerService');
// eslint-disable-next-line global-require, @typescript-eslint/no-require-imports
const { noteNeverConnectedMention } = require('./neverConnectedMentionService') as {
noteNeverConnectedMention: (args: {
podId: string; agentName: string; instanceId: string; senderId: string;
messageId: string | null; threadRootId?: string | number | null;
}) => Promise<boolean>;
};
// eslint-disable-next-line global-require, @typescript-eslint/no-require-imports
const { maybeFireWelcomeWake } = require('./welcomeWakeService') as {
maybeFireWelcomeWake: (opts: Record<string, unknown>) => Promise<unknown>;
};
Expand Down Expand Up @@ -1951,6 +1958,24 @@ const enqueueMentions = async ({
skipped.push(`${directMatch.agentName}:loop-dampened`);
return;
}
// TASK-655: a person mentioning a never-connected BYO agent is told
// once a day, in the room. The mention still queues below.
if (sender?.isBot === false) {
try {
await noteNeverConnectedMention({
podId,
agentName: directMatch.agentName,
instanceId: directMatch.instanceId,
senderId: String(userId),
messageId: message?._id || message?.id ? String(message?._id || message?.id) : null,
threadRootId,
});
} catch (error) {
// The mention is already persisted and still queues; a failed
// notice must never drop or block the delivery.
console.warn('[not-connected] notice failed:', (error as Error).message);
}
}
try {
if (directMatch.agentName === 'commonly-bot') {
pod = pod || await Pod.findById(podId).lean();
Expand Down
108 changes: 108 additions & 0 deletions backend/services/neverConnectedMentionService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { emitNotConnectedLine, hasNotConnectedLineToday } from './systemActionLineService';
import { deriveAgentState } from './agentStateService';

// eslint-disable-next-line @typescript-eslint/no-require-imports
const User = require('../models/User');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { AgentInstallation } = require('../models/AgentRegistry');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const AgentIdentityService = require('./agentIdentityService');

/**
* TASK-655 item 3. A person @mentions a Bring your own agent that has never
* polled. The mention still queues (it is delivered the moment the agent
* connects), but the room now says so once, as a reply to the mention, so the
* person is not left waiting on silence.
*
* ONE DERIVATION. "Never connected" is `deriveAgentState` returning
* `never-connected`, the same verdict the Your Team row and the stalled-connect
* nudge use. There is no second rule here.
*
* Silent when: the sender is a bot (agents do not get told they are unseen),
* the install is not active, the agent is not in the never-connected state, or
* a line for this agent already sits in the pod today.
*/
export async function noteNeverConnectedMention({
podId,
agentName,
instanceId,
senderId,
messageId,
threadRootId,
now = new Date(),
}: {
podId: string;
agentName: string;
instanceId: string;
senderId: string;
messageId: string | number | null;
threadRootId?: string | number | null;
now?: Date;
}): Promise<boolean> {
const sender = await User.findById(senderId).select('isBot').lean() as { isBot?: boolean } | null;
if (!sender || sender.isBot) return false;

const install = await AgentInstallation.findOne({
podId, agentName, instanceId, status: 'active',
}).lean() as Record<string, unknown> | null;
if (!install) return false;

const agent = await User.findOne({
isBot: true,
'botMetadata.agentName': agentName,
'botMetadata.instanceId': instanceId,
}).select('username profilePicture botMetadata agentRuntimeTokens').lean() as {
_id: unknown; username?: string; profilePicture?: string | null;
botMetadata?: { displayName?: string; instanceId?: string };
agentRuntimeTokens?: Array<{ lastUsedAt?: Date | string | null }>;
} | null;
if (!agent) return false;

const derived = deriveAgentState(
install as Parameters<typeof deriveAgentState>[0],
agent.agentRuntimeTokens || [],
'',
now.getTime(),
);
if (derived.state !== 'never-connected') return false;

const agentKey = `${agentName}:${instanceId}`;
// The day check is read-then-insert, so two mentions racing in one process
// (`@nova @nova`) would both pass it. An in-flight key closes that window;
// across replicas the worst case stays one extra line, never a dropped mention.
const flightKey = `${podId}:${agentKey}`;
if (inFlight.has(flightKey)) return false;
inFlight.add(flightKey);
try {
return await postOnce(podId, agentKey, agent, agentName, messageId, threadRootId, now);
} finally {
inFlight.delete(flightKey);
}
}

const inFlight = new Set<string>();

async function postOnce(
podId: string,
agentKey: string,
agent: { _id: unknown; username?: string; profilePicture?: string | null; botMetadata?: { displayName?: string; instanceId?: string } },
agentName: string,
messageId: string | number | null,
threadRootId: string | number | null | undefined,
now: Date,
): Promise<boolean> {
if (await hasNotConnectedLineToday(podId, agentKey, now)) return false;

await emitNotConnectedLine({
podId,
actorUserId: String(agent._id),
actorUsername: agent.username || agentName,
actorAvatarUrl: agent.profilePicture || null,
actorIsBot: true,
agentLabel: AgentIdentityService.resolveAgentDisplayLabel(agent, agentName),
agentKey,
messageId,
threadRootId: threadRootId ?? null,
});
return true;
}
50 changes: 50 additions & 0 deletions backend/services/systemActionLineService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,53 @@ export async function emitUnansweredMentionLine(line: UnansweredMentionLine): Pr
attempts: line.attempts,
}, { threadRootId: line.threadRootId ?? null, replyToMessageId: line.messageId });
}

/**
* TASK-655. A person mentioned a Bring your own agent that has never polled.
* Without this line the mention waits in a queue nobody reads and the person
* sees silence. Placed as a reply to their mention, like the line above.
* Callers dedupe with `hasNotConnectedLineToday`: one per agent per UTC day.
*/
export interface NotConnectedLine extends SystemLineActor {
agentLabel: string;
agentKey: string;
messageId: string | number | null;
threadRootId?: string | number | null;
}

export async function emitNotConnectedLine(line: NotConnectedLine): Promise<void> {
const content = `${line.agentLabel} isn't connected yet, so it won't see this until it runs. `
+ 'Run the connect command shown on its page.';
await insertSystemLine(line, content, {
kind: 'system-action',
action: 'agent-not-connected',
actorName: line.agentLabel,
agentKey: line.agentKey,
}, { threadRootId: line.threadRootId ?? null, replyToMessageId: line.messageId });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

/**
* Whether a not-connected line for this agent already sits in the pod since
* UTC midnight. A missing pool reads as "already posted": no line is better
* than a second line the person did not need.
*/
export async function hasNotConnectedLineToday(
podId: string,
agentKey: string,
now: Date = new Date(),
): Promise<boolean> {
// eslint-disable-next-line global-require, @typescript-eslint/no-require-imports
const { pool } = require('../config/db-pg') as {
pool: { query: (sql: string, params: unknown[]) => Promise<{ rows: unknown[] }> } | null;
};
if (!pool) return true;
const startOfDay = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
const { rows } = await pool.query(
`SELECT 1 FROM messages
WHERE pod_id = $1 AND message_type = 'system' AND created_at >= $2
AND payload->>'action' = 'agent-not-connected' AND payload->>'agentKey' = $3
LIMIT 1`,
[podId, startOfDay, agentKey],
);
return rows.length > 0;
}
Loading