diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs b/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs index b94a8974b..ab79b6d49 100644 --- a/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs +++ b/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { canOpenAgentConversationInChannel, getDmTaskAgentPubkeys, + getThreadTaskAgentPubkeys, mergeAutoRouteMentionPubkeys, } from "./ChannelPane.helpers.ts"; @@ -114,3 +115,61 @@ test("DM task agent inference requires exactly one other known agent", () => { [], ); }); + +test("thread task agent inference requires exactly one known agent and one human", () => { + const knownAgentPubkeys = new Set(["agent-one", "agent-two"]); + + assert.deepEqual( + getThreadTaskAgentPubkeys({ + currentPubkey: "human", + knownAgentPubkeys, + messages: [ + { + pubkey: "human", + tags: [["p", "agent-one"]], + }, + { + pubkey: "agent-one", + tags: [["p", "human"]], + }, + ], + }), + ["agent-one"], + ); + + assert.deepEqual( + getThreadTaskAgentPubkeys({ + currentPubkey: "human", + knownAgentPubkeys, + messages: [ + { + pubkey: "human", + tags: [["p", "agent-one"]], + }, + { + pubkey: "other-human", + tags: [["p", "human"]], + }, + ], + }), + [], + ); + + assert.deepEqual( + getThreadTaskAgentPubkeys({ + currentPubkey: "human", + knownAgentPubkeys, + messages: [ + { + pubkey: "human", + tags: [["p", "agent-one"]], + }, + { + pubkey: "agent-two", + tags: [["p", "human"]], + }, + ], + }), + [], + ); +}); diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.ts b/desktop/src/features/channels/ui/ChannelPane.helpers.ts index 8643525ce..bcb47c02b 100644 --- a/desktop/src/features/channels/ui/ChannelPane.helpers.ts +++ b/desktop/src/features/channels/ui/ChannelPane.helpers.ts @@ -3,6 +3,7 @@ import type { TimelineMessage } from "@/features/messages/types"; import type { Channel } from "@/shared/api/types"; import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; import { normalizePubkey } from "@/shared/lib/pubkey"; +import { getMentionTagPubkey } from "@/shared/lib/resolveMentionNames"; export function getChannelIntroKind(channel: Channel): string { const isPrivate = channel.visibility === "private"; @@ -120,6 +121,53 @@ export function getDmTaskAgentPubkeys({ ); } +export function getThreadTaskAgentPubkeys({ + currentPubkey, + knownAgentPubkeys, + messages, +}: { + currentPubkey?: string; + knownAgentPubkeys: ReadonlySet; + messages: readonly TimelineMessage[]; +}) { + const normalizedCurrentPubkey = currentPubkey + ? normalizePubkey(currentPubkey) + : null; + const participants = new Map(); + + const addParticipant = (pubkey: string | null | undefined) => { + if (!pubkey) { + return; + } + const normalized = normalizePubkey(pubkey); + if (!normalized || participants.has(normalized)) { + return; + } + participants.set(normalized, pubkey); + }; + + for (const message of messages) { + addParticipant(message.pubkey); + for (const tag of message.tags ?? []) { + addParticipant(getMentionTagPubkey(tag)); + } + } + + const agentPubkeys = new Map(); + + for (const [normalized, pubkey] of participants) { + if (normalizedCurrentPubkey && normalized === normalizedCurrentPubkey) { + continue; + } + if (!knownAgentPubkeys.has(normalized)) { + return []; + } + agentPubkeys.set(normalized, pubkey); + } + + return agentPubkeys.size === 1 ? [...agentPubkeys.values()] : []; +} + export function mergeAutoRouteMentionPubkeys({ autoRouteAgentPubkeys, mentionPubkeys, @@ -148,3 +196,20 @@ export function mergeAutoRouteMentionPubkeys({ return merged; } + +export function mergeTaskAgentMentionPubkeys({ + agentPubkeys, + mentionPubkeys, +}: { + agentPubkeys: readonly string[]; + mentionPubkeys: string[]; +}) { + if (agentPubkeys.length === 0) { + return mentionPubkeys; + } + + return mergeAutoRouteMentionPubkeys({ + autoRouteAgentPubkeys: agentPubkeys, + mentionPubkeys, + }); +} diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 50c020b79..520f6e916 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -44,7 +44,9 @@ import { getChannelIntroDescription, getChannelIntroKind, getDmTaskAgentPubkeys, + getThreadTaskAgentPubkeys, isWelcomeSetupSystemMessage, + mergeTaskAgentMentionPubkeys, mentionsKnownAgent, } from "@/features/channels/ui/ChannelPane.helpers"; import type { ChannelPaneProps } from "@/features/channels/ui/ChannelPane.types"; @@ -421,13 +423,17 @@ export const ChannelPane = React.memo(function ChannelPane({ mentionPubkeys: string[], mediaTags?: string[][], ) => { + const sendMentionPubkeys = mergeTaskAgentMentionPubkeys({ + agentPubkeys: dmTaskAgentPubkeys, + mentionPubkeys, + }); const shouldCompleteWelcomeBanner = isActiveWelcomeChannel && (containsWelcomePersonaMention(content) || - mentionsKnownAgent(mentionPubkeys, knownAgentPubkeys)); + mentionsKnownAgent(sendMentionPubkeys, knownAgentPubkeys)); messageTimelineRef.current?.scrollToBottomOnNextUpdate(); - await onSendMessage(content, mentionPubkeys, mediaTags); + await onSendMessage(content, sendMentionPubkeys, mediaTags); if (shouldCompleteWelcomeBanner) { completeWelcomeComposerBanner(); @@ -435,6 +441,7 @@ export const ChannelPane = React.memo(function ChannelPane({ }, [ completeWelcomeComposerBanner, + dmTaskAgentPubkeys, isActiveWelcomeChannel, knownAgentPubkeys, onSendMessage, @@ -764,6 +771,22 @@ export const ChannelPane = React.memo(function ChannelPane({ ...threadMessages.map((entry) => entry.message), ]; }, [threadHeadMessage, threadMessages]); + const threadTaskAgentPubkeys = getThreadTaskAgentPubkeys({ + currentPubkey, + knownAgentPubkeys, + messages: threadSourceMessages, + }); + const handleSendThreadReply = React.useCallback( + (content: string, mentionPubkeys: string[], mediaTags?: string[][]) => { + const sendMentionPubkeys = mergeTaskAgentMentionPubkeys({ + agentPubkeys: threadTaskAgentPubkeys, + mentionPubkeys, + }); + + return onSendThreadReply(content, sendMentionPubkeys, mediaTags); + }, + [onSendThreadReply, threadTaskAgentPubkeys], + ); const hiddenAgentConversationMessageIds = React.useMemo(() => { if (!enableAgentConversations) { return new Set(); @@ -1194,7 +1217,7 @@ export const ChannelPane = React.memo(function ChannelPane({ : undefined } onSelectReplyTarget={onSelectThreadReplyTarget} - onSend={onSendThreadReply} + onSend={handleSendThreadReply} onScrollTargetResolved={onThreadScrollTargetResolved} onToggleReaction={onToggleReaction} onUnfollowThread={onUnfollowThread}