Skip to content

Commit e162abf

Browse files
2witstudiosclaude
andauthored
[web] Add channel agent mention replies and clear sender names (#531)
* [web] Add channel agent mention replies and sender names * [web] Fix agent channel unread semantics and test typing * [web] Fix flaky agent mention responder tests * [web] Lazy-load channel mention responder to fix build * fix(web): Replace Extract types with explicit interfaces in test The mocked tool functions return `unknown`, causing Extract<unknown, T> to resolve to `never`. Define explicit interfaces for test result types. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(web): Simplify test mock types to avoid union type mismatch Use `as unknown as Mock` pattern instead of `vi.mocked()` to avoid strict type checking that expects full union including AsyncIterable. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 2b35bc9 commit e162abf

7 files changed

Lines changed: 784 additions & 36 deletions

File tree

apps/web/src/app/api/channels/[pageId]/messages/route.ts

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ export async function POST(req: Request, { params }: { params: Promise<{ pageId:
8787
fileId?: string;
8888
attachmentMeta?: AttachmentMeta;
8989
};
90+
const messageContent = typeof content === 'string' ? content : '';
9091

9192
// Debug: Check what content type is being received
9293
loggers.realtime.debug('API received content type:', { type: typeof content });
@@ -105,7 +106,7 @@ export async function POST(req: Request, { params }: { params: Promise<{ pageId:
105106
const [createdMessage] = await db.insert(channelMessages).values({
106107
pageId: pageId,
107108
userId: userId,
108-
content,
109+
content: messageContent,
109110
fileId: fileId || null,
110111
attachmentMeta: attachmentMeta || null,
111112
}).returning();
@@ -180,11 +181,31 @@ export async function POST(req: Request, { params }: { params: Promise<{ pageId:
180181
columns: { driveId: true, title: true },
181182
with: {
182183
drive: {
183-
columns: { ownerId: true },
184+
columns: { ownerId: true, name: true, slug: true },
184185
},
185186
},
186187
});
187188

189+
if (messageContent.trim().length > 0) {
190+
void import('@/lib/channels/agent-mention-responder')
191+
.then(({ triggerMentionedAgentResponses }) =>
192+
triggerMentionedAgentResponses({
193+
userId,
194+
channelId: pageId,
195+
channelTitle: channel?.title || 'Channel',
196+
channelType: 'CHANNEL',
197+
sourceMessageId: createdMessage.id,
198+
content: messageContent,
199+
driveId: channel?.driveId || null,
200+
driveName: channel?.drive?.name || null,
201+
driveSlug: channel?.drive?.slug || null,
202+
})
203+
)
204+
.catch((error) => {
205+
loggers.realtime.error('Failed to load channel mention responder module:', error as Error);
206+
});
207+
}
208+
188209
if (channel?.driveId) {
189210
// Get all drive members
190211
const members = await db.query.driveMembers.findMany({
@@ -200,9 +221,9 @@ export async function POST(req: Request, { params }: { params: Promise<{ pageId:
200221
}
201222

202223
// Create message preview
203-
const messagePreview = content.length > 100
204-
? content.substring(0, 100) + '...'
205-
: content;
224+
const messagePreview = messageContent.length > 100
225+
? messageContent.substring(0, 100) + '...'
226+
: messageContent;
206227

207228
// Filter to members with view permission and broadcast
208229
// Check permissions in parallel for efficiency
@@ -225,7 +246,7 @@ export async function POST(req: Request, { params }: { params: Promise<{ pageId:
225246
driveId: channel.driveId,
226247
lastMessageAt: newMessage?.createdAt?.toISOString() || new Date().toISOString(),
227248
lastMessagePreview: messagePreview,
228-
lastMessageSender: newMessage?.user?.name || undefined,
249+
lastMessageSender: newMessage?.aiMeta?.senderName || newMessage?.user?.name || undefined,
229250
})
230251
);
231252

@@ -245,4 +266,4 @@ export async function POST(req: Request, { params }: { params: Promise<{ pageId:
245266
}
246267

247268
return NextResponse.json(newMessage, { status: 201 });
248-
}
269+
}

apps/web/src/app/api/inbox/route.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export async function GET(request: Request) {
5757
cm."pageId",
5858
cm.content as last_message,
5959
cm."createdAt" as last_message_at,
60-
u.name as sender_name
60+
COALESCE(cm."aiMeta"->>'senderName', u.name) as sender_name
6161
FROM channel_messages cm
6262
INNER JOIN drive_channels dc ON dc.id = cm."pageId"
6363
LEFT JOIN users u ON u.id = cm."userId"
@@ -69,7 +69,10 @@ export async function GET(request: Request) {
6969
LEFT JOIN channel_read_status crs
7070
ON crs."channelId" = cm."pageId" AND crs."userId" = ${userId}
7171
WHERE cm."createdAt" > COALESCE(crs."lastReadAt", '1970-01-01'::timestamp)
72-
AND cm."userId" != ${userId}
72+
AND (
73+
cm."userId" != ${userId}
74+
OR cm."aiMeta"->>'senderType' = 'agent'
75+
)
7376
GROUP BY cm."pageId"
7477
)
7578
SELECT
@@ -217,7 +220,7 @@ export async function GET(request: Request) {
217220
cm."pageId",
218221
cm.content as last_message,
219222
cm."createdAt" as last_message_at,
220-
u.name as sender_name
223+
COALESCE(cm."aiMeta"->>'senderName', u.name) as sender_name
221224
FROM channel_messages cm
222225
INNER JOIN user_channels uc ON uc.id = cm."pageId"
223226
LEFT JOIN users u ON u.id = cm."userId"
@@ -229,7 +232,10 @@ export async function GET(request: Request) {
229232
LEFT JOIN channel_read_status crs
230233
ON crs."channelId" = cm."pageId" AND crs."userId" = ${userId}
231234
WHERE cm."createdAt" > COALESCE(crs."lastReadAt", '1970-01-01'::timestamp)
232-
AND cm."userId" != ${userId}
235+
AND (
236+
cm."userId" != ${userId}
237+
OR cm."aiMeta"->>'senderType' = 'agent'
238+
)
233239
GROUP BY cm."pageId"
234240
)
235241
SELECT

apps/web/src/app/dashboard/inbox/channel/[pageId]/page.tsx

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ interface MessageWithUser {
5656
fileId?: string | null;
5757
attachmentMeta?: AttachmentMeta | null;
5858
file?: FileRelation | null;
59+
aiMeta?: {
60+
senderType: 'global_assistant' | 'agent';
61+
senderName: string;
62+
agentPageId?: string;
63+
} | null;
5964
}
6065

6166
interface Page {
@@ -394,15 +399,34 @@ export default function InboxChannelPage() {
394399
<PullToRefresh direction="top" onRefresh={handleRefresh}>
395400
<ScrollArea className="h-full flex-grow" ref={scrollAreaRef}>
396401
<div className="p-4 space-y-4 max-w-4xl mx-auto">
397-
{messages.map((m) => (
402+
{messages.map((m) => {
403+
const isAi = !!m.aiMeta;
404+
const displayName = isAi ? m.aiMeta!.senderName : m.user?.name;
405+
const aiLabel = isAi
406+
? m.aiMeta!.senderType === 'global_assistant'
407+
? 'global assistant'
408+
: 'agent'
409+
: null;
410+
const avatarFallback = isAi
411+
? m.aiMeta!.senderType === 'agent'
412+
? 'A'
413+
: m.aiMeta!.senderName?.[0]
414+
: m.user?.name?.[0];
415+
416+
return (
398417
<div key={m.id} className="group flex items-start gap-4">
399418
<Avatar className="shrink-0">
400-
<AvatarImage src={m.user?.image || ''} />
401-
<AvatarFallback>{m.user?.name?.[0]}</AvatarFallback>
419+
{!isAi && <AvatarImage src={m.user?.image || ''} />}
420+
<AvatarFallback>{avatarFallback}</AvatarFallback>
402421
</Avatar>
403422
<div className="flex flex-col min-w-0 flex-1">
404423
<div className="flex items-center gap-2">
405-
<span className="font-semibold text-sm">{m.user?.name}</span>
424+
<span className="font-semibold text-sm">{displayName}</span>
425+
{aiLabel && (
426+
<span className="text-xs px-1.5 py-0.5 rounded-full bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300 font-medium">
427+
{aiLabel}
428+
</span>
429+
)}
406430
<span className="text-xs text-muted-foreground">
407431
{new Date(m.createdAt).toLocaleTimeString()}
408432
</span>
@@ -471,7 +495,8 @@ export default function InboxChannelPage() {
471495
)}
472496
</div>
473497
</div>
474-
))}
498+
);
499+
})}
475500
</div>
476501
</ScrollArea>
477502
</PullToRefresh>

apps/web/src/lib/ai/tools/__tests__/channel-tools.test.ts

Lines changed: 110 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, it, expect, vi, beforeEach } from 'vitest';
1+
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
22

33
/**
44
* Channel Tools Tests
@@ -14,6 +14,7 @@ vi.mock('@pagespace/db', () => ({
1414
query: {
1515
channelMessages: { findFirst: vi.fn() },
1616
pages: { findFirst: vi.fn() },
17+
driveMembers: { findMany: vi.fn() },
1718
},
1819
insert: vi.fn().mockReturnValue({
1920
values: vi.fn().mockReturnValue({
@@ -24,6 +25,7 @@ vi.mock('@pagespace/db', () => ({
2425
},
2526
channelMessages: {},
2627
channelReadStatus: { userId: 'userId', channelId: 'channelId' },
28+
driveMembers: { driveId: 'driveId' },
2729
pages: { id: 'id', isTrashed: 'isTrashed' },
2830
eq: vi.fn(),
2931
and: vi.fn(),
@@ -71,15 +73,20 @@ vi.mock('@/lib/logging/mask', () => ({
7173
}));
7274

7375
import { channelTools } from '../channel-tools';
74-
import { canUserEditPage } from '@pagespace/lib/permissions';
76+
import { canUserEditPage, canUserViewPage } from '@pagespace/lib/permissions';
7577
import { getActorInfo } from '@pagespace/lib/server';
7678
import { db } from '@pagespace/db';
79+
import { broadcastInboxEvent } from '@/lib/websocket/socket-utils';
7780
import type { ToolExecutionContext } from '../../core';
7881

7982
const mockCanUserEditPage = vi.mocked(canUserEditPage);
83+
const mockCanUserViewPage = vi.mocked(canUserViewPage);
8084
const mockGetActorInfo = vi.mocked(getActorInfo);
81-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
82-
const mockPagesFindFirst = db.query.pages.findFirst as any;
85+
const mockBroadcastInboxEvent = vi.mocked(broadcastInboxEvent);
86+
const mockDbInsert = db.insert as unknown as Mock;
87+
const mockPagesFindFirst = db.query.pages.findFirst as unknown as Mock;
88+
const mockChannelMessagesFindFirst = db.query.channelMessages.findFirst as unknown as Mock;
89+
const mockDriveMembersFindMany = db.query.driveMembers.findMany as unknown as Mock;
8390

8491
// Helper to safely extract result from tool execution (handles AsyncIterable union)
8592
type ToolResult = Record<string, unknown>;
@@ -92,6 +99,20 @@ const executeToolAs = async (
9299
describe('channel-tools', () => {
93100
beforeEach(() => {
94101
vi.clearAllMocks();
102+
mockGetActorInfo.mockResolvedValue({
103+
actorEmail: 'test@example.com',
104+
actorDisplayName: 'Test User',
105+
});
106+
mockCanUserViewPage.mockResolvedValue(true);
107+
mockChannelMessagesFindFirst.mockResolvedValue({
108+
id: 'msg-1',
109+
createdAt: new Date('2026-02-10T12:00:00.000Z'),
110+
user: { id: 'user-123', name: 'Alice', image: null },
111+
file: null,
112+
reactions: [],
113+
});
114+
mockDriveMembersFindMany.mockResolvedValue([]);
115+
mockBroadcastInboxEvent.mockResolvedValue(undefined);
95116
});
96117

97118
describe('send_channel_message', () => {
@@ -226,7 +247,7 @@ describe('channel-tools', () => {
226247
expect(result.messagePreview).toBe('Hello from assistant');
227248
});
228249

229-
it('sends message as page agent with agent title', async () => {
250+
it('sends message as page agent with agent + user display name', async () => {
230251
mockPagesFindFirst.mockResolvedValue({
231252
id: 'ch-1',
232253
title: 'General',
@@ -260,10 +281,93 @@ describe('channel-tools', () => {
260281
);
261282

262283
expect(result.success).toBe(true);
263-
expect(result.senderName).toBe('Budget Analyst');
284+
expect(result.senderName).toBe('Budget Analyst (Test User)');
264285
expect(result.senderType).toBe('agent');
265286
});
266287

288+
it('marks global assistant messages as read and skips sender inbox broadcast', async () => {
289+
mockPagesFindFirst.mockResolvedValue({
290+
id: 'ch-1',
291+
title: 'General',
292+
type: 'CHANNEL',
293+
driveId: 'drive-1',
294+
drive: { ownerId: 'user-456' },
295+
});
296+
mockDriveMembersFindMany.mockResolvedValue([
297+
{ userId: 'user-123' },
298+
{ userId: 'user-456' },
299+
]);
300+
mockCanUserEditPage.mockResolvedValue(true);
301+
mockGetActorInfo.mockResolvedValue({
302+
actorEmail: 'alice@example.com',
303+
actorDisplayName: 'Alice',
304+
});
305+
306+
const context = {
307+
toolCallId: '1', messages: [],
308+
experimental_context: {
309+
userId: 'user-123',
310+
chatSource: { type: 'global' },
311+
} as ToolExecutionContext,
312+
};
313+
314+
const result = await executeToolAs(
315+
{ channelId: 'ch-1', content: 'Global update' },
316+
context
317+
);
318+
319+
expect(result.success).toBe(true);
320+
expect(mockDbInsert).toHaveBeenCalledTimes(2);
321+
expect(mockBroadcastInboxEvent).toHaveBeenCalledTimes(1);
322+
expect(mockBroadcastInboxEvent).toHaveBeenCalledWith(
323+
'user-456',
324+
expect.objectContaining({
325+
operation: 'channel_updated',
326+
id: 'ch-1',
327+
})
328+
);
329+
});
330+
331+
it('keeps agent messages unread for requester and includes requester in inbox broadcast', async () => {
332+
mockPagesFindFirst.mockResolvedValue({
333+
id: 'ch-1',
334+
title: 'General',
335+
type: 'CHANNEL',
336+
driveId: 'drive-1',
337+
drive: { ownerId: 'user-456' },
338+
});
339+
mockDriveMembersFindMany.mockResolvedValue([
340+
{ userId: 'user-123' },
341+
{ userId: 'user-456' },
342+
]);
343+
mockCanUserEditPage.mockResolvedValue(true);
344+
345+
const context = {
346+
toolCallId: '1', messages: [],
347+
experimental_context: {
348+
userId: 'user-123',
349+
chatSource: {
350+
type: 'page',
351+
agentPageId: 'agent-1',
352+
agentTitle: 'Budget Analyst',
353+
},
354+
} as ToolExecutionContext,
355+
};
356+
357+
const result = await executeToolAs(
358+
{ channelId: 'ch-1', content: 'Agent follow-up' },
359+
context
360+
);
361+
362+
expect(result.success).toBe(true);
363+
expect(mockDbInsert).toHaveBeenCalledTimes(1);
364+
expect(mockBroadcastInboxEvent).toHaveBeenCalledTimes(2);
365+
366+
const recipients = mockBroadcastInboxEvent.mock.calls.map(([recipient]) => recipient);
367+
expect(recipients).toContain('user-123');
368+
expect(recipients).toContain('user-456');
369+
});
370+
267371
it('defaults to global_assistant when chatSource is not provided', async () => {
268372
mockPagesFindFirst.mockResolvedValue({
269373
id: 'ch-1',

0 commit comments

Comments
 (0)