diff --git a/desktop/src/features/messages/lib/agentMentionRevalidation.test.mjs b/desktop/src/features/messages/lib/agentMentionRevalidation.test.mjs index d04446a341..40bea9471b 100644 --- a/desktop/src/features/messages/lib/agentMentionRevalidation.test.mjs +++ b/desktop/src/features/messages/lib/agentMentionRevalidation.test.mjs @@ -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 () => { diff --git a/desktop/src/features/messages/lib/agentMentionRevalidation.ts b/desktop/src/features/messages/lib/agentMentionRevalidation.ts index 37f7ce9d4e..b4b939210f 100644 --- a/desktop/src/features/messages/lib/agentMentionRevalidation.ts +++ b/desktop/src/features/messages/lib/agentMentionRevalidation.ts @@ -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( diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 05e568f174..b1ae232cb3 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -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 || diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 10ca033272..8b21b26a51 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -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(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, diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.types.ts b/desktop/src/features/messages/ui/useMentionSendFlow.types.ts index 496a73a94a..a51627f515 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.types.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.types.ts @@ -17,6 +17,7 @@ export type UseMentionSendFlowOptions = { customEmoji: CustomEmoji[]; drafts: Pick; emojiAutocomplete: Pick; + mentionWakeEnabled: boolean; mentions: UseMentionsResult; onPrepareSendChannel?: (pubkeys?: string[]) => Promise; onAddressedAgentsSendStarted?: (pubkeys: readonly string[]) => void; diff --git a/desktop/src/features/messages/ui/useMentionWakePreflight.test.mjs b/desktop/src/features/messages/ui/useMentionWakePreflight.test.mjs new file mode 100644 index 0000000000..7de2484320 --- /dev/null +++ b/desktop/src/features/messages/ui/useMentionWakePreflight.test.mjs @@ -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("", { + 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); +}); diff --git a/desktop/src/features/messages/ui/useMentionWakePreflight.ts b/desktop/src/features/messages/ui/useMentionWakePreflight.ts new file mode 100644 index 0000000000..f8a3c45097 --- /dev/null +++ b/desktop/src/features/messages/ui/useMentionWakePreflight.ts @@ -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; + 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; + enabled: boolean; + expectedRelayUrl?: string; + expectedSignerPubkey?: string; + getDraftMentionRefs: (content: string) => DraftMentionRef[]; + getManagedAgentsByPubkey: () => Promise>; + isManagedAgentPubkey: (pubkey: string) => boolean; + memberPubkeys: ReadonlySet; + startManagedAgent: (input: ManagedAgentStartInput) => Promise; +}) { + const timerRef = React.useRef | null>(null); + const activePlanKeyRef = React.useRef(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]); +} diff --git a/desktop/src/features/messages/ui/useSingleFlightManagedAgentStart.test.mjs b/desktop/src/features/messages/ui/useSingleFlightManagedAgentStart.test.mjs new file mode 100644 index 0000000000..955f4803bd --- /dev/null +++ b/desktop/src/features/messages/ui/useSingleFlightManagedAgentStart.test.mjs @@ -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)); +}); diff --git a/desktop/src/features/messages/ui/useSingleFlightManagedAgentStart.ts b/desktop/src/features/messages/ui/useSingleFlightManagedAgentStart.ts new file mode 100644 index 0000000000..0fd1f30386 --- /dev/null +++ b/desktop/src/features/messages/ui/useSingleFlightManagedAgentStart.ts @@ -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, +) { + const promisesRef = React.useRef(new Map>()); + 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, + }; +} diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index edcf523e36..a596669804 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -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 ({