fix(desktop): revalidate current access and exact team members

Package existing reviewed repairs in the authorized seven-slice dependency stack. Preserved model A; experiment excluded.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-08 13:20:41 -04:00
parent bb8cca21e1
commit 85ac1bc6a2
8 changed files with 672 additions and 11 deletions

View File

@@ -0,0 +1,476 @@
// Admission against existing root query evidence, without membership freshness production.
// Real mention and picker hooks; Tauri policy/classification are fixture evidence.
import assert from "node:assert/strict";
import { after, afterEach, before, test } from "node:test";
import { JSDOM } from "jsdom";
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});
Object.assign(globalThis, {
window: dom.window,
document: dom.window.document,
localStorage: dom.window.localStorage,
HTMLElement: dom.window.HTMLElement,
HTMLIFrameElement: dom.window.HTMLIFrameElement,
MutationObserver: dom.window.MutationObserver,
IS_REACT_ACT_ENVIRONMENT: true,
self: dom.window,
});
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: dom.window.navigator,
});
dom.window.requestAnimationFrame = (callback) => setTimeout(callback, 0);
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame;
const VIEWER = "a".repeat(64),
AGENT = "b".repeat(64),
OTHER = "c".repeat(64);
const CHANNEL = "11111111-1111-4111-8111-111111111111";
localStorage.setItem(
"buzz-communities",
JSON.stringify([
{
id: "test",
name: "Test",
relayUrl: "ws://test.invalid",
addedAt: "2026-01-01T00:00:00Z",
},
]),
);
localStorage.setItem("buzz-active-community-id", "test");
let state;
const channel = () => ({
id: CHANNEL,
name: "fresh",
channel_type: "stream",
visibility: "open",
description: "",
is_member: true,
archived_at: null,
member_pubkeys: state.visible ? [VIEWER, AGENT] : [VIEWER],
member_count: state.visible ? 2 : 1,
participant_pubkeys: [],
participants: [],
last_message_at: null,
ttl_seconds: null,
ttl_deadline: null,
});
const rawAgent = () => ({
pubkey: AGENT,
owner_pubkey: state.owner,
name: "Remote Scout",
agent_type: "agent",
channels: [],
channel_ids: state.directoryVisible ? [CHANNEL] : [],
capabilities: [],
status: "offline",
respond_to: state.policy,
respond_to_allowlist: [],
});
const invoke = async (command, args) => {
if (command.startsWith("plugin:event|")) return 0;
if (command === "search_users") {
return { users: [], next_cursor: null };
}
if (command === "get_identity") return { pubkey: VIEWER };
if (command === "create_channel") return channel();
if (command === "get_channels")
return {
channels: [channel()],
hash: String(state.visible),
last_messages: [],
};
if (command === "get_channel_members" && state.heldRoster)
return state.heldRoster;
if (command === "get_channel_members")
return {
members: [
{
pubkey: VIEWER,
role: "owner",
display_name: "Viewer",
is_agent: false,
},
...(state.visible
? [
{
pubkey: AGENT,
role: state.role,
display_name: "Remote Scout",
is_agent: true,
},
]
: []),
],
};
if (command === "add_channel_members") {
assert.equal(args.channelId, CHANNEL);
assert.equal(args.role, state.role);
state.accepted = true;
return state.addResult;
}
if (command === "sync_agents_to_active_huddle") return null;
if (command === "list_relay_agents") {
state.directoryCalls += 1;
if (state.failDirectory) throw new Error("Directory unavailable");
return state.missingDirectory ? [] : [rawAgent()];
}
if (command === "revalidate_relay_agents")
return state.missingDirectory ? [] : [rawAgent()];
if (["list_managed_agents", "list_personas", "list_teams"].includes(command))
return [];
if (command === "get_users_batch") return { profiles: {}, missing: [] };
if (command === "list_archived_identities") return { archived: [] };
throw new Error(`Unexpected IPC: ${command}`);
};
globalThis.__TAURI_INTERNALS__ = { invoke, transformCallback: () => 1 };
dom.window.__TAURI_INTERNALS__ = globalThis.__TAURI_INTERNALS__;
globalThis.__TAURI_EVENT_PLUGIN_INTERNALS__ = { unregisterListener: () => {} };
dom.window.__TAURI_EVENT_PLUGIN_INTERNALS__ =
globalThis.__TAURI_EVENT_PLUGIN_INTERNALS__;
let React,
act,
createRoot,
QueryClient,
QueryClientProvider,
CommunitiesProvider;
let useMentions;
let root, client, mention, picker;
let useAgentAddressLockPicker, effects;
before(async () => {
({ useAgentAddressLockPicker } = await import(
"@/features/messages/ui/useAgentAddressLockPicker.ts"
));
({ default: React, act } = await import("react"));
({ createRoot } = await import("react-dom/client"));
({ QueryClient, QueryClientProvider } = await import(
"@tanstack/react-query"
));
({ CommunitiesProvider } = await import(
"@/features/communities/useCommunities.tsx"
));
({ useMentions } = await import("@/features/messages/lib/useMentions.ts"));
});
function Composer() {
mention = useMentions(state.channelId, undefined, undefined, {
channelType: "stream",
});
picker = useAgentAddressLockPicker({
mentions: mention,
audience: {
pubkeys: state.locked,
addPubkey: (key) => effects.push(["pin", key]),
removePubkey: (key) => effects.push(["remove", key]),
},
audienceScope: state.channelId,
richText: { getPlainTextAndCursor: () => ({ text: "@", cursor: 1 }) },
applyAutocompleteEdit: (edit) => effects.push(["edit", edit]),
onAddressAgentMention: (row) => effects.push(["promote", row.pubkey]),
onPulseAddressLock: () => {},
});
return null;
}
async function render(withComposer = true) {
await act(async () =>
root.render(
React.createElement(
QueryClientProvider,
{ client },
React.createElement(
CommunitiesProvider,
null,
withComposer ? React.createElement(Composer) : null,
),
),
),
);
}
async function settle() {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 300));
});
// React Query notification batching may be enqueued by effects committed above.
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
}
const rows = () => mention.suggestions.filter((row) => row.pubkey === AGENT);
async function setup(overrides = {}) {
effects = [];
state = {
locked: [],
channelId: CHANNEL,
role: "bot",
owner: VIEWER,
policy: "anyone",
accepted: false,
visible: false,
directoryVisible: false,
directoryCalls: 0,
addResult: { added: [AGENT], errors: [] },
...overrides,
};
client = new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: Infinity },
mutations: { retry: false, gcTime: Infinity },
},
});
for (const [key, data] of [
[["identity"], { pubkey: VIEWER }],
[["channels"], [channel()]],
[["managed-agents"], []],
[["personas"], []],
[["teams"], []],
[["archivedIdentities"], { archived: [] }],
])
client.setQueryData(key, data);
const container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await render();
await act(async () => mention.updateMentionQuery("@", 1));
await settle();
}
afterEach(async () => {
if (root) await act(async () => root.unmount());
client?.clear();
document.body.replaceChildren();
});
after(() => dom.window.close());
for (const change of [
"policy-denied",
"late-error",
"directory-removed",
"member-removed",
]) {
test(`a retained callback cannot bind after ${change}`, async () => {
await setup({ owner: OTHER, visible: true, directoryVisible: true });
const staleRow = rows()[0];
const staleInsert = mention.insertMention;
assert.equal(staleRow.isAgent, true);
assert.equal(mention.canSelectMention(staleRow), true);
if (change === "policy-denied") state.policy = "owner-only";
if (change === "late-error") state.failDirectory = true;
if (change.endsWith("removed")) state.missingDirectory = true;
if (change === "member-removed") {
state.visible = false;
await act(async () =>
client.invalidateQueries({
queryKey: ["channels", CHANNEL, "members"],
}),
);
}
await act(async () =>
client.invalidateQueries({ queryKey: ["relay-agents"] }),
);
await settle();
let edit;
await act(async () => {
edit = staleInsert(staleRow, 1);
});
assert.equal(
edit.insertText,
"",
"old actionable row must not establish intent",
);
assert.deepEqual(mention.knownNames, []);
});
}
test("only an exact current target can be selected", async () => {
await setup({ visible: true, directoryVisible: true });
assert.equal(mention.canSelectMention(rows()[0]), true);
for (const target of [
{ displayName: "Remote Scout" },
{ displayName: "Remote Scout", pubkey: OTHER },
]) {
assert.equal(mention.canSelectMention(target), false);
let edit;
await act(async () => {
edit = mention.insertMention(target, 1);
});
assert.equal(edit.insertText, "");
}
assert.deepEqual(mention.knownNames, []);
});
// These exercise the real sibling picker + mention hook, not an admission stub.
test("retained explicit pin rejects latest policy denial without draft effects", async () => {
await setup({ owner: OTHER, visible: true, directoryVisible: true });
const row = rows()[0],
oldPin = picker.toggleAlwaysAddressAgent;
state.policy = "owner-only";
await act(async () =>
client.invalidateQueries({ queryKey: ["relay-agents"] }),
);
await settle();
assert.equal(rows().length, 0);
await act(async () => oldPin(row));
assert.deepEqual(effects, []);
assert.deepEqual(mention.knownNames, []);
});
for (const returnToOrigin of [false, true]) {
test(`retained pin and insertion reject another scope visit (return=${returnToOrigin})`, async () => {
await setup({ visible: true, directoryVisible: true });
const row = rows()[0],
oldPin = picker.toggleAlwaysAddressAgent;
const oldInsert = mention.insertMention;
const oldSelect = picker.selectMentionSuggestion;
state.channelId = "22222222-2222-4222-8222-222222222222";
await render();
if (returnToOrigin) {
state.channelId = CHANNEL;
await render();
}
let edit;
await act(async () => {
oldPin(row);
oldSelect(row);
edit = oldInsert(row, 1);
});
assert.deepEqual(effects, []);
assert.equal(edit.insertText, "");
assert.deepEqual(mention.knownNames, []);
});
}
test("latest locked state permits removal after denial, including a retained toggle", async () => {
await setup({ owner: OTHER, visible: true, directoryVisible: true });
const row = rows()[0],
oldPin = picker.toggleAlwaysAddressAgent;
state.locked = [AGENT];
state.policy = "owner-only";
await act(async () =>
client.invalidateQueries({ queryKey: ["relay-agents"] }),
);
await settle();
assert.equal(mention.canSelectMention(row), false);
await act(async () => oldPin(row));
assert.ok(
effects.some(([effect, key]) => effect === "remove" && key === AGENT),
);
assert.ok(
effects.every(
([effect, edit]) =>
effect === "remove" || (effect === "edit" && edit.insertText === ""),
),
);
assert.deepEqual(mention.knownNames, []);
});
test("retained team cannot bind a removed exact member", async () => {
await setup({ visible: true, directoryVisible: true });
const persona = {
id: "review-scout",
displayName: "Remote Scout",
isActive: true,
};
const team = {
id: "team-review",
name: "Review Team",
isBuiltin: false,
personaIds: [persona.id],
};
await act(async () => {
client.setQueryData(["personas"], [persona]);
client.setQueryData(
["managed-agents"],
[
{
pubkey: AGENT,
name: "Remote Scout",
personaId: persona.id,
status: "running",
},
],
);
client.setQueryData(["teams"], [team]);
});
await settle();
const row = mention.suggestions.find((s) => s.kind === "team");
assert.ok(row, JSON.stringify(mention.suggestions));
assert.equal(row.teamMembers[0].pubkey, AGENT);
const old = mention.insertMention;
state.missingDirectory = true;
await act(async () => client.setQueryData(["managed-agents"], []));
await act(async () =>
client.invalidateQueries({ queryKey: ["relay-agents"] }),
);
await settle();
assert.equal(
mention.suggestions.some((s) => s.pubkey === AGENT),
false,
);
assert.ok(mention.suggestions.find((s) => s.kind === "team"));
let edit;
await act(async () => {
edit = old(row, 1);
});
assert.deepEqual(mention.knownNames, []);
assert.deepEqual(mention.getDraftMentionRefs(edit.insertText), []);
assert.equal(
edit.insertText,
"",
"removed exact team member must not establish intent",
);
});
test("duplicate team members cannot mask a recipient set change", async () => {
await setup({ visible: true, directoryVisible: true });
const personas = ["one", "two"].map((id) => ({
id,
displayName: id,
isActive: true,
}));
const team = {
id: "duplicates",
name: "Duplicates",
isBuiltin: false,
personaIds: ["one", "one"],
};
await act(async () => {
client.setQueryData(["personas"], personas);
client.setQueryData(
["managed-agents"],
personas.map((p, i) => ({
pubkey: i ? OTHER : AGENT,
name: p.displayName,
personaId: p.id,
status: "running",
})),
);
client.setQueryData(["teams"], [team]);
});
await settle();
const row = mention.suggestions.find((s) => s.kind === "team");
assert.ok(row);
assert.equal(
new Set(row.teamMembers.map((m) => m.pubkey ?? m.personaId)).size,
1,
);
const insert = mention.insertMention;
await act(async () =>
client.setQueryData(["teams"], [{ ...team, personaIds: ["one", "two"] }]),
);
await settle();
assert.equal(
new Set(
mention.suggestions
.find((s) => s.kind === "team")
.teamMembers.map((m) => m.pubkey ?? m.personaId),
).size,
2,
);
let edit;
await act(async () => {
edit = insert(row, 1);
});
assert.equal(edit.insertText, "");
assert.deepEqual(mention.knownNames, []);
assert.deepEqual(mention.getDraftMentionRefs(edit.insertText), []);
});

View File

@@ -4,6 +4,7 @@ import test from "node:test";
import {
buildTeamMentionCandidates,
formatTeamMention,
sameTeamMentionRecipients,
} from "./mentionCandidates.ts";
function persona(id, displayName, isActive = true) {
@@ -171,3 +172,27 @@ test("teams with identity and persona display-name collisions are not suggested"
[],
);
});
test("team recipient equality ignores multiplicity, order and names, not exact identity", () => {
const a = { kind: "identity", displayName: "A", pubkey: "ab".repeat(32) };
const b = { ...a, pubkey: "cd".repeat(32) };
const p = { kind: "persona", displayName: "A", personaId: "a" };
for (const [left, right, equal] of [
[[], [], true],
[[a], [], false],
[[], [a], false],
[[a, a], [a, b], false],
[[a, b], [a, a], false],
[[a, a], [a], true],
[[a], [a, a], true],
[
[a, b],
[b, { ...a, displayName: "Renamed", pubkey: a.pubkey.toUpperCase() }],
true,
],
[[p, p], [p], true],
[[p], [{ ...p, personaId: "b" }], false],
[[p], [{ ...p, pubkey: a.pubkey }], false],
])
assert.equal(sameTeamMentionRecipients(left, right), equal);
});

View File

@@ -1,3 +1,4 @@
import type { MentionAction } from "./mentionPresentation";
import { resolveTeamPersonas } from "@/features/agents/lib/teamPersonas";
import type {
AgentPersona,
@@ -5,7 +6,7 @@ import type {
ChannelRole,
UserSearchResult,
} from "@/shared/api/types";
import { truncatePubkey } from "@/shared/lib/pubkey";
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
export function formatSearchUserDisplayName(user: UserSearchResult) {
return user.displayName?.trim() || user.nip05Handle?.trim() || null;
@@ -33,6 +34,7 @@ export type TeamMentionMember = {
};
export type MentionCandidate = {
action?: MentionAction;
kind: "identity" | "persona" | "team";
pubkey?: string;
personaId?: string;
@@ -157,3 +159,20 @@ export function formatTeamMention(
) {
return `${teamName}(${members.map((member) => `@${member.displayName}`).join(" ")}) `;
}
/** Compare exact team recipient sets; duplicate members and presentation order are irrelevant. */
export function sameTeamMentionRecipients(
selected: readonly TeamMentionMember[],
current: readonly TeamMentionMember[] = [],
): boolean {
const identity = (member: TeamMentionMember) =>
member.pubkey
? `key:${normalizePubkey(member.pubkey)}`
: `persona:${member.personaId}`;
const selectedSet = new Set(selected.map(identity));
const currentSet = new Set(current.map(identity));
return (
selectedSet.size === currentSet.size &&
[...selectedSet].every((key) => currentSet.has(key))
);
}

View File

@@ -0,0 +1,6 @@
/** Presentation only. Publication still performs fresh authorization. */
export type MentionAction = "mention" | "invite" | "checking" | "unavailable";
export function isMentionActionable(candidate: { action?: MentionAction }) {
return candidate.action !== "checking" && candidate.action !== "unavailable";
}

View File

@@ -1,3 +1,4 @@
import { isMentionActionable } from "./mentionPresentation";
import * as React from "react";
import {
useManagedAgentsQuery,
@@ -58,6 +59,7 @@ import {
appendUniqueName,
buildTeamMentionCandidates,
formatTeamMention,
sameTeamMentionRecipients,
type MentionCandidate,
} from "./mentionCandidates";
import { buildMentionCandidates } from "./buildMentionCandidates";
@@ -447,12 +449,75 @@ export function useMentions(
const { mentionSelectedIndex, setMentionSelectedIndex: setSelected } =
mentionSelection;
const isMentionOpen = mentionQuery !== null && suggestions.length > 0;
// Recheck against this render's exact-key evidence even if a child retained
// an older row/callback. A rejected selection must not establish draft intent.
const admissionScope = React.useMemo(
() => ({ currentPubkey, channelId }),
[currentPubkey, channelId],
);
const admissionRef = React.useRef({
scope: admissionScope,
candidates: mentionCandidatesWithTeams,
});
admissionRef.current = {
scope: admissionScope,
candidates: mentionCandidatesWithTeams,
};
const canSelectMention = React.useCallback(
(suggestion: MentionSuggestion) => {
const current = admissionRef.current.candidates.find((candidate) =>
suggestion.pubkey
? candidate.pubkey === normalizePubkey(suggestion.pubkey)
: suggestion.teamId
? candidate.teamId === suggestion.teamId
: !!suggestion.personaId &&
candidate.personaId === suggestion.personaId,
);
return (
admissionRef.current.scope === admissionScope &&
!!current &&
(current.kind !== "team" ||
(suggestion.kind === "team" &&
!!suggestion.teamMembers?.length &&
sameTeamMentionRecipients(
suggestion.teamMembers,
current.teamMembers,
) &&
suggestion.teamMembers.every((member) => {
const matches = (target: {
pubkey?: string;
personaId?: string | null;
}) =>
member.pubkey
? target.pubkey === normalizePubkey(member.pubkey)
: !!member.personaId &&
!target.pubkey &&
target.personaId === member.personaId;
return (
current.teamMembers?.some(matches) &&
admissionRef.current.candidates.some(
(target) => matches(target) && isMentionActionable(target),
)
);
}))) &&
isMentionActionable(current) &&
isMentionActionable(suggestion)
);
},
[admissionScope],
);
const insertMention = React.useCallback(
(suggestion: MentionSuggestion, selectionEnd: number): AutocompleteEdit => {
if (debounceTimerRef.current !== null) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
if (!canSelectMention(suggestion))
return {
replaceFromOffset: selectionEnd,
replaceToOffset: selectionEnd,
insertText: "",
};
const [boundSuggestion] = selectedMentionLabels(
[suggestion],
mentionMapRef.current,
@@ -525,7 +590,7 @@ export function useMentions(
insertText,
};
},
[knownAgentPubkeys, mentionStartIndex, setSelected],
[canSelectMention, knownAgentPubkeys, mentionStartIndex, setSelected],
);
const registerMentionPubkey = React.useCallback(
(displayName: string, pubkey: string, options?: { isAgent?: boolean }) => {
@@ -837,6 +902,7 @@ export function useMentions(
],
);
return {
canSelectMention,
cancelMentionAutocomplete,
clearMentions,
getDefaultAgentSuggestion,

View File

@@ -1,3 +1,4 @@
import type { MentionAction } from "@/features/messages/lib/mentionPresentation";
import * as React from "react";
import { Bot, ChevronRight, Pin, Users } from "lucide-react";
import { OtherSetupAgentMarker } from "@/features/agents/ui/OtherSetupAgentMarker";
@@ -20,6 +21,7 @@ import { truncatePubkey } from "@/shared/lib/pubkey";
import { getPlatformKeysById } from "@/shared/lib/keyboard-shortcuts";
export type MentionSuggestion = {
action?: MentionAction;
pubkey?: string;
personaId?: string;
teamId?: string;

View File

@@ -43,6 +43,7 @@ test("always addressing an agent keeps autocomplete open, inserts the chip, adds
isInlineMentionSelection: () => false,
isMentionOpen: true,
openMentionPicker: (...args) => openPickerCalls.push(args),
canSelectMention: () => true,
registerMentionPubkey: () => {},
mentionStartIndex: text.lastIndexOf("@"),
};
@@ -117,6 +118,7 @@ test("always addressing a new agent delegates the first add for immediate confir
getMentionDisplayName: () => "Agent Ada",
isInlineMentionSelection: () => false,
isMentionOpen: false,
canSelectMention: () => true,
registerMentionPubkey: () => {},
},
onAddressAgentMention: (value) => addressedSuggestions.push(value),
@@ -156,6 +158,7 @@ test("toggling an addressed agent keeps autocomplete open and removes the lock",
},
],
getMentionDisplayName: () => "Agent Ada",
canSelectMention: () => true,
registerMentionPubkey: () => {},
mentionStartIndex: text.lastIndexOf("@"),
};
@@ -216,6 +219,7 @@ test("selecting an already addressed agent from the explicit picker pulses its b
cancelMentionAutocomplete: () => {},
getDraftMentionRefs: () => [],
getMentionDisplayName: () => "Agent Ada",
canSelectMention: () => true,
registerMentionPubkey: () => {},
isInlineMentionSelection: () => false,
insertMention: () => ({
@@ -275,6 +279,7 @@ test("selecting an agent from a typed query immediately auto-addresses it", asyn
cancelMentionAutocomplete: () => {},
getDraftMentionRefs: () => [],
getMentionDisplayName: () => "Agent Ada",
canSelectMention: () => true,
registerMentionPubkey: () => {},
isInlineMentionSelection: () => true,
insertMention: () => ({
@@ -342,6 +347,7 @@ test("selecting a human mention never changes automatic addressing", async () =>
audienceScope: "channel-scope",
mentions: {
getMentionDisplayName: () => "Alice",
isInlineMentionSelection: () => true,
insertMention: () => ({
replaceFromOffset: 0,
replaceToOffset: 3,
@@ -394,6 +400,7 @@ test("restoring a multi-word automatic mention into an empty composer focuses af
mentions: {
getDraftMentionRefs: () => [],
getMentionDisplayName: () => "claude code",
canSelectMention: () => true,
registerMentionPubkey: (...args) => {
registeredMentions.push(args);
return args[0];
@@ -443,6 +450,7 @@ test("restoring before authored text preserves its selection", async () => {
mentions: {
getDraftMentionRefs: () => [],
getMentionDisplayName: () => "Morgarita",
canSelectMention: () => true,
registerMentionPubkey: () => {},
},
onPulseAddressLock: () => {},
@@ -497,6 +505,7 @@ test("restoring an existing automatic mention re-registers its agent chip", asyn
]
: [],
getMentionDisplayName: () => "claude code",
canSelectMention: () => true,
registerMentionPubkey: (...args) => {
registeredMentions.push(args);
return args[0];
@@ -638,6 +647,7 @@ test("selecting an agent from the explicit picker auto-addresses it", async () =
cancelMentionAutocomplete: () => {},
getDraftMentionRefs: () => [],
getMentionDisplayName: () => "Agent Ada",
canSelectMention: () => true,
registerMentionPubkey: () => {},
isInlineMentionSelection: () => false,
insertMention: () => ({
@@ -704,6 +714,7 @@ test("repeatedly selecting an explicitly unpinned agent keeps its mentions manua
{ displayName: "Agent Ada", pubkey: "agent-pubkey", isAgent: true },
],
getMentionDisplayName: () => "Agent Ada",
canSelectMention: () => true,
registerMentionPubkey: () => {},
isInlineMentionSelection: () => true,
insertMention: () => ({
@@ -818,6 +829,7 @@ test("restoring after an agent rename keeps the existing automatic mention", asy
{ displayName: oldName, pubkey: "agent-pubkey", isAgent: true },
],
getMentionDisplayName: () => displayName,
canSelectMention: () => true,
registerMentionPubkey: (...args) => {
registeredMentions.push(args);
return args[0];
@@ -899,6 +911,7 @@ test("automatic mention insertion and restoration use the registered collision-s
snapshotDraftMentionRefs(value, bindings, [...bindings.keys()]),
getMentionDisplayName: (pubkey) =>
[...bindings].find(([, key]) => key === pubkey)?.[0] ?? "carl",
canSelectMention: () => true,
registerMentionPubkey: (name, pubkey) => {
const label = selectedMentionLabel(name, pubkey, bindings);
bindings.set(label, pubkey);
@@ -998,6 +1011,7 @@ test("inverse deletion and toggle preserve B and exclude A from the composed sen
snapshotDraftMentionRefs(value, bindings, [...bindings.keys()]),
getMentionDisplayName: (key) =>
[...bindings].find(([, k]) => k === key)?.[0],
canSelectMention: () => true,
registerMentionPubkey: (name, key) => {
const label = selectedMentionLabel(name, key, bindings);
bindings.set(label, key);
@@ -1097,3 +1111,39 @@ test("implicit prefix removal uses the present exact label rather than a stale a
act(() => result.current.removeAddressedAgent(key));
assert.equal(text, "hello");
});
test("rejected stale selection never pins, tracks, announces or edits", async () => {
const { act, renderHook } = await import("@testing-library/react");
const { useAgentAddressLockPicker } = await import(
"./useAgentAddressLockPicker.ts"
);
const effects = [];
const { result } = renderHook(() =>
useAgentAddressLockPicker({
applyAutocompleteEdit: () => effects.push("edit"),
audience: { pubkeys: [], addPubkey: () => effects.push("audience") },
audienceScope: "room",
mentions: {
getMentionDisplayName: () => "Scout",
isInlineMentionSelection: () => true,
insertMention: () => ({
replaceFromOffset: 1,
replaceToOffset: 1,
insertText: "",
}),
},
onAutoPinAgentMention: () => effects.push("pin"),
onPulseAddressLock: () => effects.push("pulse"),
richText: { getPlainTextAndCursor: () => ({ text: "@", cursor: 1 }) },
}),
);
act(() =>
result.current.selectMentionSuggestion({
pubkey: "a".repeat(64),
displayName: "Scout",
isAgent: true,
}),
);
assert.deepEqual(effects, []);
assert.equal(result.current.announcement, "");
});

View File

@@ -91,6 +91,10 @@ export function useAgentAddressLockPicker({
() => new Set(audience.pubkeys),
[audience.pubkeys],
);
// A retained callback belongs to one audience visit, including A -> B -> A.
const scopeOwner = React.useMemo(() => ({ audienceScope }), [audienceScope]);
const currentAudience = React.useRef({ scopeOwner, lockedAgentPubkeys });
currentAudience.current = { scopeOwner, lockedAgentPubkeys };
const unpinnedAgentPubkeysRef = React.useRef(new Set<string>());
const unpinnedAudienceScopeRef = React.useRef(audienceScope);
if (unpinnedAudienceScopeRef.current !== audienceScope) {
@@ -246,14 +250,21 @@ export function useAgentAddressLockPicker({
const toggleAlwaysAddressAgent = React.useCallback(
(suggestion: MentionSuggestion) => {
const pubkey = normalizePubkey(suggestion.pubkey ?? "");
if (!audienceScope || !pubkey || !suggestion.isAgent) return;
if (
!audienceScope ||
!pubkey ||
!suggestion.isAgent ||
currentAudience.current.scopeOwner !== scopeOwner
)
return;
if (lockedAgentPubkeys.has(pubkey)) {
if (currentAudience.current.lockedAgentPubkeys.has(pubkey)) {
removeAddressedAgentMentions(pubkey);
setAnnouncement(
`Stopped automatically mentioning ${suggestion.displayName}`,
);
} else {
if (!mentions.canSelectMention(suggestion)) return;
unpinnedAgentPubkeysRef.current.delete(pubkey);
const label =
mentions.registerMentionPubkey(suggestion.displayName, pubkey, {
@@ -320,13 +331,14 @@ export function useAgentAddressLockPicker({
applyAutocompleteEdit,
audience.addPubkey,
audienceScope,
lockedAgentPubkeys,
mentions.getDraftMentionRefs,
mentions.isInlineMentionSelection,
mentions.isMentionOpen,
mentions.mentionStartIndex,
mentions.openMentionPicker,
mentions.registerMentionPubkey,
mentions.canSelectMention,
scopeOwner,
onAddressAgentMention,
onImplicitPrefixInserted,
onPulseAddressLock,
@@ -338,14 +350,19 @@ export function useAgentAddressLockPicker({
const selectMentionSuggestion = React.useCallback(
(suggestion: MentionSuggestion) => {
if (currentAudience.current.scopeOwner !== scopeOwner) return;
const { cursor } = richText.getPlainTextAndCursor();
const wasInlineSelection = mentions.isInlineMentionSelection();
const edit = mentions.insertMention(suggestion, cursor);
// Rejected stale selections must not establish automatic audience intent.
if (!edit.insertText) return;
const pubkey = normalizePubkey(suggestion.pubkey ?? "");
if (suggestion.isAgent && pubkey && audienceScope) {
const { cursor } = richText.getPlainTextAndCursor();
const wasUnpinned =
!lockedAgentPubkeys.has(pubkey) &&
unpinnedAgentPubkeysRef.current.has(pubkey);
if (mentions.isInlineMentionSelection() || wasUnpinned) {
applyAutocompleteEdit(mentions.insertMention(suggestion, cursor));
if (wasInlineSelection || wasUnpinned) {
applyAutocompleteEdit(edit);
trackMentionAddressedAgent(pubkey);
onAutoPinAgentMention?.(suggestion, {
reinstateExcluded: !wasUnpinned,
@@ -353,7 +370,7 @@ export function useAgentAddressLockPicker({
return;
}
applyAutocompleteEdit(mentions.insertMention(suggestion, cursor));
applyAutocompleteEdit(edit);
if (!lockedAgentPubkeys.has(pubkey)) {
trackMentionAddressedAgent(pubkey);
if (onAddressAgentMention) {
@@ -369,8 +386,7 @@ export function useAgentAddressLockPicker({
return;
}
const { cursor } = richText.getPlainTextAndCursor();
applyAutocompleteEdit(mentions.insertMention(suggestion, cursor));
applyAutocompleteEdit(edit);
},
[
applyAutocompleteEdit,
@@ -379,6 +395,7 @@ export function useAgentAddressLockPicker({
lockedAgentPubkeys,
mentions.isInlineMentionSelection,
mentions.insertMention,
scopeOwner,
onAddressAgentMention,
onAutoPinAgentMention,
onPulseAddressLock,