Skip to content
Open
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
19 changes: 19 additions & 0 deletions apps/agor-daemon/src/mcp/tools/messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,25 @@ describe('agor_messages_list MCP tool', () => {
expect(mockAllSpy).toHaveBeenCalled();
});

it('always excludes mention (ping) messages from agent-facing results', async () => {
const handler = await registerAndGetHandler({ userId: 'user-1' });

await handler({ sessionId: 'sess-0001', includeToolCalls: true });

expect(mockWhereSpy).toHaveBeenCalledTimes(1);
// Drizzle SQL fragments are circular; flatten to text to assert on content
// without depending on their internal shape.
const seen = new WeakSet();
const flattened = JSON.stringify(mockWhereSpy.mock.calls[0]?.[0], (_key, v) => {
if (typeof v === 'object' && v !== null) {
if (seen.has(v)) return undefined;
seen.add(v);
}
return v;
});
expect(flattened).toContain("!= 'mention'");
});

it('sets next_offset to the raw rows consumed for the returned page', async () => {
mockAllSpy.mockResolvedValue([row(0), row(1), row(2)]);
const handler = await registerAndGetHandler({ userId: 'user-1' });
Expand Down
5 changes: 5 additions & 0 deletions apps/agor-daemon/src/mcp/tools/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,11 @@ export function registerMessageTools(server: McpServer, ctx: McpContext): void {
);
}

// Pings ('mention' messages) are human-to-human notes, never part of the
// agent's own conversation — exclude unconditionally so the agent can't
// read them back through this tool either.
conditions.push(sql`${messagesTable.type} != 'mention'`);

// Search: parse "term1 term2 | term3 term4" into (t1 AND t2) OR (t3 AND t4)
if (search) {
const orGroups = search.split(/\s*\|\s*/).map((group) => {
Expand Down
70 changes: 69 additions & 1 deletion apps/agor-daemon/src/register-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import type {
Task,
TaskID,
User,
UserID,
UUID,
} from '@agor/core/types';
import {
Expand Down Expand Up @@ -131,6 +132,7 @@ import {
sessionCanStartTask,
shouldReconcileSessionPromptState,
} from './utils/session-task-state.js';
import { findHostTaskForSession } from './utils/session-tasks.js';
import { type SessionTurnLocks, withSessionTurnLock } from './utils/session-turn-lock.js';
import { normalizeMessageSource, runExistingTask } from './utils/task-runner.js';
import {
Expand Down Expand Up @@ -272,7 +274,7 @@ export async function registerRoutes(ctx: RegisterRoutesContext): Promise<void>
messagesService,
boardsService,
branchRepository,
usersRepository: _usersRepository,
usersRepository,
sessionsRepository,
sessionMCPServersService,
sessionEnvSelectionsService,
Expand Down Expand Up @@ -1537,6 +1539,72 @@ export async function registerRoutes(ctx: RegisterRoutesContext): Promise<void>
requireAuth
);

// ============================================================================
// Ping endpoint
//
// A "ping" is a human-to-human note in the conversation stream — Slack-style
// `@collaborator what do you think?`. Unlike /sessions/:id/prompt, this path
// NEVER creates/queues a Task and NEVER spawns the executor: it only writes
// a `type: 'mention'` message via the same messages service used elsewhere,
// so the same RBAC hooks (ensureCanPromptInSession) apply. Structurally this
// guarantees the ping can't reach the agent — no Task means the executor is
// never invoked with it. It's also excluded from the agent-facing
// `agor_messages_list` MCP tool (see mcp/tools/messages.ts) so the agent
// can't read it back that way either.
// ============================================================================

registerAuthenticatedRoute(
app,
'/sessions/:id/ping',
{
async create(data: { text: string; mentioned_user_ids?: string[] }, params: RouteParams) {
const id = params.route?.id;
if (!id) throw new Error('Session ID required');
const text = data.text?.trim();
if (!text) throw new Error('Ping text required');
if (!params.user?.user_id) {
throw new NotAuthenticated('Authentication required to ping a session');
}

const session = await sessionsService.get(id, params);

// Resolve mentioned user ids, silently dropping any that don't
// resolve to a real user (best-effort — the ping still posts).
const requestedMentionIds = Array.from(new Set(data.mentioned_user_ids ?? []));
const mentions: UserID[] = [];
for (const userId of requestedMentionIds) {
const user = await usersRepository.findById(userId);
if (user) mentions.push(user.user_id as UserID);
}

// Attach to the session's host task (mirrors the widget_request
// pattern) so the transcript renderer — which loads messages by
// task_id — picks it up. Sessions with no tasks yet (never prompted)
// have no host task; the ping is still stored but won't render until
// the session has at least one task.
const hostTask = await findHostTaskForSession(app, session.session_id, params);

const created = await appendSystemMessage({
app,
db,
sessionId: session.session_id,
taskId: hostTask?.task_id,
content: text,
type: 'mention',
role: MessageRole.USER,
metadata: { mentions, author_user_id: params.user.user_id as UserID },
params,
});

return created;
},
},
{
create: { role: ROLES.MEMBER, action: 'ping session collaborators' },
},
requireAuth
);

// ============================================================================
// Task run endpoint
//
Expand Down
5 changes: 4 additions & 1 deletion apps/agor-daemon/src/utils/append-system-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ export interface AppendSystemMessageOptions {
/** Falls back to the first 200 chars of string content when omitted */
contentPreview?: string;
/** Defaults to 'system' */
type?: Extract<MessageType, 'system' | 'daemon_restart' | 'daemon_crash' | 'widget_request'>;
type?: Extract<
MessageType,
'system' | 'daemon_restart' | 'daemon_crash' | 'widget_request' | 'mention'
>;
/** Defaults to MessageRole.SYSTEM */
role?: Message['role'];
metadata?: Message['metadata'];
Expand Down
19 changes: 19 additions & 0 deletions apps/agor-ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -829,6 +829,24 @@ function AppContent() {
}
};

// Handle send ping - human-to-human note, never reaches the agent
const handleSendPing = async (
sessionId: string,
text: string,
mentionedUserIds?: string[]
): Promise<boolean> => {
if (!client) return false;

try {
await client.sessions.ping(sessionId, text, mentionedUserIds);
return true;
} catch (error) {
showError(`Failed to send ping: ${error instanceof Error ? error.message : String(error)}`);
console.error('Ping error:', error);
return false;
}
};

// Handle update session
const handleUpdateSession = async (sessionId: string, updates: Partial<Session>) => {
const session = await updateSession(sessionId as SessionID, updates);
Expand Down Expand Up @@ -1605,6 +1623,7 @@ function AppContent() {
onBtwForkSession={handleBtwForkSession}
onSpawnSession={handleSpawnSession}
onSendPrompt={handleSendPrompt}
onSendPing={handleSendPing}
onUpdateSession={handleUpdateSession}
onDeleteSession={handleDeleteSession}
onCreateBoard={handleCreateBoard}
Expand Down
8 changes: 8 additions & 0 deletions apps/agor-ui/src/components/App/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,11 @@ export interface AppProps {
prompt: string,
permissionMode?: PermissionMode
) => boolean | undefined | Promise<boolean | undefined>;
onSendPing?: (
sessionId: string,
text: string,
mentionedUserIds?: string[]
) => boolean | undefined | Promise<boolean | undefined>;
onUpdateSession?: (sessionId: string, updates: Partial<Session>) => void;
onDeleteSession?: (sessionId: string) => void;
onCreateBoard?: (board: Partial<Board>) => Promise<Board | null>;
Expand Down Expand Up @@ -269,6 +274,7 @@ export const App: React.FC<AppProps> = ({
onBtwForkSession,
onSpawnSession,
onSendPrompt,
onSendPing,
onUpdateSession,
onDeleteSession,
onCreateBoard,
Expand Down Expand Up @@ -1066,6 +1072,7 @@ export const App: React.FC<AppProps> = ({
const appActionsValue = useMemo(
() => ({
onSendPrompt,
onSendPing,
onFork: onForkSession,
onBtwFork: onBtwForkSession,
onSubsession: onSpawnSession,
Expand All @@ -1086,6 +1093,7 @@ export const App: React.FC<AppProps> = ({
}),
[
onSendPrompt,
onSendPing,
onForkSession,
onBtwForkSession,
onSpawnSession,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,39 @@ describe('AutocompleteTextarea', () => {
expect(kbDocumentsFind).not.toHaveBeenCalled();
});

it('quotes multi-word user mentions and fires onMentionSelect', async () => {
const onMentionSelect = vi.fn();
const userById = new Map([
['user-1', { user_id: 'user-1', name: 'Jane Smith', email: 'jane@example.com' } as never],
]);

const Harness = () => {
const [value, setValue] = useState('');
return (
<AutocompleteTextarea
value={value}
onChange={setValue}
placeholder="Prompt"
client={null}
sessionId={null}
userById={userById}
onMentionSelect={onMentionSelect}
/>
);
};
render(<Harness />);
const textarea = screen.getByPlaceholderText('Prompt') as HTMLTextAreaElement;

fireEvent.change(textarea, { target: { value: '@Jane', selectionStart: 5 } });

fireEvent.click(await screen.findByText(/Jane Smith/));

await waitFor(() => {
expect(textarea).toHaveValue('@"Jane Smith" ');
});
expect(onMentionSelect).toHaveBeenCalledWith('user-1', '@"Jane Smith"');
});

it('routes pasted image files through file drop handling with screenshot names', () => {
const { textarea, onFilesDrop } = renderFilePasteTextarea();
const imageFile = new File(['image'], 'clipboard.png', { type: 'image/png' });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ interface FileResult {
}

interface UserResult {
userId: string;
name: string;
email: string;
type: 'user';
Expand Down Expand Up @@ -121,6 +122,13 @@ interface AutocompleteTextareaProps {
kbLinkTarget?: KbLinkTarget;
/** Draw attention to the textarea while it is empty. */
highlightWhenEmpty?: boolean;
/**
* Fires when a user is chosen from the `@` autocomplete, with the userId
* and the exact `@name` / `@"name"` text inserted into the textarea. Lets
* callers (e.g. the ping composer) track mentioned user ids without
* re-parsing free text.
*/
onMentionSelect?: (userId: string, insertedText: string) => void;
}

// Minimum characters required after : before showing emoji picker (like Slack)
Expand Down Expand Up @@ -380,6 +388,7 @@ export const AutocompleteTextarea = React.forwardRef<
kbDocs,
kbLinkTarget = 'stable-uri',
highlightWhenEmpty = false,
onMentionSelect,
},
ref
) => {
Expand Down Expand Up @@ -621,6 +630,7 @@ export const AutocompleteTextarea = React.forwardRef<
// If no query, show all users (up to MAX_USER_RESULTS)
if (!searchQuery.trim()) {
return allUsers.slice(0, MAX_USER_RESULTS).map((u: User) => ({
userId: u.user_id,
name: u.name || u.email,
email: u.email,
type: 'user' as const,
Expand All @@ -637,6 +647,7 @@ export const AutocompleteTextarea = React.forwardRef<
)
.slice(0, MAX_USER_RESULTS)
.map((u: User) => ({
userId: u.user_id,
name: u.name || u.email,
email: u.email,
type: 'user' as const,
Expand Down Expand Up @@ -927,8 +938,10 @@ export const AutocompleteTextarea = React.forwardRef<
// File selection
insertText = `@${quoteIfNeeded(item.path)}`;
} else {
// User selection
insertText = `@${item.name}`;
// User selection. Quote multi-word names so the highlight regex
// (and any downstream mention parsing) captures the whole name.
insertText = `@${quoteIfNeeded(item.name)}`;
onMentionSelect?.(item.userId, insertText);
}

const newValue =
Expand Down Expand Up @@ -958,7 +971,7 @@ export const AutocompleteTextarea = React.forwardRef<
textareaRef.current.current?.focus();
}, 0);
},
[triggerIndex, value, onChange, kbLinkTarget]
[triggerIndex, value, onChange, kbLinkTarget, onMentionSelect]
);

/**
Expand Down
Loading