Skip to content

Commit 2e7583b

Browse files
wesbillmanCarl
andauthored
fix(desktop): distinguish duplicate agent devices (#6337)
## Summary - distinguish same-name owned agents by management provenance: `managed here` for Desktop-managed identities and `managed elsewhere` for same-owner relay identities - show provenance only when same-name suggestions collide, alongside each identity's short npub - preserve exact-pubkey selection and keep unique-agent autocomplete unchanged - add a composed mock-bridge regression covering relay owner propagation, rendered labels, keyboard/pointer selection, and outbound mention pubkeys ## Testing - focused mention suggestion mapping and label tests - composed Desktop E2E passes for both same-name identities and exact-pubkey routing - causal mutation verified: replacing the relay candidate's `ownerPubkey` with `null` makes the composed E2E fail on the `managed elsewhere` assertion - pre-push Desktop checks: Biome, TypeScript, file-size ratchet, and 5,103 Desktop tests ## Manual test With two same-name owned agents visible in a channel, type `@<name>`. Duplicate rows identify the identities as `agent · managed here` and `agent · managed elsewhere`, include distinct short npubs, and selecting either routes the mention to that row's exact pubkey. --------- Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
1 parent e5d1dfe commit 2e7583b

6 files changed

Lines changed: 219 additions & 6 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
4+
import { mapMentionCandidateToSuggestion } from "./mentionSuggestionMapping.ts";
5+
6+
const OWNER = "a".repeat(64);
7+
8+
function candidate(overrides = {}) {
9+
return {
10+
kind: "identity",
11+
pubkey: "b".repeat(64),
12+
isAgent: true,
13+
isMember: true,
14+
ownerPubkey: OWNER,
15+
...overrides,
16+
};
17+
}
18+
19+
function suggestion(overrides = {}) {
20+
return mapMentionCandidateToSuggestion({
21+
candidate: candidate(overrides),
22+
currentPubkey: OWNER,
23+
label: "Carl",
24+
});
25+
}
26+
27+
test("labels Desktop-managed agent identities as managed here", () => {
28+
assert.equal(
29+
suggestion({ isManagedAgent: true }).agentProvenance,
30+
"managed-here",
31+
);
32+
});
33+
34+
test("labels same-owner relay agent identities as managed elsewhere", () => {
35+
assert.equal(suggestion().agentProvenance, "managed-elsewhere");
36+
});
37+
38+
test("does not attribute another owner's agent to a device", () => {
39+
assert.equal(
40+
suggestion({ ownerPubkey: "c".repeat(64) }).agentProvenance,
41+
undefined,
42+
);
43+
});
44+
45+
test("does not attribute people or personas to a device", () => {
46+
assert.equal(suggestion({ isAgent: false }).agentProvenance, undefined);
47+
assert.equal(
48+
suggestion({ kind: "persona", pubkey: undefined }).agentProvenance,
49+
undefined,
50+
);
51+
});

desktop/src/features/messages/lib/mentionSuggestionMapping.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export type MentionSuggestionCandidate = {
1313
teamMembers?: TeamMentionMember[];
1414
avatarUrl?: string | null;
1515
isAgent: boolean;
16+
isManagedAgent?: boolean;
1617
isMember: boolean;
1718
role?: ChannelRole | null;
1819
ownerPubkey?: string | null;
@@ -52,6 +53,17 @@ export function mapMentionCandidateToSuggestion(opts: {
5253
: null) ??
5354
null,
5455
isAgent: candidate.isAgent,
56+
agentProvenance:
57+
candidate.kind === "identity" && candidate.isAgent
58+
? candidate.isManagedAgent
59+
? "managed-here"
60+
: candidate.ownerPubkey &&
61+
currentPubkey &&
62+
normalizePubkey(candidate.ownerPubkey) ===
63+
normalizePubkey(currentPubkey)
64+
? "managed-elsewhere"
65+
: undefined
66+
: undefined,
5567
notInChannel:
5668
candidate.kind !== "team" &&
5769
channelType !== "dm" &&

desktop/src/features/messages/lib/useMentions.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,7 @@ export function useMentions(
349349
personaId:
350350
managedAgentPersonaIdsByPubkey.get(pubkey) ??
351351
(activePersonaById.has(pubkey) ? pubkey : undefined),
352-
ownerPubkey: null,
352+
ownerPubkey: agent.ownerPubkey,
353353
isAgent: true,
354354
});
355355
}
@@ -515,13 +515,14 @@ export function useMentions(
515515
searchableNamesLowerRef.current = searchableNamesLower;
516516
}, [searchableNamesLower]);
517517

518-
React.useEffect(() => {
519-
return () => {
518+
React.useEffect(
519+
() => () => {
520520
if (debounceTimerRef.current !== null) {
521521
clearTimeout(debounceTimerRef.current);
522522
}
523-
};
524-
}, []);
523+
},
524+
[],
525+
);
525526

526527
const matchingSuggestions = React.useMemo<MentionSuggestion[]>(() => {
527528
if (mentionQuery === null) {
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
4+
import { mentionAgentLabel } from "./MentionAutocomplete.tsx";
5+
6+
function suggestion(agentProvenance) {
7+
return {
8+
pubkey: "1".repeat(64),
9+
displayName: "Carl",
10+
isAgent: true,
11+
agentProvenance,
12+
};
13+
}
14+
15+
test("duplicate owned agents show their management provenance", () => {
16+
assert.equal(
17+
mentionAgentLabel(suggestion("managed-here"), true),
18+
"agent · managed here",
19+
);
20+
assert.equal(
21+
mentionAgentLabel(suggestion("managed-elsewhere"), true),
22+
"agent · managed elsewhere",
23+
);
24+
});
25+
26+
test("unique agents keep the compact generic label", () => {
27+
assert.equal(mentionAgentLabel(suggestion("managed-here"), false), "agent");
28+
});
29+
30+
test("agents without trustworthy provenance keep the generic label", () => {
31+
assert.equal(mentionAgentLabel(suggestion(undefined), true), "agent");
32+
});

desktop/src/features/messages/ui/MentionAutocomplete.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export type MentionSuggestion = {
2222
displayName: string;
2323
avatarUrl?: string | null;
2424
isAgent?: boolean;
25+
agentProvenance?: "managed-here" | "managed-elsewhere";
2526
notInChannel?: boolean;
2627
ownerLabel?: string | null;
2728
role?: string | null;
@@ -35,6 +36,16 @@ type MentionAutocompleteProps = {
3536
position?: "above" | "below";
3637
};
3738

39+
export function mentionAgentLabel(
40+
suggestion: MentionSuggestion,
41+
hasNameCollision: boolean,
42+
) {
43+
if (!hasNameCollision || !suggestion.agentProvenance) return "agent";
44+
return suggestion.agentProvenance === "managed-here"
45+
? "agent · managed here"
46+
: "agent · managed elsewhere";
47+
}
48+
3849
export const MentionAutocomplete = React.memo(function MentionAutocomplete({
3950
suggestions,
4051
selectedIndex,
@@ -100,9 +111,9 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
100111
(suggestion.personaId ? `persona-${suggestion.personaId}` : null) ??
101112
(suggestion.teamId ? `team-${suggestion.teamId}` : null) ??
102113
suggestion.displayName;
103-
const agentLabel = "agent";
104114
const hasNameCollision =
105115
(nameCounts.get(suggestion.displayName.toLowerCase()) ?? 0) > 1;
116+
const agentLabel = mentionAgentLabel(suggestion, hasNameCollision);
106117
const collisionNpub =
107118
hasNameCollision && suggestion.pubkey
108119
? safeNpub(suggestion.pubkey)

desktop/tests/e2e/mentions.spec.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,112 @@ test("@ trigger prioritizes channel members before runnable personas and other m
320320
expect(fizzIndex).toBeLessThan(charlieIndex);
321321
});
322322

323+
test("duplicate owned agents preserve provenance and exact pubkey selection", async ({
324+
page,
325+
}) => {
326+
const managedPubkey = IN_CHANNEL_MANAGED_AGENT_PUBKEY;
327+
const relayPubkey = ALLOWLIST_RELAY_AGENT_PUBKEY;
328+
await installMockBridge(page, {
329+
managedAgents: [
330+
{
331+
pubkey: managedPubkey,
332+
name: "carl",
333+
status: "running",
334+
channelNames: ["general"],
335+
backend: {
336+
type: "provider",
337+
id: "mock",
338+
config: {},
339+
},
340+
},
341+
],
342+
relayAgents: [
343+
{
344+
pubkey: relayPubkey,
345+
ownerPubkey: MOCK_VIEWER_PUBKEY,
346+
name: "carl",
347+
respondTo: "owner-only",
348+
channelNames: ["general"],
349+
},
350+
],
351+
});
352+
await page.goto("/");
353+
await page.getByTestId("channel-general").click();
354+
await page.evaluate(
355+
async ({ channelId, pubkey }) => {
356+
const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
357+
if (!invoke) throw new Error("Mock bridge is not installed.");
358+
await invoke("add_channel_members", {
359+
channelId,
360+
pubkeys: [pubkey],
361+
role: "bot",
362+
});
363+
await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({
364+
queryKey: ["channels"],
365+
});
366+
},
367+
{ channelId: GENERAL_CHANNEL_ID, pubkey: relayPubkey },
368+
);
369+
370+
const input = page.getByTestId("message-input");
371+
await input.fill("@carl");
372+
const dropdown = autocomplete(page);
373+
const managedRow = dropdown.getByTestId(
374+
`mention-suggestion-${managedPubkey}`,
375+
);
376+
const relayRow = dropdown.getByTestId(`mention-suggestion-${relayPubkey}`);
377+
await expect(managedRow).toContainText("agent · managed here");
378+
await expect(relayRow).toContainText("agent · managed elsewhere");
379+
380+
const collisionKeys = dropdown.getByTestId("mention-collision-npub");
381+
await expect(collisionKeys).toHaveCount(2);
382+
const fullNpubs = await collisionKeys.evaluateAll((nodes) =>
383+
nodes.map((node) => node.getAttribute("title")),
384+
);
385+
expect(fullNpubs).toHaveLength(2);
386+
expect(new Set(fullNpubs).size).toBe(2);
387+
388+
const initialRows = dropdown.locator("button");
389+
const managedIndex = await initialRows.evaluateAll(
390+
(buttons, pubkey) =>
391+
buttons.findIndex(
392+
(button) =>
393+
button.getAttribute("data-testid") === `mention-suggestion-${pubkey}`,
394+
),
395+
managedPubkey,
396+
);
397+
expect(managedIndex).toBeGreaterThanOrEqual(0);
398+
for (let index = 0; index < managedIndex; index += 1) {
399+
await input.press("ArrowDown");
400+
}
401+
await input.press("Enter");
402+
await page.keyboard.type("local");
403+
await page.getByTestId("send-message").click();
404+
await expect
405+
.poll(() => readOutgoingMentionPubkeys(page, "@carl local"))
406+
.toEqual([managedPubkey]);
407+
await expect(input).toBeEmpty();
408+
409+
await input.fill("@carl");
410+
const reopenedDropdown = autocomplete(page);
411+
await expect(reopenedDropdown).toBeVisible();
412+
await reopenedDropdown
413+
.getByTestId(`mention-suggestion-${relayPubkey}`)
414+
.click();
415+
await page.keyboard.type("remote");
416+
await page.getByTestId("send-message").click();
417+
const sendWithoutInviting = page.getByRole("button", { name: "Do nothing" });
418+
try {
419+
await sendWithoutInviting.waitFor({ state: "visible", timeout: 2_000 });
420+
await sendWithoutInviting.click();
421+
} catch {
422+
// In-channel selections send immediately without opening the prompt.
423+
}
424+
await expect
425+
.poll(() => readOutgoingMentionPubkeys(page, "@carl remote"))
426+
.toEqual([relayPubkey]);
427+
});
428+
323429
test("relay-only shared agents emit an outbound mention tag when selected", async ({
324430
page,
325431
}) => {

0 commit comments

Comments
 (0)