Skip to content
Open
51 changes: 51 additions & 0 deletions desktop/src/app/useTrayMenu.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import assert from "node:assert/strict";
import test from "node:test";

import { resolveTrayActivities, resolveTrayAgentName } from "./useTrayMenu.ts";

const REMOTE_AGENT_PUBKEY = "1".repeat(64);

test("resolveTrayAgentName uses a hydrated remote-agent profile", () => {
assert.equal(
resolveTrayAgentName({
knownAgentName: undefined,
profile: {
avatarUrl: null,
displayName: "Hermes",
isAgent: true,
nip05Handle: null,
ownerPubkey: "2".repeat(64),
},
pubkey: REMOTE_AGENT_PUBKEY,
}),
"Hermes",
);
});

test("resolveTrayActivities replaces a completed activity fallback after profile hydration", () => {
const activities = resolveTrayActivities({
activities: [
{
activityId: `recent:channel:${REMOTE_AGENT_PUBKEY}:1`,
agentName: "Agent 111111…111111",
agentPubkey: REMOTE_AGENT_PUBKEY,
channelId: "channel",
channelName: "hermes-acceptance",
elapsed: "1s",
},
],
knownAgentNames: new Map(),
profiles: {
[REMOTE_AGENT_PUBKEY]: {
avatarUrl: null,
displayName: "Hermes",
isAgent: true,
nip05Handle: null,
ownerPubkey: "2".repeat(64),
},
},
});

assert.equal(activities[0].agentName, "Hermes");
assert.equal("agentPubkey" in activities[0], false);
});
102 changes: 88 additions & 14 deletions desktop/src/app/useTrayMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import {
useManagedAgentsQuery,
useRelayAgentsQuery,
} from "@/features/agents/hooks";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { UserProfileSummary } from "@/shared/api/types";
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
import { useNow } from "@/shared/lib/useNow";
import { formatElapsed } from "@/features/agents/ui/agentSessionUtils";
Expand All @@ -23,12 +26,52 @@ type TrayAgentActivity = {
elapsed: string;
};

type TrayAgentActivityState = TrayAgentActivity & {
agentPubkey: string;
};

type TrayAction =
| { kind: "newChannel" }
| { kind: "openChannel"; channelId: string };

const MAX_RECENT_TRAY_ACTIVITIES = 5;

export function resolveTrayAgentName({
knownAgentName,
profile,
pubkey,
}: {
knownAgentName?: string;
profile?: Pick<UserProfileSummary, "displayName" | "name">;
pubkey: string;
}): string {
return (
profile?.displayName?.trim() ||
profile?.name?.trim() ||
knownAgentName?.trim() ||
`Agent ${truncatePubkey(pubkey)}`
);
}

export function resolveTrayActivities({
activities,
knownAgentNames,
profiles,
}: {
activities: TrayAgentActivityState[];
knownAgentNames: Map<string, string>;
profiles?: UserProfileLookup;
}): TrayAgentActivity[] {
return activities.map(({ agentPubkey, ...activity }) => ({
...activity,
agentName: resolveTrayAgentName({
knownAgentName: knownAgentNames.get(normalizePubkey(agentPubkey)),
profile: profiles?.[normalizePubkey(agentPubkey)],
pubkey: agentPubkey,
}),
}));
}

/**
* Keeps Buzz's native tray menu synchronized with active agent turns and
* forwards its navigation actions into the React app.
Expand All @@ -47,20 +90,40 @@ export function useTrayMenu({
const managedAgents = useManagedAgentsQuery().data;
const relayAgents = useRelayAgentsQuery().data;
const previousActivitiesRef = React.useRef(
new Map<string, TrayAgentActivity>(),
new Map<string, TrayAgentActivityState>(),
);
const [recentActivities, setRecentActivities] = React.useState<
TrayAgentActivity[]
TrayAgentActivityState[]
>([]);
const activityAgentPubkeys = React.useMemo(
() => [
...new Set(
[
...activeTurns.flatMap((turn) => turn.agentPubkeys),
...recentActivities.map((activity) => activity.agentPubkey),
].map((pubkey) => normalizePubkey(pubkey)),
),
],
[activeTurns, recentActivities],
);
const profiles = useUsersBatchQuery(activityAgentPubkeys, {
enabled: activityAgentPubkeys.length > 0,
}).data?.profiles;
const knownAgentNames = React.useMemo(
() =>
new Map(
[...(managedAgents ?? []), ...(relayAgents ?? [])].map((agent) => [
normalizePubkey(agent.pubkey),
agent.name,
]),
),
[managedAgents, relayAgents],
);

const activities = React.useMemo<TrayAgentActivity[]>(() => {
const activities = React.useMemo<TrayAgentActivityState[]>(() => {
const channelNames = new Map(
channels.map((channel) => [channel.id, channel.name]),
);
const agentNames = new Map<string, string>();
for (const agent of [...(managedAgents ?? []), ...(relayAgents ?? [])]) {
agentNames.set(normalizePubkey(agent.pubkey), agent.name);
}

return activeTurns.flatMap((channelTurn) =>
channelTurn.agentPubkeys.map((pubkey) => {
Expand All @@ -70,9 +133,12 @@ export function useTrayMenu({

return {
activityId: `${channelTurn.channelId}:${normalizePubkey(pubkey)}`,
agentName:
agentNames.get(normalizePubkey(pubkey)) ??
`Agent ${truncatePubkey(pubkey)}`,
agentPubkey: pubkey,
agentName: resolveTrayAgentName({
knownAgentName: knownAgentNames.get(normalizePubkey(pubkey)),
profile: profiles?.[normalizePubkey(pubkey)],
pubkey,
}),
channelId: channelTurn.channelId,
channelName:
channelNames.get(channelTurn.channelId) ?? "Unknown channel",
Expand All @@ -82,7 +148,7 @@ export function useTrayMenu({
};
}),
);
}, [activeTurns, channels, managedAgents, now, relayAgents]);
}, [activeTurns, channels, knownAgentNames, now, profiles]);

React.useEffect(() => {
const currentActivities = new Map(
Expand All @@ -109,12 +175,20 @@ export function useTrayMenu({
React.useEffect(() => {
if (!isTauri()) return;
void invoke("update_tray_agent_activity", {
activities,
recentActivities,
activities: resolveTrayActivities({
activities,
knownAgentNames,
profiles,
}),
recentActivities: resolveTrayActivities({
activities: recentActivities,
knownAgentNames,
profiles,
}),
}).catch((error) => {
console.error("Failed to update the macOS tray menu", error);
});
}, [activities, recentActivities]);
}, [activities, knownAgentNames, profiles, recentActivities]);

React.useEffect(() => {
if (!isTauri()) return;
Expand Down
48 changes: 48 additions & 0 deletions desktop/src/features/agents/activeAgentTurnsStore.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
getAgentTranscript,
subscribeAgentObserverStore,
subscribeAgentManagementRequests,
syncAgentObserverEvents,
resetAgentObserverStore,
_testProcessLiveObserverEvents,
} from "./observerRelayStore.ts";
Expand Down Expand Up @@ -1719,6 +1720,34 @@ describe("observer → active-turns bridge sync", () => {
assert.equal(observerNotifications, 1);
});

it("does not re-dispatch management callbacks for duplicate replay", () => {
const managementFrame = makeEvent({
seq: 2,
kind: "acp_message",
timestamp: "2024-01-01T00:00:01Z",
payload: {
type: "agent_management_request",
action: "create",
requestId: "request-duplicate",
request: {
channelId: "chan-1",
displayName: "Fleet Observer",
systemPrompt: "Observe the fleet.",
},
},
});
let callbacks = 0;
const unsubscribe = subscribeAgentManagementRequests(() => {
callbacks += 1;
});

_testProcessLiveObserverEvents(AGENT, [managementFrame]);
_testProcessLiveObserverEvents(AGENT, [managementFrame]);
unsubscribe();

assert.equal(callbacks, 1);
});

it("does not publish when a replay batch is entirely duplicate", () => {
const events = [makeEvent({ seq: 1, kind: "turn_started" })];
injectObserverEventsForE2E(AGENT, events);
Expand All @@ -1734,6 +1763,25 @@ describe("observer → active-turns bridge sync", () => {
assert.equal(getAgentObserverSnapshot(AGENT, true).events.length, 1);
});

it("does not publish duplicate decoded replay through the sync bridge", () => {
const events = [
makeEvent({
seq: 41,
timestamp: "2024-01-01T00:00:41Z",
kind: "turn_started",
}),
];
syncAgentObserverEvents(AGENT, events);
let observerNotifications = 0;
const unsubscribeObserver = subscribeAgentObserverStore(() => {
observerNotifications += 1;
});
syncAgentObserverEvents(AGENT, events);
unsubscribeObserver();

assert.equal(observerNotifications, 0);
});

it("skips agents that are neither running nor deployed", () => {
injectObserverEventsForE2E(AGENT, [
makeEvent({ seq: 1, kind: "turn_started" }),
Expand Down
Loading