diff --git a/backend/__tests__/unit/services/neverConnectedMentionService.test.js b/backend/__tests__/unit/services/neverConnectedMentionService.test.js new file mode 100644 index 0000000000..cc148c5971 --- /dev/null +++ b/backend/__tests__/unit/services/neverConnectedMentionService.test.js @@ -0,0 +1,98 @@ +// 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(); +}); diff --git a/backend/services/agentMentionService.ts b/backend/services/agentMentionService.ts index 4f111f51cf..76bc7c04d2 100644 --- a/backend/services/agentMentionService.ts +++ b/backend/services/agentMentionService.ts @@ -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; +}; +// eslint-disable-next-line global-require, @typescript-eslint/no-require-imports const { maybeFireWelcomeWake } = require('./welcomeWakeService') as { maybeFireWelcomeWake: (opts: Record) => Promise; }; @@ -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(); diff --git a/backend/services/neverConnectedMentionService.ts b/backend/services/neverConnectedMentionService.ts new file mode 100644 index 0000000000..323c86e910 --- /dev/null +++ b/backend/services/neverConnectedMentionService.ts @@ -0,0 +1,84 @@ +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 { + 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 | 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[0], + agent.agentRuntimeTokens || [], + '', + now.getTime(), + ); + if (derived.state !== 'never-connected') return false; + + const agentKey = `${agentName}:${instanceId}`; + 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; +} diff --git a/backend/services/systemActionLineService.ts b/backend/services/systemActionLineService.ts index 748ee1c334..6534b54c7f 100644 --- a/backend/services/systemActionLineService.ts +++ b/backend/services/systemActionLineService.ts @@ -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 { + 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 }); +} + +/** + * 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 { + // 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; +}