Skip to content
Draft
Show file tree
Hide file tree
Changes from 6 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
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ test("relay policy revalidation admits an authorized external agent", async () =
});

test("fresh managed evidence survives unrelated relay authorization errors", async () => {
let relayFetches = 0;
const result = await revalidateAgentMentionPubkeys({
...options(),
pubkeys: [HUMAN, LOCAL_AGENT],
Expand All @@ -44,11 +45,33 @@ test("fresh managed evidence survives unrelated relay authorization errors", asy
error: null,
}),
fetchRelayAgents: async () => {
relayFetches += 1;
throw new Error("relay directory unavailable");
},
});

assert.deepEqual(result, [HUMAN, LOCAL_AGENT]);
assert.equal(relayFetches, 0);
});

test("relay lookup is limited to agents not confirmed as managed", async () => {
const requestedRelayPubkeys = [];
const result = await revalidateAgentMentionPubkeys({
...options(),
pubkeys: [HUMAN, LOCAL_AGENT, AGENT],
agentPubkeys: new Set([LOCAL_AGENT, AGENT]),
refetchManagedAgents: async () => ({
data: [{ pubkey: LOCAL_AGENT }],
error: null,
}),
fetchRelayAgents: async (pubkeys) => {
requestedRelayPubkeys.push(...pubkeys);
return options().fetchRelayAgents();
},
});

assert.deepEqual(requestedRelayPubkeys, [AGENT]);
assert.deepEqual(result, [HUMAN, LOCAL_AGENT, AGENT]);
});

test("relay-only agents still fail closed when relay discovery fails", async () => {
Expand Down
16 changes: 10 additions & 6 deletions desktop/src/features/messages/lib/agentMentionRevalidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,23 +38,27 @@ export async function revalidateAgentMentionPubkeys({
return [...pubkeys];
}

const [managedResult, relayAgents] = await Promise.all([
refetchManagedAgents(),
fetchRelayAgents([...requestedAgentPubkeys]).catch(() => null),
]);
const relayDirectoryReady = relayAgents !== null;
const managedResult = await refetchManagedAgents();
if (managedResult.error !== null || managedResult.data === undefined) {
return filterAdmittedMentionPubkeys(pubkeys, agentPubkeys, new Set());
}

const managedPubkeys = new Set(
managedResult.data.map((agent) => normalizePubkey(agent.pubkey)),
);
const relayAgentPubkeys = [...requestedAgentPubkeys].filter(
(pubkey) => !managedPubkeys.has(pubkey),
);
const relayAgents =
relayAgentPubkeys.length === 0
? []
: await fetchRelayAgents(relayAgentPubkeys).catch(() => null);
const relayDirectoryReady = relayAgents !== null;
const mentionablePubkeys = getMentionableAgentPubkeys({
currentPubkey,
eligibilityScope,
managedAgentPubkeys: managedPubkeys,
relayAgents: relayDirectoryReady ? relayAgents : [],
relayAgents: relayAgents ?? [],
sharedChannelIds,
});
const admittedPubkeys = new Set(
Expand Down
8 changes: 4 additions & 4 deletions desktop/src/features/messages/ui/MessageComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ function MessageComposerImpl({
const syncAddressedAgentsFromTextRef = React.useRef<(text: string) => void>(
() => {},
);
const prepareMentionWakeRef = React.useRef<(text: string) => void>(() => {});
disabledRef.current = disabled;
isSendingRef.current = isSending;
isUploadingRef.current = media.isUploading;
Expand Down Expand Up @@ -274,6 +275,7 @@ function MessageComposerImpl({
onUpdate: ({ cursor, linkPreviewContent, text }) => {
trackAuthoredContent(text);
contentRef.current = text;
prepareMentionWakeRef.current(text);
setComposerContentFromText(text);
setPreviewContent(linkPreviewContent);
if (!isSubmitLockedRef.current && !editTargetRef.current) {
Expand Down Expand Up @@ -341,6 +343,7 @@ function MessageComposerImpl({
drafts,
emojiAutocomplete,
mentions,
mentionWakeEnabled: editTarget == null && !isSubmitLocked,
onAddressedAgentsComposerCleared: (pubkeys) =>
restoreAddressedAgentMentionsRef.current(pubkeys),
onAddressedAgentsSendFailed: addressPulse.shakeMany,
Expand Down Expand Up @@ -371,6 +374,7 @@ function MessageComposerImpl({
restoreQueuedAttachments: media.restoreQueuedAttachments,
setSpoileredAttachmentUrls,
});
prepareMentionWakeRef.current = mentionSendFlow.prepareMentionWake;
React.useEffect(() => {
onDeferredEditPendingChange?.(isDeferredEditPending);
return () => onDeferredEditPendingChange?.(false);
Expand Down Expand Up @@ -398,7 +402,6 @@ function MessageComposerImpl({
setSpoileredAttachmentUrls(
findSpoileredImetaMediaUrls(editTarget.body, editableImeta),
);
// also lands the caret at end of the loaded content.
const rafId = requestAnimationFrame(() => richText.focusEnd());
return () => cancelAnimationFrame(rafId);
} else if (preEditSnapshotRef.current !== null) {
Expand Down Expand Up @@ -426,8 +429,6 @@ function MessageComposerImpl({
richText.focusPreserve();
}, [composerDisabled, replyTarget, richText.focusPreserve]);
useComposerAutofocus(richText.focus, effectiveDraftKey, composerDisabled);
// Hooks return a plain-text edit descriptor; `replacePlainTextRange`
// applies it as a single ProseMirror transaction (no markdown round-trip).
const applyAutocompleteEdit = React.useCallback(
(edit: AutocompleteEdit) => {
richText.replacePlainTextRange(
Expand Down Expand Up @@ -769,7 +770,6 @@ function MessageComposerImpl({
setPendingImeta: media.setPendingImeta,
uploadFile: media.uploadFile,
});
// ── Send button state ───────────────────────────────────────────────
const sendDisabled =
composerDisabled ||
media.isUploading ||
Expand Down
45 changes: 34 additions & 11 deletions desktop/src/features/messages/ui/useMentionSendFlow.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
managedAgentsQueryKey,
type CreateChannelManagedAgentInput,
useAttachManagedAgentToChannelMutation,
useAvailableAcpRuntimes,
useCreateChannelManagedAgentMutation,
useManagedAgentsQuery,
useProvisionChannelManagedAgentMutation,
useStartManagedAgentMutation,
} from "@/features/agents/hooks";
import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime";
import { useAddChannelMembersMutation } from "@/features/channels/hooks";
Expand Down Expand Up @@ -41,6 +42,8 @@ import {
} from "./useMentionSendFlow.helpers";
import { buildAgentAddressMentionTags } from "@/features/messages/lib/agentAddressMention.mjs";
import type { UseMentionSendFlowOptions } from "./useMentionSendFlow.types";
import { useMentionWakePreflight } from "./useMentionWakePreflight";
import { useScopedManagedAgentStart } from "./useSingleFlightManagedAgentStart";

export function useMentionSendFlow({
channelId,
Expand All @@ -50,6 +53,7 @@ export function useMentionSendFlow({
customEmoji,
drafts,
emojiAutocomplete,
mentionWakeEnabled,
mentions,
onPrepareSendChannel,
onAddressedAgentsComposerCleared,
Expand Down Expand Up @@ -95,16 +99,36 @@ export function useMentionSendFlow({
useProvisionChannelManagedAgentMutation(channelId);
const availableRuntimesQuery = useAvailableAcpRuntimes();
const managedAgentsQuery = useManagedAgentsQuery();
const startAgentMutation = useStartManagedAgentMutation();
const queryClient = useQueryClient();
const {
expectedRelayUrl,
expectedSignerPubkey,
startManagedAgentForScope,
startManagedAgentOnce,
} = useScopedManagedAgentStart();
const getManagedAgentsByPubkey = React.useCallback(async () => {
const agents =
managedAgentsQuery.data ??
queryClient.getQueryData<ManagedAgent[]>(managedAgentsQueryKey) ??
(await managedAgentsQuery.refetch()).data ??
[];
return new Map(
agents.map((agent) => [normalizePubkey(agent.pubkey), agent]),
);
}, [managedAgentsQuery.data, managedAgentsQuery.refetch]);
}, [managedAgentsQuery.refetch, queryClient]);
const { prepareMentionWake } = useMentionWakePreflight({
channelId,
contentRef,
enabled:
mentionWakeEnabled &&
(channelType === "stream" || channelType === "forum"),
expectedRelayUrl,
expectedSignerPubkey,
getDraftMentionRefs: mentions.getDraftMentionRefs,
getManagedAgentsByPubkey,
isManagedAgentPubkey: mentions.isManagedAgentPubkey,
memberPubkeys: mentions.memberPubkeys,
startManagedAgent: startManagedAgentOnce,
});
const getAvailableRuntimes = React.useCallback(async (): Promise<
AcpRuntime[]
> => {
Expand Down Expand Up @@ -156,10 +180,10 @@ export function useMentionSendFlow({
if (participantPubkeys.has(pubkey)) {
if (isProviderBackedAgent(agent)) {
if (agent.status !== "deployed") {
await startAgentMutation.mutateAsync(agent.pubkey);
await startManagedAgentForScope(agent.pubkey);
}
} else if (!isManagedAgentRunning(agent)) {
await startAgentMutation.mutateAsync(agent.pubkey);
await startManagedAgentForScope(agent.pubkey);
}
} else {
await attachAgentMutation.mutateAsync({
Expand Down Expand Up @@ -187,7 +211,7 @@ export function useMentionSendFlow({
attachAgentMutation,
getManagedAgentsByPubkey,
mentions.memberPubkeys,
startAgentMutation,
startManagedAgentForScope,
],
);
const createMentionedPersonaAgents = React.useCallback(
Expand Down Expand Up @@ -938,8 +962,7 @@ export function useMentionSendFlow({
isMentionSendPending ||
isCompleteSendPending ||
attachAgentMutation.isPending ||
createPersonaAgentMutation.isPending ||
startAgentMutation.isPending,
createPersonaAgentMutation.isPending,
nonMemberPromptProps: {
canInvite: canInviteNonMembers,
error: nonMemberPromptError,
Expand All @@ -948,14 +971,14 @@ export function useMentionSendFlow({
isCompleteSendPending ||
addMembersMutation.isPending ||
attachAgentMutation.isPending ||
createPersonaAgentMutation.isPending ||
startAgentMutation.isPending,
createPersonaAgentMutation.isPending,
names: pendingNonMemberNames,
onDismiss: dismissNonMemberPrompt,
onDoNothing: handleSendWithoutInviting,
onInvite: handleInviteNonMembers,
open: pendingNonMemberSend !== null,
},
prepareMentionWake,
sendMessageWithMentionFlow,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type UseMentionSendFlowOptions = {
customEmoji: CustomEmoji[];
drafts: Pick<UseDraftsResult, "loadDraft" | "markDraftSent" | "persistDraft">;
emojiAutocomplete: Pick<UseEmojiAutocompleteResult, "clearEmojis">;
mentionWakeEnabled: boolean;
mentions: UseMentionsResult;
onPrepareSendChannel?: (pubkeys?: string[]) => Promise<string | null>;
onAddressedAgentsComposerCleared?: (pubkeys: readonly string[]) => string;
Expand Down
Loading
Loading