mirror of
https://github.com/block/buzz.git
synced 2026-11-02 07:22:55 +00:00
Prewake mentioned agents while composing
Signed-off-by: Matt Toohey <contact@matttoohey.com>
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
213
desktop/src/features/messages/ui/useMentionWakePreflight.ts
Normal file
213
desktop/src/features/messages/ui/useMentionWakePreflight.ts
Normal 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]);
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 ({
|
||||
|
||||
Reference in New Issue
Block a user