Prewake mentioned agents while composing

Signed-off-by: Matt Toohey <contact@matttoohey.com>
This commit is contained in:
Matt Toohey
2026-08-26 15:53:08 +10:00
parent 52621c09be
commit 2c66df82d3
10 changed files with 519 additions and 26 deletions

View File

@@ -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],
@@ -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 () => {

View File

@@ -38,11 +38,7 @@ 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());
}
@@ -50,11 +46,19 @@ export async function revalidateAgentMentionPubkeys({
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(

View File

@@ -341,6 +341,7 @@ function MessageComposerImpl({
drafts,
emojiAutocomplete,
mentions,
mentionWakeEnabled: editTarget == null && !isSubmitLocked,
onAddressedAgentsSendStarted: addressPulse.pulseMany,
onAddressedAgentsComposerCleared: (pubkeys) =>
restoreAddressedAgentMentionsRef.current(pubkeys),
@@ -770,7 +771,6 @@ function MessageComposerImpl({
setPendingImeta: media.setPendingImeta,
uploadFile: media.uploadFile,
});
// ── Send button state ───────────────────────────────────────────────
const sendDisabled =
composerDisabled ||
media.isUploading ||

View File

@@ -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";
@@ -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,
@@ -50,6 +53,7 @@ export function useMentionSendFlow({
customEmoji,
drafts,
emojiAutocomplete,
mentionWakeEnabled,
mentions,
onPrepareSendChannel,
onAddressedAgentsSendStarted,
@@ -96,16 +100,37 @@ 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]);
useMentionWakePreflight({
channelId,
content: contentRef.current,
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[]
> => {
@@ -157,10 +182,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({
@@ -188,7 +213,7 @@ export function useMentionSendFlow({
attachAgentMutation,
getManagedAgentsByPubkey,
mentions.memberPubkeys,
startAgentMutation,
startManagedAgentForScope,
],
);
const createMentionedPersonaAgents = React.useCallback(
@@ -943,8 +968,7 @@ export function useMentionSendFlow({
isMentionSendPending ||
isCompleteSendPending ||
attachAgentMutation.isPending ||
createPersonaAgentMutation.isPending ||
startAgentMutation.isPending,
createPersonaAgentMutation.isPending,
nonMemberPromptProps: {
canInvite: canInviteNonMembers,
error: nonMemberPromptError,
@@ -953,8 +977,7 @@ export function useMentionSendFlow({
isCompleteSendPending ||
addMembersMutation.isPending ||
attachAgentMutation.isPending ||
createPersonaAgentMutation.isPending ||
startAgentMutation.isPending,
createPersonaAgentMutation.isPending,
names: pendingNonMemberNames,
onDismiss: dismissNonMemberPrompt,
onDoNothing: handleSendWithoutInviting,

View File

@@ -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>;
onAddressedAgentsSendStarted?: (pubkeys: readonly string[]) => void;

View File

@@ -0,0 +1,113 @@
import assert from "node:assert/strict";
import { after, afterEach, before, test } from "node:test";
import { JSDOM } from "jsdom";
import {
buildMentionWakePlan,
hasSubstantiveNonMentionText,
MENTION_WAKE_DELAY_MS,
useMentionWakePreflight,
} from "./useMentionWakePreflight.ts";
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});
before(() => {
Object.assign(globalThis, {
document: dom.window.document,
HTMLElement: dom.window.HTMLElement,
IS_REACT_ACT_ENVIRONMENT: true,
window: dom.window,
});
});
afterEach(async () => {
const { cleanup } = await import("@testing-library/react");
cleanup();
});
after(() => dom.window.close());
const AGENT = "a".repeat(64);
const OTHER_AGENT = "b".repeat(64);
const fizzRef = { displayName: "Fizz", pubkey: AGENT, isAgent: true };
test("mention-only composer content is not substantive", () => {
assert.equal(hasSubstantiveNonMentionText("@Fizz ", [fizzRef]), false);
assert.equal(hasSubstantiveNonMentionText("@Fizz hello", [fizzRef]), true);
assert.equal(hasSubstantiveNonMentionText("hello @Fizz", [fizzRef]), true);
});
test("wake plan contains only managed agents already in the channel", () => {
const plan = buildMentionWakePlan({
channelId: "general",
content: "@Fizz ask @Imp for help",
isManagedAgentPubkey: (pubkey) => pubkey === AGENT,
memberPubkeys: new Set([AGENT, OTHER_AGENT]),
mentionRefs: [
fizzRef,
{ displayName: "Imp", pubkey: OTHER_AGENT, isAgent: true },
],
});
assert.deepEqual(plan, {
key: `general:${AGENT}`,
pubkeys: [AGENT],
});
});
test("wake plan rejects non-member managed agents", () => {
assert.equal(
buildMentionWakePlan({
channelId: "general",
content: "@Fizz hello",
isManagedAgentPubkey: () => true,
memberPubkeys: new Set(),
mentionRefs: [fizzRef],
}),
null,
);
});
test("unmount prevents a wake after an in-flight lookup resolves", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const { act, renderHook } = await import("@testing-library/react");
let resolveManagedAgents;
const managedAgents = new Promise((resolve) => {
resolveManagedAgents = resolve;
});
let starts = 0;
const contentRef = { current: "@Fizz hello" };
const view = renderHook(() =>
useMentionWakePreflight({
channelId: "general",
content: contentRef.current,
contentRef,
enabled: true,
expectedRelayUrl: "wss://relay.example",
expectedSignerPubkey: "c".repeat(64),
getDraftMentionRefs: () => [fizzRef],
getManagedAgentsByPubkey: () => managedAgents,
isManagedAgentPubkey: () => true,
memberPubkeys: new Set([AGENT]),
startManagedAgent: async () => {
starts += 1;
return { pubkey: AGENT, status: "running" };
},
}),
);
await act(async () => t.mock.timers.tick(MENTION_WAKE_DELAY_MS));
view.unmount();
await act(async () => {
resolveManagedAgents(
new Map([[AGENT, { pubkey: AGENT, status: "stopped" }]]),
);
await managedAgents;
});
assert.equal(starts, 0);
});

View File

@@ -0,0 +1,213 @@
import * as React from "react";
import { getMentionOffsets } from "@/features/messages/lib/hasMention";
import type { DraftMentionRef } from "@/features/messages/lib/useDrafts";
import type { ManagedAgent } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { isManagedAgentRunning } from "./useMentionSendFlow.helpers";
export const MENTION_WAKE_DELAY_MS = 1_000;
export type ManagedAgentStartInput =
| string
| {
pubkey: string;
expectedRelayUrl?: string;
expectedSignerPubkey?: string;
};
type MentionWakePlan = {
key: string;
pubkeys: string[];
};
export function hasSubstantiveNonMentionText(
content: string,
mentionRefs: readonly DraftMentionRef[],
): boolean {
const remaining = content.split("");
for (const ref of mentionRefs) {
const mentionLength = `@${ref.displayName}`.length;
for (const start of getMentionOffsets(content, ref.displayName)) {
remaining.fill(" ", start, start + mentionLength);
}
}
return remaining.join("").trim().length > 0;
}
export function buildMentionWakePlan({
channelId,
content,
isManagedAgentPubkey,
memberPubkeys,
mentionRefs,
}: {
channelId: string | null;
content: string;
isManagedAgentPubkey: (pubkey: string) => boolean;
memberPubkeys: ReadonlySet<string>;
mentionRefs: readonly DraftMentionRef[];
}): MentionWakePlan | null {
if (!channelId || !hasSubstantiveNonMentionText(content, mentionRefs)) {
return null;
}
const pubkeys = [
...new Set(
mentionRefs
.filter((ref) => ref.isAgent)
.map((ref) => normalizePubkey(ref.pubkey))
.filter(
(pubkey) =>
pubkey && memberPubkeys.has(pubkey) && isManagedAgentPubkey(pubkey),
),
),
].sort();
if (pubkeys.length === 0) return null;
return { key: `${channelId}:${pubkeys.join(",")}`, pubkeys };
}
export function useMentionWakePreflight({
channelId,
content,
contentRef,
enabled,
expectedRelayUrl,
expectedSignerPubkey,
getDraftMentionRefs,
getManagedAgentsByPubkey,
isManagedAgentPubkey,
memberPubkeys,
startManagedAgent,
}: {
channelId: string | null;
content: string;
contentRef: React.MutableRefObject<string>;
enabled: boolean;
expectedRelayUrl?: string;
expectedSignerPubkey?: string;
getDraftMentionRefs: (content: string) => DraftMentionRef[];
getManagedAgentsByPubkey: () => Promise<Map<string, ManagedAgent>>;
isManagedAgentPubkey: (pubkey: string) => boolean;
memberPubkeys: ReadonlySet<string>;
startManagedAgent: (input: ManagedAgentStartInput) => Promise<ManagedAgent>;
}) {
const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const activePlanKeyRef = React.useRef<string | null>(null);
const contextRef = React.useRef({
channelId,
contentRef,
enabled,
expectedRelayUrl,
expectedSignerPubkey,
getDraftMentionRefs,
getManagedAgentsByPubkey,
isManagedAgentPubkey,
memberPubkeys,
startManagedAgent,
});
contextRef.current = {
channelId,
contentRef,
enabled,
expectedRelayUrl,
expectedSignerPubkey,
getDraftMentionRefs,
getManagedAgentsByPubkey,
isManagedAgentPubkey,
memberPubkeys,
startManagedAgent,
};
const cancelMentionWake = React.useCallback(() => {
if (timerRef.current !== null) clearTimeout(timerRef.current);
timerRef.current = null;
activePlanKeyRef.current = null;
}, []);
const currentPlan = React.useCallback((content: string) => {
const context = contextRef.current;
if (
!context.enabled ||
!context.expectedRelayUrl ||
!context.expectedSignerPubkey
) {
return null;
}
const mentionRefs = context.getDraftMentionRefs(content);
const plan = buildMentionWakePlan({
channelId: context.channelId,
content,
isManagedAgentPubkey: context.isManagedAgentPubkey,
memberPubkeys: context.memberPubkeys,
mentionRefs,
});
return plan
? {
...plan,
key: JSON.stringify([
context.expectedRelayUrl,
context.expectedSignerPubkey,
plan.key,
]),
}
: null;
}, []);
const prepareMentionWake = React.useCallback(
(content: string) => {
const plan = currentPlan(content);
if (!plan) return cancelMentionWake();
if (activePlanKeyRef.current === plan.key) return;
cancelMentionWake();
activePlanKeyRef.current = plan.key;
timerRef.current = setTimeout(() => {
timerRef.current = null;
void (async () => {
if (activePlanKeyRef.current !== plan.key) return;
const beforeLookup = currentPlan(contentRef.current);
if (beforeLookup?.key !== plan.key) return;
const managedAgents =
await contextRef.current.getManagedAgentsByPubkey();
if (activePlanKeyRef.current !== plan.key) return;
const beforeWake = currentPlan(contentRef.current);
if (beforeWake?.key !== plan.key) return;
const context = contextRef.current;
await Promise.allSettled(
beforeWake.pubkeys.flatMap((pubkey) => {
const agent = managedAgents.get(pubkey);
return agent && !isManagedAgentRunning(agent)
? [
context.startManagedAgent({
pubkey,
expectedRelayUrl: context.expectedRelayUrl,
expectedSignerPubkey: context.expectedSignerPubkey,
}),
]
: [];
}),
);
})();
}, MENTION_WAKE_DELAY_MS);
},
[cancelMentionWake, contentRef, currentPlan],
);
// biome-ignore lint/correctness/useExhaustiveDependencies: contextRef keeps callbacks fresh; these inputs are plan invalidation signals.
React.useEffect(() => {
prepareMentionWake(content);
}, [
channelId,
content,
enabled,
expectedRelayUrl,
expectedSignerPubkey,
getDraftMentionRefs,
isManagedAgentPubkey,
memberPubkeys,
prepareMentionWake,
]);
React.useEffect(() => cancelMentionWake, [cancelMentionWake]);
}

View File

@@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import test from "node:test";
import { managedAgentStartFlightKey } from "./useSingleFlightManagedAgentStart.ts";
const AGENT = "a".repeat(64);
const SIGNER = "b".repeat(64);
test("start flight keys coalesce only within the same tenant scope", () => {
const scoped = managedAgentStartFlightKey({
pubkey: AGENT,
expectedRelayUrl: "wss://relay.example",
expectedSignerPubkey: SIGNER,
});
assert.equal(
scoped,
managedAgentStartFlightKey({
pubkey: AGENT.toUpperCase(),
expectedRelayUrl: "wss://relay.example",
expectedSignerPubkey: SIGNER.toUpperCase(),
}),
);
assert.notEqual(
scoped,
managedAgentStartFlightKey({
pubkey: AGENT,
expectedRelayUrl: "wss://other.example",
expectedSignerPubkey: SIGNER,
}),
);
assert.notEqual(scoped, managedAgentStartFlightKey(AGENT));
});

View File

@@ -0,0 +1,71 @@
import * as React from "react";
import { useStartManagedAgentMutation } from "@/features/agents/hooks";
import { normalizeRelayUrl } from "@/features/communities/communityStorage";
import { useCommunities } from "@/features/communities/useCommunities";
import { useIdentityQuery } from "@/shared/api/hooks";
import type { ManagedAgent } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
import type { ManagedAgentStartInput } from "./useMentionWakePreflight";
export function managedAgentStartFlightKey(input: ManagedAgentStartInput) {
const pubkey = normalizePubkey(
typeof input === "string" ? input : input.pubkey,
);
return typeof input === "string"
? JSON.stringify([pubkey, "", ""])
: JSON.stringify([
pubkey,
input.expectedRelayUrl?.trim() ?? "",
normalizePubkey(input.expectedSignerPubkey ?? ""),
]);
}
export function useSingleFlightManagedAgentStart(
startManagedAgent: (input: ManagedAgentStartInput) => Promise<ManagedAgent>,
) {
const promisesRef = React.useRef(new Map<string, Promise<ManagedAgent>>());
return React.useCallback(
(input: ManagedAgentStartInput) => {
const key = managedAgentStartFlightKey(input);
const existing = promisesRef.current.get(key);
if (existing) return existing;
const started = startManagedAgent(input);
promisesRef.current.set(key, started);
const clear = () => {
if (promisesRef.current.get(key) === started) {
promisesRef.current.delete(key);
}
};
void started.then(clear, clear);
return started;
},
[startManagedAgent],
);
}
export function useScopedManagedAgentStart() {
const startAgentMutation = useStartManagedAgentMutation();
const { activeCommunity } = useCommunities();
const identityQuery = useIdentityQuery();
const relayUrl = activeCommunity?.relayUrl;
const expectedRelayUrl = relayUrl ? normalizeRelayUrl(relayUrl) : undefined;
const expectedSignerPubkey = identityQuery.data?.pubkey
? normalizePubkey(identityQuery.data.pubkey)
: undefined;
const startManagedAgentOnce = useSingleFlightManagedAgentStart(
startAgentMutation.mutateAsync,
);
const startManagedAgentForScope = React.useCallback(
(pubkey: string) =>
startManagedAgentOnce({ pubkey, expectedRelayUrl, expectedSignerPubkey }),
[expectedRelayUrl, expectedSignerPubkey, startManagedAgentOnce],
);
return {
expectedRelayUrl,
expectedSignerPubkey,
startManagedAgentForScope,
startManagedAgentOnce,
};
}

View File

@@ -2062,10 +2062,11 @@ test("shared agents wait for initial directory authorization", async ({
});
});
test("mentioning an in-channel stopped managed agent starts it before sending", async ({
test("substantive in-channel managed-agent mentions wake before send", async ({
page,
}) => {
await installMockBridge(page, {
startManagedAgentDelayMs: 750,
managedAgents: [
{
pubkey: IN_CHANNEL_MANAGED_AGENT_PUBKEY,
@@ -2079,6 +2080,10 @@ test("mentioning an in-channel stopped managed agent starts it before sending",
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const baselineStartCount = commandCount(
await readCommandLog(page),
"start_managed_agent",
);
const input = page.getByTestId("message-input");
await input.fill("Hey @fizz");
@@ -2088,23 +2093,30 @@ test("mentioning an in-channel stopped managed agent starts it before sending",
await input.press("Enter");
await page.keyboard.type(" can you help?");
const baselineStartCount = commandCount(
await expect
.poll(
async () =>
commandCount(await readCommandLog(page), "start_managed_agent"),
{ timeout: 3_000 },
)
.toBeGreaterThan(baselineStartCount);
const preSendStartCount = commandCount(
await readCommandLog(page),
"start_managed_agent",
);
await page.getByTestId("send-message").click();
await expect
.poll(async () =>
commandCount(await readCommandLog(page), "start_managed_agent"),
)
.toBeGreaterThan(baselineStartCount);
const sendButton = page.getByTestId("send-message");
await expect(sendButton).toBeEnabled();
await sendButton.click();
const mentionChip = page
.getByTestId("message-row")
.last()
.locator("[data-mention].agent-mention-highlight", { hasText: "fizz" });
await expect(mentionChip).toBeVisible();
expect(commandCount(await readCommandLog(page), "start_managed_agent")).toBe(
preSendStartCount,
);
});
test("mentioning an in-channel provider managed agent deploys it before sending", async ({