diff --git a/apps/web/src/app/api/channels/[pageId]/messages/route.ts b/apps/web/src/app/api/channels/[pageId]/messages/route.ts index 2f122e0c91..3f293f841a 100644 --- a/apps/web/src/app/api/channels/[pageId]/messages/route.ts +++ b/apps/web/src/app/api/channels/[pageId]/messages/route.ts @@ -87,6 +87,7 @@ export async function POST(req: Request, { params }: { params: Promise<{ pageId: fileId?: string; attachmentMeta?: AttachmentMeta; }; + const messageContent = typeof content === 'string' ? content : ''; // Debug: Check what content type is being received loggers.realtime.debug('API received content type:', { type: typeof content }); @@ -105,7 +106,7 @@ export async function POST(req: Request, { params }: { params: Promise<{ pageId: const [createdMessage] = await db.insert(channelMessages).values({ pageId: pageId, userId: userId, - content, + content: messageContent, fileId: fileId || null, attachmentMeta: attachmentMeta || null, }).returning(); @@ -180,11 +181,31 @@ export async function POST(req: Request, { params }: { params: Promise<{ pageId: columns: { driveId: true, title: true }, with: { drive: { - columns: { ownerId: true }, + columns: { ownerId: true, name: true, slug: true }, }, }, }); + if (messageContent.trim().length > 0) { + void import('@/lib/channels/agent-mention-responder') + .then(({ triggerMentionedAgentResponses }) => + triggerMentionedAgentResponses({ + userId, + channelId: pageId, + channelTitle: channel?.title || 'Channel', + channelType: 'CHANNEL', + sourceMessageId: createdMessage.id, + content: messageContent, + driveId: channel?.driveId || null, + driveName: channel?.drive?.name || null, + driveSlug: channel?.drive?.slug || null, + }) + ) + .catch((error) => { + loggers.realtime.error('Failed to load channel mention responder module:', error as Error); + }); + } + if (channel?.driveId) { // Get all drive members const members = await db.query.driveMembers.findMany({ @@ -200,9 +221,9 @@ export async function POST(req: Request, { params }: { params: Promise<{ pageId: } // Create message preview - const messagePreview = content.length > 100 - ? content.substring(0, 100) + '...' - : content; + const messagePreview = messageContent.length > 100 + ? messageContent.substring(0, 100) + '...' + : messageContent; // Filter to members with view permission and broadcast // Check permissions in parallel for efficiency @@ -225,7 +246,7 @@ export async function POST(req: Request, { params }: { params: Promise<{ pageId: driveId: channel.driveId, lastMessageAt: newMessage?.createdAt?.toISOString() || new Date().toISOString(), lastMessagePreview: messagePreview, - lastMessageSender: newMessage?.user?.name || undefined, + lastMessageSender: newMessage?.aiMeta?.senderName || newMessage?.user?.name || undefined, }) ); @@ -245,4 +266,4 @@ export async function POST(req: Request, { params }: { params: Promise<{ pageId: } return NextResponse.json(newMessage, { status: 201 }); -} \ No newline at end of file +} diff --git a/apps/web/src/app/api/inbox/route.ts b/apps/web/src/app/api/inbox/route.ts index 0362cc49b7..867dccc42f 100644 --- a/apps/web/src/app/api/inbox/route.ts +++ b/apps/web/src/app/api/inbox/route.ts @@ -57,7 +57,7 @@ export async function GET(request: Request) { cm."pageId", cm.content as last_message, cm."createdAt" as last_message_at, - u.name as sender_name + COALESCE(cm."aiMeta"->>'senderName', u.name) as sender_name FROM channel_messages cm INNER JOIN drive_channels dc ON dc.id = cm."pageId" LEFT JOIN users u ON u.id = cm."userId" @@ -69,7 +69,10 @@ export async function GET(request: Request) { LEFT JOIN channel_read_status crs ON crs."channelId" = cm."pageId" AND crs."userId" = ${userId} WHERE cm."createdAt" > COALESCE(crs."lastReadAt", '1970-01-01'::timestamp) - AND cm."userId" != ${userId} + AND ( + cm."userId" != ${userId} + OR cm."aiMeta"->>'senderType' = 'agent' + ) GROUP BY cm."pageId" ) SELECT @@ -217,7 +220,7 @@ export async function GET(request: Request) { cm."pageId", cm.content as last_message, cm."createdAt" as last_message_at, - u.name as sender_name + COALESCE(cm."aiMeta"->>'senderName', u.name) as sender_name FROM channel_messages cm INNER JOIN user_channels uc ON uc.id = cm."pageId" LEFT JOIN users u ON u.id = cm."userId" @@ -229,7 +232,10 @@ export async function GET(request: Request) { LEFT JOIN channel_read_status crs ON crs."channelId" = cm."pageId" AND crs."userId" = ${userId} WHERE cm."createdAt" > COALESCE(crs."lastReadAt", '1970-01-01'::timestamp) - AND cm."userId" != ${userId} + AND ( + cm."userId" != ${userId} + OR cm."aiMeta"->>'senderType' = 'agent' + ) GROUP BY cm."pageId" ) SELECT diff --git a/apps/web/src/app/dashboard/inbox/channel/[pageId]/page.tsx b/apps/web/src/app/dashboard/inbox/channel/[pageId]/page.tsx index 00efecb9ed..5da96f8f00 100644 --- a/apps/web/src/app/dashboard/inbox/channel/[pageId]/page.tsx +++ b/apps/web/src/app/dashboard/inbox/channel/[pageId]/page.tsx @@ -56,6 +56,11 @@ interface MessageWithUser { fileId?: string | null; attachmentMeta?: AttachmentMeta | null; file?: FileRelation | null; + aiMeta?: { + senderType: 'global_assistant' | 'agent'; + senderName: string; + agentPageId?: string; + } | null; } interface Page { @@ -394,15 +399,34 @@ export default function InboxChannelPage() {
- {messages.map((m) => ( + {messages.map((m) => { + const isAi = !!m.aiMeta; + const displayName = isAi ? m.aiMeta!.senderName : m.user?.name; + const aiLabel = isAi + ? m.aiMeta!.senderType === 'global_assistant' + ? 'global assistant' + : 'agent' + : null; + const avatarFallback = isAi + ? m.aiMeta!.senderType === 'agent' + ? 'A' + : m.aiMeta!.senderName?.[0] + : m.user?.name?.[0]; + + return (
- - {m.user?.name?.[0]} + {!isAi && } + {avatarFallback}
- {m.user?.name} + {displayName} + {aiLabel && ( + + {aiLabel} + + )} {new Date(m.createdAt).toLocaleTimeString()} @@ -471,7 +495,8 @@ export default function InboxChannelPage() { )}
- ))} + ); + })}
diff --git a/apps/web/src/lib/ai/tools/__tests__/channel-tools.test.ts b/apps/web/src/lib/ai/tools/__tests__/channel-tools.test.ts index 51be14baa3..60b77766ad 100644 --- a/apps/web/src/lib/ai/tools/__tests__/channel-tools.test.ts +++ b/apps/web/src/lib/ai/tools/__tests__/channel-tools.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; /** * Channel Tools Tests @@ -14,6 +14,7 @@ vi.mock('@pagespace/db', () => ({ query: { channelMessages: { findFirst: vi.fn() }, pages: { findFirst: vi.fn() }, + driveMembers: { findMany: vi.fn() }, }, insert: vi.fn().mockReturnValue({ values: vi.fn().mockReturnValue({ @@ -24,6 +25,7 @@ vi.mock('@pagespace/db', () => ({ }, channelMessages: {}, channelReadStatus: { userId: 'userId', channelId: 'channelId' }, + driveMembers: { driveId: 'driveId' }, pages: { id: 'id', isTrashed: 'isTrashed' }, eq: vi.fn(), and: vi.fn(), @@ -71,15 +73,20 @@ vi.mock('@/lib/logging/mask', () => ({ })); import { channelTools } from '../channel-tools'; -import { canUserEditPage } from '@pagespace/lib/permissions'; +import { canUserEditPage, canUserViewPage } from '@pagespace/lib/permissions'; import { getActorInfo } from '@pagespace/lib/server'; import { db } from '@pagespace/db'; +import { broadcastInboxEvent } from '@/lib/websocket/socket-utils'; import type { ToolExecutionContext } from '../../core'; const mockCanUserEditPage = vi.mocked(canUserEditPage); +const mockCanUserViewPage = vi.mocked(canUserViewPage); const mockGetActorInfo = vi.mocked(getActorInfo); -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const mockPagesFindFirst = db.query.pages.findFirst as any; +const mockBroadcastInboxEvent = vi.mocked(broadcastInboxEvent); +const mockDbInsert = db.insert as unknown as Mock; +const mockPagesFindFirst = db.query.pages.findFirst as unknown as Mock; +const mockChannelMessagesFindFirst = db.query.channelMessages.findFirst as unknown as Mock; +const mockDriveMembersFindMany = db.query.driveMembers.findMany as unknown as Mock; // Helper to safely extract result from tool execution (handles AsyncIterable union) type ToolResult = Record; @@ -92,6 +99,20 @@ const executeToolAs = async ( describe('channel-tools', () => { beforeEach(() => { vi.clearAllMocks(); + mockGetActorInfo.mockResolvedValue({ + actorEmail: 'test@example.com', + actorDisplayName: 'Test User', + }); + mockCanUserViewPage.mockResolvedValue(true); + mockChannelMessagesFindFirst.mockResolvedValue({ + id: 'msg-1', + createdAt: new Date('2026-02-10T12:00:00.000Z'), + user: { id: 'user-123', name: 'Alice', image: null }, + file: null, + reactions: [], + }); + mockDriveMembersFindMany.mockResolvedValue([]); + mockBroadcastInboxEvent.mockResolvedValue(undefined); }); describe('send_channel_message', () => { @@ -226,7 +247,7 @@ describe('channel-tools', () => { expect(result.messagePreview).toBe('Hello from assistant'); }); - it('sends message as page agent with agent title', async () => { + it('sends message as page agent with agent + user display name', async () => { mockPagesFindFirst.mockResolvedValue({ id: 'ch-1', title: 'General', @@ -260,10 +281,93 @@ describe('channel-tools', () => { ); expect(result.success).toBe(true); - expect(result.senderName).toBe('Budget Analyst'); + expect(result.senderName).toBe('Budget Analyst (Test User)'); expect(result.senderType).toBe('agent'); }); + it('marks global assistant messages as read and skips sender inbox broadcast', async () => { + mockPagesFindFirst.mockResolvedValue({ + id: 'ch-1', + title: 'General', + type: 'CHANNEL', + driveId: 'drive-1', + drive: { ownerId: 'user-456' }, + }); + mockDriveMembersFindMany.mockResolvedValue([ + { userId: 'user-123' }, + { userId: 'user-456' }, + ]); + mockCanUserEditPage.mockResolvedValue(true); + mockGetActorInfo.mockResolvedValue({ + actorEmail: 'alice@example.com', + actorDisplayName: 'Alice', + }); + + const context = { + toolCallId: '1', messages: [], + experimental_context: { + userId: 'user-123', + chatSource: { type: 'global' }, + } as ToolExecutionContext, + }; + + const result = await executeToolAs( + { channelId: 'ch-1', content: 'Global update' }, + context + ); + + expect(result.success).toBe(true); + expect(mockDbInsert).toHaveBeenCalledTimes(2); + expect(mockBroadcastInboxEvent).toHaveBeenCalledTimes(1); + expect(mockBroadcastInboxEvent).toHaveBeenCalledWith( + 'user-456', + expect.objectContaining({ + operation: 'channel_updated', + id: 'ch-1', + }) + ); + }); + + it('keeps agent messages unread for requester and includes requester in inbox broadcast', async () => { + mockPagesFindFirst.mockResolvedValue({ + id: 'ch-1', + title: 'General', + type: 'CHANNEL', + driveId: 'drive-1', + drive: { ownerId: 'user-456' }, + }); + mockDriveMembersFindMany.mockResolvedValue([ + { userId: 'user-123' }, + { userId: 'user-456' }, + ]); + mockCanUserEditPage.mockResolvedValue(true); + + const context = { + toolCallId: '1', messages: [], + experimental_context: { + userId: 'user-123', + chatSource: { + type: 'page', + agentPageId: 'agent-1', + agentTitle: 'Budget Analyst', + }, + } as ToolExecutionContext, + }; + + const result = await executeToolAs( + { channelId: 'ch-1', content: 'Agent follow-up' }, + context + ); + + expect(result.success).toBe(true); + expect(mockDbInsert).toHaveBeenCalledTimes(1); + expect(mockBroadcastInboxEvent).toHaveBeenCalledTimes(2); + + const recipients = mockBroadcastInboxEvent.mock.calls.map(([recipient]) => recipient); + expect(recipients).toContain('user-123'); + expect(recipients).toContain('user-456'); + }); + it('defaults to global_assistant when chatSource is not provided', async () => { mockPagesFindFirst.mockResolvedValue({ id: 'ch-1', diff --git a/apps/web/src/lib/ai/tools/channel-tools.ts b/apps/web/src/lib/ai/tools/channel-tools.ts index bc7854c99b..c92423dc72 100644 --- a/apps/web/src/lib/ai/tools/channel-tools.ts +++ b/apps/web/src/lib/ai/tools/channel-tools.ts @@ -18,26 +18,27 @@ const channelLogger = loggers.ai.child({ module: 'channel-tools' }); * Resolve sender identity for AI-generated channel messages. * * Global assistant: uses the user's display name + 'global_assistant' type - * Page agent: uses the agent page title + 'agent' type + * Page agent: uses "agent title (user display name)" + 'agent' type */ const resolveSenderIdentity = async ( context: ToolExecutionContext ): Promise<{ senderType: 'global_assistant' | 'agent'; senderName: string; agentPageId?: string }> => { const { chatSource } = context; + const actorInfo = await getActorInfo(context.userId); + const actorDisplayName = actorInfo.actorDisplayName ?? 'User'; if (chatSource?.type === 'page' && chatSource.agentTitle) { return { senderType: 'agent', - senderName: chatSource.agentTitle, + senderName: `${chatSource.agentTitle} (${actorDisplayName})`, agentPageId: chatSource.agentPageId, }; } - // Global assistant or unknown — look up the user's display name - const actorInfo = await getActorInfo(context.userId); + // Global assistant or unknown — use the user's display name return { senderType: 'global_assistant', - senderName: actorInfo.actorDisplayName ?? 'Assistant', + senderName: actorDisplayName, }; }; @@ -93,6 +94,7 @@ export const channelTools = { // Determine sender identity const senderIdentity = await resolveSenderIdentity(context as ToolExecutionContext); + const treatAsSelfAuthored = senderIdentity.senderType === 'global_assistant'; // Insert the message const [createdMessage] = await db @@ -109,14 +111,17 @@ export const channelTools = { }) .returning(); - // Update sender's read status - await db - .insert(channelReadStatus) - .values({ userId, channelId, lastReadAt: new Date() }) - .onConflictDoUpdate({ - target: [channelReadStatus.userId, channelReadStatus.channelId], - set: { lastReadAt: new Date() }, - }); + // Only mark sender read for self-authored global assistant messages. + // Agent messages should behave like third-party messages for unread/inbox semantics. + if (treatAsSelfAuthored) { + await db + .insert(channelReadStatus) + .values({ userId, channelId, lastReadAt: new Date() }) + .onConflictDoUpdate({ + target: [channelReadStatus.userId, channelReadStatus.channelId], + set: { lastReadAt: new Date() }, + }); + } // Log activity for audit trail (fire-and-forget) const toolContext = context as ToolExecutionContext; @@ -203,7 +208,7 @@ export const channelTools = { const memberPermissions = await Promise.all( members - .filter(m => m.userId !== userId) + .filter(m => !treatAsSelfAuthored || m.userId !== userId) .map(async member => ({ userId: member.userId, canView: await canUserViewPage(member.userId, channelId), diff --git a/apps/web/src/lib/channels/__tests__/agent-mention-responder.test.ts b/apps/web/src/lib/channels/__tests__/agent-mention-responder.test.ts new file mode 100644 index 0000000000..4352bcee2e --- /dev/null +++ b/apps/web/src/lib/channels/__tests__/agent-mention-responder.test.ts @@ -0,0 +1,269 @@ +import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; + +vi.mock('@pagespace/db', () => ({ + db: { + query: { + pages: { findMany: vi.fn() }, + channelMessages: { findMany: vi.fn() }, + }, + }, + pages: { id: 'id', type: 'type', isTrashed: 'isTrashed' }, + channelMessages: { pageId: 'pageId', isActive: 'isActive', createdAt: 'createdAt' }, + and: vi.fn(), + eq: vi.fn(), + inArray: vi.fn(), + desc: vi.fn(), +})); + +vi.mock('@pagespace/lib/server', () => ({ + canUserViewPage: vi.fn(), + loggers: { + ai: { + debug: vi.fn(), + child: vi.fn(() => ({ + warn: vi.fn(), + error: vi.fn(), + })), + }, + }, +})); + +vi.mock('@/lib/ai/tools/agent-communication-tools', () => ({ + agentCommunicationTools: { + ask_agent: { + execute: vi.fn(), + }, + }, +})); + +vi.mock('@/lib/ai/tools/channel-tools', () => ({ + channelTools: { + send_channel_message: { + execute: vi.fn(), + }, + }, +})); + +import { db } from '@pagespace/db'; +import { canUserViewPage } from '@pagespace/lib/server'; +import { agentCommunicationTools } from '@/lib/ai/tools/agent-communication-tools'; +import { channelTools } from '@/lib/ai/tools/channel-tools'; +import { + triggerMentionedAgentResponses, + type TriggerMentionedAgentResponsesParams, +} from '../agent-mention-responder'; + +const mockPagesFindMany = db.query.pages.findMany as unknown as Mock; +const mockChannelMessagesFindMany = db.query.channelMessages.findMany as unknown as Mock; +const mockCanUserViewPage = vi.mocked(canUserViewPage); + +const askAgentExecute = agentCommunicationTools.ask_agent.execute; +const sendChannelExecute = channelTools.send_channel_message.execute; + +if (!askAgentExecute || !sendChannelExecute) { + throw new Error('Agent mention responder tool mocks are unavailable'); +} + +const mockAskAgentExecute = askAgentExecute as unknown as Mock; +const mockSendChannelExecute = sendChannelExecute as unknown as Mock; + +const createAskAgentSuccess = (response: string) => ({ + success: true, + agent: 'Budget Agent', + agentPath: '/Budget Agent', + question: 'What do you think?', + response, + context: undefined, + conversationId: 'channel:channel-1:agent:agent-1', + metadata: { + agentId: 'agent-1', + processingTime: 42, + persistent: true, + isNewConversation: false, + callDepth: 1, + provider: 'PageSpace', + model: 'Default (Free)', + toolsEnabled: 1, + toolCalls: 0, + steps: 1, + }, +}); + +const createAskAgentFailure = (error: string) => ({ + success: false, + agent: '/Budget Agent', + error, + question: 'What do you think?', + context: undefined, + metadata: { + processingTime: 42, + callDepth: 1, + }, +}); + +const createSendChannelSuccess = () => ({ + success: true, + messageId: 'msg-agent-1', + channelId: 'channel-1', + channelTitle: 'General', + senderName: 'Budget Agent (Alice)', + senderType: 'agent' as const, + messagePreview: 'Agent reply', + message: 'Successfully sent message to channel "General"', + summary: 'Posted to #General as Budget Agent (Alice) (agent)', +}); + +const baseParams: TriggerMentionedAgentResponsesParams = { + userId: 'user-1', + channelId: 'channel-1', + channelTitle: 'General', + channelType: 'CHANNEL', + sourceMessageId: 'msg-1', + content: 'Hello', + driveId: 'drive-1', + driveName: 'Workspace', + driveSlug: 'workspace', +}; + +describe('agent-mention-responder', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockPagesFindMany.mockResolvedValue([]); + mockChannelMessagesFindMany.mockResolvedValue([]); + mockCanUserViewPage.mockResolvedValue(true); + mockAskAgentExecute.mockResolvedValue(createAskAgentSuccess('Agent reply')); + mockSendChannelExecute.mockResolvedValue(createSendChannelSuccess()); + }); + + it('does nothing when no structured mentions are present', async () => { + await triggerMentionedAgentResponses({ + ...baseParams, + content: 'No mentions here', + }); + + expect(mockPagesFindMany).not.toHaveBeenCalled(); + expect(mockAskAgentExecute).not.toHaveBeenCalled(); + expect(mockSendChannelExecute).not.toHaveBeenCalled(); + }); + + it('consults and posts response for mentioned AI agent', async () => { + mockPagesFindMany.mockResolvedValue([ + { id: 'agent-1', title: 'Budget Agent', enabledTools: ['send_channel_message'] }, + ]); + mockChannelMessagesFindMany.mockResolvedValue([ + { + content: 'Earlier thread context', + createdAt: new Date('2026-02-10T09:00:00.000Z'), + aiMeta: null, + user: { name: 'Alice' }, + }, + ]); + mockAskAgentExecute.mockResolvedValue( + createAskAgentSuccess('I think this conversation is on track.') + ); + + await triggerMentionedAgentResponses({ + ...baseParams, + content: 'What do you think of this convo @[Budget Agent](agent-1:page)', + }); + + expect(mockAskAgentExecute).toHaveBeenCalledTimes(1); + expect(mockAskAgentExecute).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: 'agent-1', + conversationId: 'channel:channel-1:agent:agent-1', + }), + expect.objectContaining({ + experimental_context: expect.objectContaining({ + userId: 'user-1', + locationContext: expect.objectContaining({ + currentPage: expect.objectContaining({ + id: 'channel-1', + }), + }), + }), + }) + ); + + expect(mockSendChannelExecute).toHaveBeenCalledTimes(1); + expect(mockSendChannelExecute).toHaveBeenCalledWith( + { + channelId: 'channel-1', + content: 'I think this conversation is on track.', + }, + expect.objectContaining({ + experimental_context: expect.objectContaining({ + chatSource: { + type: 'page', + agentPageId: 'agent-1', + agentTitle: 'Budget Agent', + }, + }), + }) + ); + }); + + it('skips when structured mention does not resolve to an active AI agent', async () => { + mockPagesFindMany.mockResolvedValue([]); + + await triggerMentionedAgentResponses({ + ...baseParams, + content: 'Check this @[Regular Page](page-123:page)', + }); + + expect(mockPagesFindMany).toHaveBeenCalledTimes(1); + expect(mockAskAgentExecute).not.toHaveBeenCalled(); + expect(mockSendChannelExecute).not.toHaveBeenCalled(); + }); + + it('deduplicates repeated mentions and skips agents without view access', async () => { + mockPagesFindMany.mockResolvedValue([ + { id: 'agent-1', title: 'Budget Agent', enabledTools: ['send_channel_message'] }, + { id: 'agent-2', title: 'Ops Agent', enabledTools: ['send_channel_message'] }, + ]); + mockCanUserViewPage.mockImplementation(async (_userId, pageId) => pageId === 'agent-1'); + + await triggerMentionedAgentResponses({ + ...baseParams, + content: + 'Ping @[Budget Agent](agent-1:page) and @[Budget Agent](agent-1:page) and @[Ops Agent](agent-2:page)', + }); + + expect(mockCanUserViewPage).toHaveBeenCalledTimes(2); + expect(mockAskAgentExecute).toHaveBeenCalledTimes(1); + expect(mockAskAgentExecute).toHaveBeenCalledWith( + expect.objectContaining({ agentId: 'agent-1' }), + expect.any(Object) + ); + expect(mockSendChannelExecute).toHaveBeenCalledTimes(1); + }); + + it('does not post when ask_agent returns failure', async () => { + mockPagesFindMany.mockResolvedValue([ + { id: 'agent-1', title: 'Budget Agent', enabledTools: ['send_channel_message'] }, + ]); + mockAskAgentExecute.mockResolvedValue(createAskAgentFailure('Agent failed')); + + await triggerMentionedAgentResponses({ + ...baseParams, + content: 'Need input @[Budget Agent](agent-1:page)', + }); + + expect(mockAskAgentExecute).toHaveBeenCalledTimes(1); + expect(mockSendChannelExecute).not.toHaveBeenCalled(); + }); + + it('skips agent replies when send_channel_message is not enabled', async () => { + mockPagesFindMany.mockResolvedValue([ + { id: 'agent-1', title: 'Budget Agent', enabledTools: ['list_pages'] }, + ]); + + await triggerMentionedAgentResponses({ + ...baseParams, + content: 'Need input @[Budget Agent](agent-1:page)', + }); + + expect(mockAskAgentExecute).not.toHaveBeenCalled(); + expect(mockSendChannelExecute).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/lib/channels/agent-mention-responder.ts b/apps/web/src/lib/channels/agent-mention-responder.ts new file mode 100644 index 0000000000..fca7c37637 --- /dev/null +++ b/apps/web/src/lib/channels/agent-mention-responder.ts @@ -0,0 +1,318 @@ +import { + and, + channelMessages, + db, + desc, + eq, + inArray, + pages, +} from '@pagespace/db'; +import { canUserViewPage, loggers } from '@pagespace/lib/server'; +import { processMentionsInMessage } from '@/lib/ai/core/mention-processor'; +import type { ToolExecutionContext } from '@/lib/ai/core'; + +const channelMentionLogger = loggers.ai.child({ module: 'channel-agent-mentions' }); + +const CONTEXT_MESSAGE_LIMIT = 12; +const MESSAGE_SNIPPET_LIMIT = 320; +const TRANSCRIPT_CHAR_LIMIT = 5000; + +interface MentionedAgent { + id: string; + title: string; + enabledTools: string[] | null; +} + +export interface TriggerMentionedAgentResponsesParams { + userId: string; + channelId: string; + channelTitle: string; + channelType?: string; + sourceMessageId: string; + content: string; + driveId?: string | null; + driveName?: string | null; + driveSlug?: string | null; +} + +interface AskAgentResult { + success?: boolean; + response?: string; + error?: string; +} + +function convertMentionsToDisplayText(content: string): string { + return content.replace( + /@\[([^\]]{1,500})\]\(([^:)]{1,200}):([^)]{1,200})\)/g, + (_match, label: string) => `@${label}` + ); +} + +function toSingleLine(text: string, limit: number): string { + const normalized = text.replace(/\s+/g, ' ').trim(); + if (normalized.length <= limit) { + return normalized; + } + return `${normalized.slice(0, limit)}...`; +} + +function buildChannelTranscript( + messages: Array<{ + content: string; + createdAt: Date; + user: { name: string | null } | null; + aiMeta: { senderName: string } | null; + }> +): string { + if (messages.length === 0) { + return 'No prior channel messages.'; + } + + const lines = messages.map((message) => { + const senderName = message.aiMeta?.senderName || message.user?.name || 'Unknown'; + const timestamp = message.createdAt.toISOString(); + const displayContent = toSingleLine( + convertMentionsToDisplayText(message.content || ''), + MESSAGE_SNIPPET_LIMIT + ); + return `- [${timestamp}] ${senderName}: ${displayContent}`; + }); + + const transcript = lines.join('\n'); + if (transcript.length <= TRANSCRIPT_CHAR_LIMIT) { + return transcript; + } + + return `${transcript.slice(0, TRANSCRIPT_CHAR_LIMIT)}\n...`; +} + +function buildLocationContext(params: TriggerMentionedAgentResponsesParams): ToolExecutionContext['locationContext'] { + return { + currentPage: { + id: params.channelId, + title: params.channelTitle, + type: params.channelType || 'CHANNEL', + path: `/channel/${params.channelId}`, + }, + currentDrive: params.driveId + ? { + id: params.driveId, + name: params.driveName || 'Unknown Drive', + slug: params.driveSlug || params.driveId, + } + : undefined, + }; +} + +async function resolveMentionedAgents(content: string): Promise { + const processed = processMentionsInMessage(content); + if (processed.mentions.length === 0) { + return []; + } + + const mentionOrder: string[] = []; + const seen = new Set(); + for (const mention of processed.mentions) { + if (!seen.has(mention.id)) { + seen.add(mention.id); + mentionOrder.push(mention.id); + } + } + + if (mentionOrder.length === 0) { + return []; + } + + const pagesById = await db.query.pages.findMany({ + where: and( + inArray(pages.id, mentionOrder), + eq(pages.type, 'AI_CHAT'), + eq(pages.isTrashed, false) + ), + columns: { + id: true, + title: true, + enabledTools: true, + }, + }); + + if (pagesById.length === 0) { + return []; + } + + const pageLookup = new Map(pagesById.map((page) => [page.id, page])); + const orderedAgents: MentionedAgent[] = []; + + for (const pageId of mentionOrder) { + const page = pageLookup.get(pageId); + if (!page) { + continue; + } + orderedAgents.push({ + id: page.id, + title: page.title || 'Agent', + enabledTools: Array.isArray(page.enabledTools) ? page.enabledTools : null, + }); + } + + return orderedAgents; +} + +function canAgentSendChannelMessages(enabledTools: string[] | null): boolean { + return Array.isArray(enabledTools) && enabledTools.includes('send_channel_message'); +} + +export async function triggerMentionedAgentResponses( + params: TriggerMentionedAgentResponsesParams +): Promise { + try { + if (!params.content || !params.content.trim()) { + return; + } + + const [{ agentCommunicationTools }, { channelTools }] = await Promise.all([ + import('@/lib/ai/tools/agent-communication-tools'), + import('@/lib/ai/tools/channel-tools'), + ]); + + const askAgentExecute = agentCommunicationTools.ask_agent.execute; + const sendChannelExecute = channelTools.send_channel_message.execute; + + if (!askAgentExecute || !sendChannelExecute) { + channelMentionLogger.warn('Agent mention responder tools are unavailable'); + return; + } + + const mentionedAgents = await resolveMentionedAgents(params.content); + if (mentionedAgents.length === 0) { + return; + } + + const eligibleAgentChecks = await Promise.all( + mentionedAgents.map(async (agent) => ({ + agent, + canView: await canUserViewPage(params.userId, agent.id), + canSend: canAgentSendChannelMessages(agent.enabledTools), + })) + ); + + const eligibleAgents = eligibleAgentChecks + .filter((entry) => entry.canView && entry.canSend) + .map((entry) => entry.agent); + + if (eligibleAgents.length === 0) { + return; + } + + const recentMessages = await db.query.channelMessages.findMany({ + where: and( + eq(channelMessages.pageId, params.channelId), + eq(channelMessages.isActive, true) + ), + columns: { + content: true, + createdAt: true, + aiMeta: true, + }, + with: { + user: { + columns: { + name: true, + }, + }, + }, + orderBy: [desc(channelMessages.createdAt)], + limit: CONTEXT_MESSAGE_LIMIT, + }); + + const contextMessages = [...recentMessages].reverse(); + const transcript = buildChannelTranscript(contextMessages); + const question = toSingleLine( + convertMentionsToDisplayText(params.content), + MESSAGE_SNIPPET_LIMIT + ); + const locationContext = buildLocationContext(params); + + for (const agent of eligibleAgents) { + try { + const mentionConversationId = `channel:${params.channelId}:agent:${agent.id}`; + const askResult = (await askAgentExecute( + { + agentPath: `/${agent.title}`, + agentId: agent.id, + question, + context: [ + `You were mentioned in the channel "${params.channelTitle}".`, + 'Respond directly to the latest request and use recent channel context when relevant.', + '', + 'Recent channel transcript (oldest to newest):', + transcript, + ].join('\n'), + conversationId: mentionConversationId, + }, + { + toolCallId: `channel-mention-ask-${params.sourceMessageId}-${agent.id}`, + messages: [], + experimental_context: { + userId: params.userId, + conversationId: mentionConversationId, + locationContext, + requestOrigin: 'user', + agentCallDepth: 0, + } as ToolExecutionContext, + } + )) as AskAgentResult; + + if (!askResult.success || !askResult.response || !askResult.response.trim()) { + channelMentionLogger.warn('Mentioned agent returned no response', { + channelId: params.channelId, + agentId: agent.id, + error: askResult.error, + }); + continue; + } + + await sendChannelExecute( + { + channelId: params.channelId, + content: askResult.response.trim(), + }, + { + toolCallId: `channel-mention-send-${params.sourceMessageId}-${agent.id}`, + messages: [], + experimental_context: { + userId: params.userId, + conversationId: mentionConversationId, + locationContext, + requestOrigin: 'agent', + chatSource: { + type: 'page', + agentPageId: agent.id, + agentTitle: agent.title, + }, + } as ToolExecutionContext, + } + ); + } catch (error) { + channelMentionLogger.error( + 'Failed to generate or post mentioned agent response', + error instanceof Error ? error : undefined, + { + channelId: params.channelId, + sourceMessageId: params.sourceMessageId, + agentId: agent.id, + } + ); + } + } + } catch (error) { + channelMentionLogger.error( + 'Failed to process channel agent mentions', + error instanceof Error ? error : undefined, + { + channelId: params.channelId, + sourceMessageId: params.sourceMessageId, + } + ); + } +}